Skip to content

Latest commit

 

History

History
137 lines (110 loc) · 6.16 KB

File metadata and controls

137 lines (110 loc) · 6.16 KB

An OpenAPI client, generated by a custom macro and its Mustache templates

One annotated class with no members:

@dmx('openApiClient')
class Frankfurter {
  //#region
  //#endregion
}

Save the file. dmx writes a typed client, a data class per schema, and a barrel that exports the lot — nine files, none of whose names appear anywhere in hand-written source:

lib/
  frankfurter.dart          hand-written: the annotation, and the manifest in its region
  http_transport.dart       hand-written: dart:io behind the DmxTransport seam
  frankfurter_client.dart   generated: one method per operationId
  rate.dart                 generated: components/schemas/Rate
  currency.dart             generated
  currency_detail.dart      generated
  provider.dart             generated
  rate_provider.dart        generated: an object the document never named
  currency_detail_peg.dart  generated: likewise
  api.dart                  generated: the barrel

Nothing about the API is typed by a person. Not an endpoint, not a parameter, not a response type, not a file name.

The macro and the templates work together

This example shows both halves doing their own job, because it is easy to read "custom Dart macro" and "Mustache template" as a choice between two options.

The macro does what no template could. It parses the OpenAPI document, resolves $refs into components/parameters and components/responses, reads OpenAPI 3.1's type: ["string", "null"] as nullability, maps format: date to DateTime in a response but to String in a query parameter, and invents names for schemas the document wrote inline — the object inside Rate.providers[] has no name anywhere in the document, and the macro calls it RateProvider.

The templates do what string-building should not. Every line of generated Dart is laid out by a file in tool/dmx/templates/. Want a different constructor, a retry, a logging hook, toJson back? Edit templates/client.mustache and save. No Dart changes.

They meet at a plain JSON model. tool/dmx/src/context.dart turns the parsed document into strings, booleans, and lists of maps; the templates contain no logic beyond {{#section}}.

frankfurter.openapi.json  →  document.dart  →  context.dart  →  *.mustache  →  lib/*.dart
   the source of truth        parse it         model it        shape it        emit it

dmx renders those templates itself, over the worker protocol ([dartmacros.render]) — the same Mustache, the same standalone-tag handling and the same whitespace normalizer the built-in catalogue uses. The macro never ships a template engine.

The API

Frankfurter, an open-source exchange-rate API over European Central Bank data. It was chosen for three reasons:

  • it publishes a real OpenAPI 3.1 document (vendored here as tool/dmx/frankfurter.openapi.json, MIT-licensed, from https://api.frankfurter.dev/v2/openapi.json);
  • the document is small but not toy — five operations, four named schemas, path and query parameters, $refs, enums, nullable properties, inline objects; and
  • its data is immutable, so tests against the live API are deterministic. The euro's reference rate against the dollar on 1999-01-04 is a fact, not a reading.

The document is vendored, not fetched. Generation must be reproducible from committed source, so a build never depends on the network.

Run it

make example-openapi        # generate, analyze, and run the hermetic tests
make example-openapi-live   # the same, then run the client against the real API

Tests

Suite What it proves Network
test/generated_shape_test.dart The generated tree tracks the document — every operationId has a method, every schema has a class and a file, every file carries its ownership marker. Assertions are read out of the document at runtime, so a stale build fails. no
test/decode_test.dart The generated decoders read payloads the live API actually returned, captured into test/fixtures/. Failure paths included: a wrong type names its property, a bad list element names its index. no
test_live/live_api_test.dart The generated client, the generated decoders, and the real API. yes

The live suite lives in test_live/ rather than behind a skip, so a bare dart test is hermetic and no CI or release gate depends on somebody else's uptime — while the tests themselves stay real and are run by a target of their own. Nothing here is skipped.

What this macro understands

Deliberately narrow, and it says so rather than guessing. A generator that quietly mishandles a construct emits Dart that compiles and lies.

Read: openapi/info/servers[0].url; paths operations with an operationId; path and query parameters, including $refs into components/parameters; 200 responses, including $refs into components/responses; components/schemas with type, format, properties, required, items, enum, and OpenAPI 3.1 nullable type arrays; and inline objects, which get a synthesized class.

Not read: request bodies (this API has none — every operation is a GET, so nothing is generated to encode), allOf/oneOf/anyOf, additionalProperties schemas, security schemes, non-JSON content types, and servers beyond the first. Meeting one of these is a DmxRefusal with a code, not a silent wrong answer.

Layout

lib/                          the seed, the transport, and the generated tree
tool/dmx/
  macros.dart                 the worker: reads the document, renders, returns files
  frankfurter.openapi.json    the source of truth, vendored
  templates/
    model.mustache            a data class and its decoder
    client.mustache           the client and its methods
    barrel.mustache           the export list
    manifest.mustache         the seed's own region
  src/
    document.dart             OpenAPI JSON → typed model, with `$ref`s resolved
    dart_types.dart           a schema node → a Dart type and its decoder
    context.dart              the typed model → the JSON the templates render
test/                         hermetic suites
test_live/                    the suite that calls the API