Skip to content

Latest commit

 

History

History
226 lines (143 loc) · 13.8 KB

File metadata and controls

226 lines (143 loc) · 13.8 KB

Conversion Rules

The generator applies a well defined set of rules to convert OpenAPI documents to Rust code.

Principles

Code generation follows the following principles:

  • Every valid OpenAPI document will be tranformed into valid Rust code. This means that the goal is to support every construct allowed by the spec.
  • Generated code should be ergonomic. This may be in conflict with the first rule, however the generator may apply simplifications where it deems necessary
  • Generated code must be usable without modification.
  • Generated code should be stable with regards to small changes in the spec document. This means that local changes to the spec should only result in local changes to the generated code. Violation to this rule causes code relying on generated code to break.
  • The generator aims to support all major OpenAPI versions (at the time of writing this would be 2.0, 3.0 and 3.1)

General notes about code generation

Code will be generated in a module whose name is configurable. Only generated code will live inside this module. This helps prevent interference with surrounding user Rust code.

Mapping OpenAPI's JSON Schema flavor to Rust

Where possible, the generator will use Rust's built-in types. The types chosen depend on the the type and the format set in the schema definition (see also the respective section in the OpenAPI 3.0 spec)

When new types are generated, their names are derived from the names in the schemas object (#/components/schemas/). So a an object type in #/components/schemas/Foo will be mapped as struct Foo.

TODO: Mapping inline schemata

Mapping string

string types are mapped to Rust's String type.

TODO: we will map function parameters to &str in the future.

TODO: Map string types with enum to actual Rust enums

TODO: Other string formats from https://spec.openapis.org/oas/v3.0.4.html#x4-4-1-data-type-format

Mapping number (and it's integer)

type format Rust type(s)
number int32 i32
number int64 i64
number float f32
number double f64
number any other f64

Mapping boolean

Booleans always map to bool.

Mapping array

In general, arrays are mapped to Vec<T>, where T is the type mapped from the schema in the items property.

Mapping object

Schemas with type object are mapped to generated Rust structs.

TODO: Support allOf / anyOf

TODO: Support oneOf polymorphism by generating Rust enums

TODO: Support additionalProperties via HashMap<String,V>

Mapping null

TODO: Support mapping null, even though this does not make much sense. For Rust, the unit type () seems appropriate.

Client code generation from OpenAPI operations

The generator will produce a struct called Client, for which it produces an impl block. The block will contains methods for each operation (so each HTTP verb like get, put etc. will produce its own method). The method names derive from the path and the HTTP verb, so the endpoint for GET /foo/bar will become pub fn foo_bar_get(&self).

An OpenAPI operation consits of a number of key components that each influence the way a method is generated.

Every method has Result<T,E> as it's return type. The actual types used for T and E depend on the responses object.

TODO: Async methods

Parameters

Parameters are mapped directly to operation method parameters. Rust method parameter types derive by the usual rules for type mapping (see above). Since parameters that affect an operation can be declared at the path level or the operation level, both parameter lists are considered for generation.

Parameters object

To generate a list of parameters to include into the operation method from the operation's declared parameters, the following steps are performed:

  • Since operation-level parameters can shadow path-level parameters if they have the same values for in and name, the shadowed parameters are removed from the path-level parameter list.
  • the list of operation-level parameters is appended to the list of remaining path-level parameters
  • Each parameter is converted into a Rust parameter in sequence, applying the rules for type mapping defined above. The name of the generated method parameters are taken by converting the names in the operation parameters.

TODO: instead of using String, use &str for parameters.

RequestBody

If an operation has a request body (defined by its requestBody field), a parameter named body (subject to de-duplication) is appended to the operation method's parameter list.

The type of the parameter is determined using the rules in the section about mapping content.

Responses

Broadly speaking, we distinguish between known responses (that is, responses explicitely declared in the OpenAPI file) and unknown responses (responses that the server yields, but are not defined in the API). Unknown responses are generally treated as an error, regardless of their response code (when an endpoint declared to respond with 200 actuall returns 201 instead, this is an error in terms of the spec).

In terms of the responses declared in the OpenAPI file we devide those into two categories: Success responses (1xx, 2xx, 3xx) and Non-Success responses (4xx-5xx, or any other non-2xx code).

The responses of an OpenAPI operation may link to a named response in the #/components/responses path. The names there may be used to name generated types.

Each Client method returns a Result<T,E>, where T represents the success response category (1xx-3xx), and E non-success responses (4xx-5xx HTTP statuses as well as other errors alike).

TODO: what to do with 'default' responses?

TODO: Success Responses

We treat all 1xx, 2xx and 3xx response codes as 'success' responses - because in Result<T,E>, T is the type used in Result::Ok(T), which respresents success. In contrast to this, HTTP only calls the 2xx response code range 'success' - the 1xx and 3xx ranges are called 'informational' and 'redirection'. However, all three ranges are treated as 'success' when it comes to the generated result types. The 'Default' range is both 'success' and non-successful (it may be returned in either case).

If there is no informational, success or redirection response defined (no 1xx, 2xx or 3xx codes present), T maps to the Rust unit type ().

If there is exactly one such response defined, T maps to the type that is mapped for the media type(s) in content (see section below)

If there are multiple success responses defined, T maps to an enum that is generated for this purpose. The enum carries the name {operationFragment}Success (GET /foo/bar will become FooBarSuccess). For each defined response, a variant for this response will be generated.

In OAS responses can bei either keyed to explicit status codes like 200 or 302, or be tied to entire ranges, like 2XX or 3XX, or the 'Default' range.

  • For explicit status codes, the variants in the enum are called by the respective name of the HTTP code. The variants are crated as tuple variants of T, so 200 becomes Ok200(T), 201 becomes Created201(T), etc. For non-standard codes in the OAS-supported 100..599 range, which don't have a name, the enum variant name is formed using the pattern Status{code} - so a code 288 is represented by the Status288(T) variant.
  • Status code ranges like '2XX' are created as Status{range}. The variant is generated as tuple (u16,T), where the u16 value stores the status code. So '2XX' becomes Status2XX(u16,T).
  • Declared 'Default' responses are generated as the Default(u16,T) variant. Like ranges, u16 contains the status code.

TODO: Non-Success responses

Non-Success responses as well as other potential errors that can happen when calling a HTTP endpoint must be represented by the error type E in a method's Result<T,E> return type.

Therefore, E must be able to represent:

  • Defined error responses, distinguished by their HTTP status
  • Non-HTTP errors such as network or general I/O errors
  • Undefined responses, success or error alike

For this reason, E will always be a generated Rust enum.

The enum is generated as follows:

  • The name is composed of {operationFragment}Error. So for PUT /pet, the generated enum will be called PutPetError.
  • The variants are defined like this:
    • Declared error codes, such as HTTP 400, are called after their {statusFragment}, so HTTP 400 becomes NotFound400. For each declared HTTP error (4xx or 5xx ranges), such a variant is generated. The variants are generated as tuple variants, whose single member type is the type yielded by mapping the media type of that response (see section below).
    • For undeclared HTTP responses, a variant called UnknownResponse is generated. The variant is generated as a tuple variant whoose type is http::Response from the http crate.
    • For all other errors, a tuple variant OtherError is generated. It's contained type is Box<dyn Error>.

Media type content mapping

A Media type content Map is present in these places in OAS:

  • Parameter Object
  • Request Body Object
  • Response Body Object
  • Header Object

For each of these, OAS defines a 'content' attribute, validated against a Map[String,Media Type Object].

For example, a response or a request body defined in OpenAPI may define schemata for one or more media types. Consider the following fragment:

      responses:
        "200":
          description: Successful operation
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Pet"
            application/xml:
              schema:
                $ref: "#/components/schemas/Pet"

Note that the example above is taken from the petstore example for OpenAPI which defines the same schema for the XML and JSON media types, but this may not be the case for other OpenAPI for other OpenAPI definitions.

The example shown above will generate the following enum:

enum PetPutOk200Content {
    ApplicationJson(Pet),
    ApplicationXml(Pet),
}

Mapping content in Request Body Object or Response Object

The type mapped to a response follows the following rules:

  • If a request body or response declaration does not have a content field or the content map is empty, the mapped type is the unit type ()
  • Otherwise, the mapped type for the request or response is the type mapped via the content attribute.

Mapping the content attribute

  • If the content map is empty, the mapped type is the unit type ()
  • If the content map contains only one media type, the type representing the declaration is the type mapped for this media type (see below).
  • Otherwise, an enum is generated to distinguish between media types. The enum is generated according to the following rules:
    • The name of the enum depends on where the content attribute appears (request body, response, ...). In general, the location of content produces a {prefix}. The pattern for the name is {prefix}Content.
    • For each media type, an enum variant is introduced. Media types are translated into Rust enum variant names by using the type and subtype and uppercasing each of their first characters. Then both are joined into a string (ignoring the '/' separator). Media type wildcard characters (*) present in the type or subtype are replaced by the string Any. So a media type application/json becomes an enum variant called ApplicationJson. A media type wildcard text/* will become TextAny. Non-alphabetic characters are removed.
    • The generated enum variants are generated as tuple variants with a single field. The field's type is the field mapped for this media type (see below)

A single media type will be mapped to a type according to the following rules:

  • If the media type contains a schema field, the type mapped for this media type is the type mapped for this schema
  • Otherwise, a type implementing std::io::Read will be mapped. The idea is that, because the spec does not sufficiently specify what the content is, the client falls back to reading the content's binary representation

If an enum is generated for the content propery, its name is created using the pattern {prefix}Content. {prefix} depends on the OAS object in which the content attribute is embedded:

  • Request Body Object: {prefix} is the {operationFragment}. For PUT /pet, {operationFragment} is PetPut. So for PUT /pet, the enum will be called PetPutContent.
  • Response Object:
    • If the response is inlined, {prefix} is {operationFragment}{statusFragment}. For the HTTP 200 example above, the {statusFragment} is Ok200. So the resulting name of the enum is PetPutOk200Content.
    • If the response is declared in #/components/responses, {prefix} is the local name of the Response Body Object
  • Parameter Object:
    • If the parameter is inlined (in an operation object, it only occurs there), {prefix} is {operationFragment}{paramName}. {paramName} is taken from the parameter objects's name property. For example the type name for the X-APIKey header on PUT /pet will be PetPutX_APIKeyContent (not that X-APIKey translates to X_APIKey)
    • If the parameter is declared in #/components/parameters, {prefix} is the local name of the Parameter Object.
  • TODO: Header Object

TODO: Be more concrete in which type is mapped if there is no schema. In this case we may also think about providing more information, like HTTP headers, to provide additional information to API consumers. An alternative to providing a std::io::Read could be the underlying client's entire response object.