A helper class to create a list of populated objects from an Airtable API call
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
This is very alpha. It's just an idea I was screwing around with in LINQPad. | |
Airtable might have a full-blown .NET API, for all I know. | |
Deane | |
July 19, 2017 | |
*/ | |
// Usage Example | |
// Call the method generically, passing in the custom type you wish to create | |
var airtable = new Airtable("myAccountId", "myApiKey"); | |
// You can add a convertor, keyed to the column name and the property name, which will convert from the string in the JSON to...whatever | |
// The first param is the Airtable column name, the second is the object property name. This convertor will only be run when assigning | |
// from that column to that property | |
// The convertor is a Func<string, object>. The incoming string is the raw field value from the JSON. Return whatever you want assigned. | |
airtable.AddConvertor("Last Name", "LastName", (value) => { | |
return $"{value} the Great!"; | |
}); | |
// Call GetObjects<YourClassName> with the name of the table where the data is | |
var people = airtable.GetObjects<Person>("myTableName"); | |
// There are a few events, in order: | |
// OnHttpResponse fires right after the HTTP call is returned. It gives you the raw JSON. You can | |
// inspect or modify it. | |
// Example: This will write the raw JSON response to a file, which is handy for debugging | |
airtable.OnHttpResponse += (sender, e) => { File.WriteAllText(@"C:\airtable.json", e.HttpResponse); }; | |
// OnTypeConversionFailure fires when a JSON string value cannot be converted to the proper type for property assignment | |
// Fires on either an error in the custom convertor function, or an error in Convert.ChangeType (for column/property combinations without a convertor) | |
// This gives you a chance to fix it by changing the value of e.FieldValue | |
// e.PropertyName: the name of the object property | |
// e.FieldName: the name of the AirTable column | |
// e.FieldValue: the string value in the AirTable JSON (this is the value that cannot convert). You can change this and your new value will be used. | |
// e.PropertyType: the type we are trying to convert to | |
// Example: if the value in the Date column can't be parsed, we'll just set it to standard value | |
airtable.OnTypeConversionFailure += (sender, e) => { | |
if(e.FieldName == "Date") { e.FieldValue = DateTime.MinValue} | |
}; | |
// OnBeforeAssignValue runs before the object is created, and gives you a dictionary with all the | |
// property names and corresponding values that will be assigned. You can inspect or modify before | |
// the object is created. | |
// Example: This will prevent anything with the first name "Deane" from being added to the object list | |
airtable.OnBeforeAssignValues += (sender, e) => { | |
if(e.Values["FirstName"] == "Deane") { e.Cancel = true; } | |
}; | |
// This is your custom class. Objects will be created from this class, then populated from AirTable based on the property attributes. | |
public class Person | |
{ | |
// This has to be on a string property, or it will throw an exception | |
[AirtableId] | |
public string Id { get; set; } | |
// This will throw an exception if it can't convert the column value to the property type | |
// The exception will give you clear information on the source column and desintation property that caused the problem | |
[AirtableColumn("First Name")] | |
public string FirstName { get; set; } | |
[AirtableColumn("Last Name")] | |
public string LastName { get; set; } | |
// Added to demonstrate the "correct" way of doing things not represented in the Airtable data | |
// See the above discussion under the OnTypeConversionFailure event | |
public string Fullname | |
{ | |
get { return string.Concat(FirstName, " ", LastName); } | |
} | |
} | |
public class Airtable | |
{ | |
private string accountId; | |
private string apiKey; | |
private const string AIRTABLE_API_URL = "https://api.airtable.com/v0/{0}/{1}?api_key={2}"; | |
// Fires when the JSON string is returned from HTTP. The raw JSON is in e.HttpResponse | |
public event Action<object, AirtableEventArgs> OnHttpResponse = delegate {}; | |
// Fires before the object is created and properties are assigned | |
// e.Values: a Dictionary<string, object> that contains the property name (key) and what will be assigned (value); this can be changed in the event handler | |
// e.Cancel: set to true inside the event handler to skip adding this record | |
public event Action<object, AirtableEventArgs> OnBeforeAssignValues = delegate {}; | |
// Fires if a value cannot be converted to the correct type for assignment | |
// e.PropertyName: the name of the object property | |
// e.FieldName: the name of the Airtable column | |
// e.FieldValue: the string value in the Airtable JSON (this is the value that cannot convert). You can change this and your new value will be used. | |
// e.PropertyType: the type we are trying to convert to | |
public event Action<object, AirtableEventArgs> OnTypeConversionFailure = delegate {}; | |
// Contains the list of column->property convertor functions | |
private List<AirtableColumnConvertor> convertors; | |
public void AddConvertor(string columnName, string propertyName, Func<string, object> function) | |
{ | |
// Remove one, if it already exists | |
convertors.Remove(convertors.FirstOrDefault(c => c.IsMatch(columnName,propertyName))); | |
// Add this one | |
convertors.Add(new AirtableColumnConvertor() { ColumnName = columnName, PropertyName = propertyName, Function = function }); | |
} | |
public Airtable(string accountId, string apiKey) | |
{ | |
this.accountId = accountId; | |
this.apiKey = apiKey; | |
convertors = new List<AirtableColumnConvertor>(); | |
} | |
public List<T> GetObjects<T>(string tableName) | |
{ | |
var json = new WebClient().DownloadString(string.Format(AIRTABLE_API_URL, accountId, tableName, apiKey)); | |
// Call the event handler, and reset the json in the event it's changed... | |
var httpResponseEventArgs = new AirtableEventArgs() { HttpResponse = json }; | |
OnHttpResponse(this, httpResponseEventArgs); | |
json = httpResponseEventArgs.HttpResponse; | |
var records = new List<T>(); | |
// Loop all the "records" | |
foreach (var jsonRecord in JObject.Parse(json)["records"]) | |
{ | |
// We'll "pre-stage" all property assignments here so we can hand them off to an event handler before final assignment | |
var thisRecord = new Dictionary<string, object>(); | |
// We need the ID in a couple places below... | |
var airTableId = jsonRecord["id"].ToString(); | |
// Set the Airtable ID on the property marked with AirtableId | |
var idProp = typeof(T).GetProperties().FirstOrDefault(p => p.GetCustomAttributes<AirtableIdAttribute>(true).Any()); | |
if (idProp != null) | |
{ | |
if (idProp.PropertyType != typeof(System.String)) | |
{ | |
throw new InvalidCastException("Property containing the AirtableId attribute must be of type System.String"); | |
} | |
thisRecord.Add(idProp.Name, airTableId); | |
} | |
// Loop through all the properties of the object which have the AirtableColumn attribute | |
foreach (var prop in typeof(T).GetProperties().Where(p => p.GetCustomAttributes<AirtableColumnAttribute>(true).Any())) | |
{ | |
// Get the value from the JSON | |
var columnName = prop.GetCustomAttribute<AirtableColumnAttribute>(true).ColumnName; | |
var field = (JProperty)jsonRecord["fields"].FirstOrDefault(f => ((JProperty)f).Name == columnName); | |
// Do we have an Airtable column? | |
if (field == null) | |
{ | |
// We don't have a column for this. This may or may not be a problem. | |
// If you absolutely _have_ to have certain columns, then write an OnBeforeAssign event handler to verify you have everything before the object is assigned. | |
continue; | |
} | |
// This is simply the string present in the JSON. We still have to try to convert it to the right type. | |
var rawFieldValue = (string)field.Value; | |
// Load a custom convertor, if we have one | |
var convertor = convertors.FirstOrDefault(c => c.IsMatch(columnName, prop.Name)); | |
// Attempt to convert it to the required type | |
object value; | |
try | |
{ | |
if (convertor != null) | |
{ | |
// Use the custom convertor | |
value = convertor.Convert(rawFieldValue); | |
} | |
else | |
{ | |
// Just try to switch types | |
value = Convert.ChangeType(rawFieldValue, prop.PropertyType); | |
} | |
} | |
catch (Exception e) | |
{ | |
// Throw the type conversion failure event to allow the calling code to fix the problem | |
var typeConversionFailureEventArgs = new AirtableEventArgs() | |
{ | |
PropertyName = prop.Name, | |
FieldName = field.Name, | |
FieldValue = rawFieldValue, | |
PropertyType = prop.PropertyType | |
}; | |
OnTypeConversionFailure(this, typeConversionFailureEventArgs); | |
value = typeConversionFailureEventArgs.FieldValue; | |
// Skip adding this property assignment (not this _entire record_, just this particular property assignment) | |
// If this is a problem, then between OnTypeConversionFailure and OnBeforeAssignValues, you have lots of changes to fix it... | |
if (typeConversionFailureEventArgs.Cancel) | |
{ | |
continue; | |
} | |
} | |
// Important: we could still have a bad value here. | |
// If there is a chance of that, you should: | |
// 1. Catch the TypeConversionFailure event and fix it, or... | |
// 2. Catch the BeforeAssign event and verify values, and fix if necessary | |
thisRecord.Add(prop.Name, value); | |
} | |
// The dictionary can be modified in this event | |
var beforeAssignEventArgs = new AirtableEventArgs() { Values = thisRecord }; | |
OnBeforeAssignValues(this, beforeAssignEventArgs); | |
// If the event handler wants to cancel, then abort | |
if (beforeAssignEventArgs.Cancel) | |
{ | |
continue; | |
} | |
// Create the object, assign all the properties, and add to the collection | |
var obj = (T)Activator.CreateInstance(typeof(T)); | |
foreach (var assignment in thisRecord) | |
{ | |
try | |
{ | |
typeof(T).GetProperty(assignment.Key).SetValue(obj, assignment.Value, null); | |
} | |
catch (Exception e) | |
{ | |
var truncatedValue = assignment.Value.ToString().Substring(0, assignment.Value.ToString().Length > 50 ? 50 : assignment.Value.ToString().Length); | |
throw new Exception($"Failed to assign property value. Property name: \"{assignment.Key}\"; Value \"{truncatedValue}\"; Exception: \"{e.Message}\""); | |
} | |
} | |
records.Add(obj); | |
} | |
return records; | |
} | |
} | |
public class AirtableEventArgs | |
{ | |
public string HttpResponse { get; set; } | |
public Dictionary<string, object> Values { get; set; } | |
public bool Cancel { get; set; } | |
public string PropertyName { get; set; } | |
public string FieldName { get; set; } | |
public object FieldValue { get; set; } | |
public Type PropertyType { get; set; } | |
} | |
[AttributeUsage(AttributeTargets.Property)] | |
public class AirtableIdAttribute : Attribute { } | |
[AttributeUsage(AttributeTargets.Property)] | |
public class AirtableColumnAttribute : Attribute | |
{ | |
public string ColumnName { get; private set; } | |
public AirtableColumnAttribute(string columnName) | |
{ | |
ColumnName = columnName; | |
} | |
} | |
public class AirtableColumnConvertor | |
{ | |
public string ColumnName { get; set; } | |
public string PropertyName { get; set; } | |
public Func<string, object> Function { get; set; } | |
public object Convert(string value) | |
{ | |
return Function(value); | |
} | |
public bool IsMatch(string columnName, string propertyName) | |
{ | |
return (ColumnName == columnName || columnName == "*") && (PropertyName == propertyName || propertyName == "*"); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Thanks! We don't have a .NET client, so hopefully this'll be handy for people on .NET stack. Our official spelling it Airtable - can you change the capitalization if you have a chance?