I’ve been working project that uses macros to generate api bindings from a simple api description format. In short, you write a file describing what http call to make, and a macro creates functions corresponding to these calls. The main point is that I’m implementing is in multiple language, and I can share the simple description format between them. (If you’re curious, you can read more about it at https://github.com/madjar/apicult/)
The current architecture is as follow:
A Parser module is responsible parsing the description file into an internal representation
A Generator module turns that representation into macros. It tries to generate code that is close to what I would have written by hand.
I’m wondering if that’s the right approach, or if I should try to introduce functions that can work with the internal representation without generating code, and have the generated function be light wrapper around these.
Does anybody have insights, or maybe pointers, on how to architecture code a library of medium complexity that uses macros to provide its main feature?
Am I correct in interpreting that scenario as mostly changing the contents of this generated function?
A couple, more tactical-level, thoughts on that code:
handling 4xx and 5xx with raise is fine, but consider including the actual response struct in a structured exception instead of a string. Some APIs will respond with useful information about the error.
having to rescue MatchError to handle timeouts ({:error, Exception.t()} from Finch) is not good
the generated code for a redirect breaks the generated @spec by returning the redirect URL. Is there code missing that would follow the redirect?
Indeed. Right now, a lot of logic is contained in the generated code, and I’m thinking there might be benefits to trying to extra as much logic to a normal runtime function (that’d be easier to test, or even use without macros).
The main obstacle to that is that my description language allows for string interpolation, and this gets translated directly to elixir string interpolation in the resulting ast. Making this work without macro would require switching to some templating language, which might be more complexity than is worth.
Thank you for the thoughts on the code, I’ll improve things based on them. The redirect thing is definitely a quick-hack in response to a specific use-case (an endpoint that redirects to a huge file, requiring a different client code to download that my library doesn’t allow for yet). I’ll have to figure out how to handle that last one cleanly.
A way I’ve found useful to think about macros is to ask myself, “does this depend on something that only a macro can do?”
For instance:
generating specific argument signatures based on configuration
evaluating code from configuration to generate requests and handle result formatting
Not things like:
making Finch requests
parsing the response in the same way every time, independent of config
Based on that idea, you could split the functionality between macro-generated code and plain functions. For instance, imagine a macro that produces code like:
def this_function_is_macro_generated(client_arg, ...endpoint args) do
make_request = fn ->
# unquoted generate_request
end
format_response = fn attrs ->
# code to either format response_struct or return attrs
end
SomeModule.plain_function(client, make_request, format_response)
end
make_request doesn’t technically have to be an anonymous function, but this keeps things symmetrical.