How to organise macro-heavy code?

Hi folks!

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?

1 Like

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?
1 Like

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.

Maybe I am about to embarrass myself here so I’ll only address this: why don’t you replace interpolation with plain old string concatenation?

# before
path = "/api/#{version}/users/#{user_id}/edit_profile"

# after
path = "/api/" <> version <> "/users/" <> Integer.to_string(user_id) <> "/edit_profile"

But I am not sure it that will help you then edit the files easier.

2 Likes

You are absolutely right! I hadn’t realised that string concatenation is largely enough for what I’m doing here. Thanks!

1 Like

I am more surprised than you that this helped you. :103: :003:

3 Likes

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.

1 Like

@al2o3cr I implemented what you suggested (both for the macro organisation and the tactical-level comments), and it makes for nicer code. Thanks!