Skip to content

Instantly share code, notes, and snippets.

@deanebarker
Last active April 13, 2022 22:27
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save deanebarker/2b4520f290ece96be40436bc5c8915c5 to your computer and use it in GitHub Desktop.
Save deanebarker/2b4520f290ece96be40436bc5c8915c5 to your computer and use it in GitHub Desktop.
A helper class to create a list of populated objects from an Airtable API call
/*
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 == "*");
}
}
@deanebarker
Copy link
Author

Done.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment