Search code examples
rustopenapiopenapi-generatoropenapi-generator-cli

Replace a model in openapi schema with external Rust value during generation


I have an openapi specification of a server where one endpoint returns a list of Thing, but where Thing is an extremely complicated and large object generated from a different openapi spec. It's not possible (read: it's possible, but the worst option for myriad reasons) to add this server spec to that openapi definition or reference the object by path. In any case, that spec generates large model libraries/packages/crates in various languages that client/server code generated from this spec would have access to.

In particular, I'd like to generate server code in Rust. And then after, some client stuff in other languages, but I feel if I can overcome this issue with Rust server generation, I'll know what to do then. In this server spec, it references the Thing. I would like the generator to not generate this model and replace any reference to it with Thing that is found at our_crate::thing::Thing.

First of all, I'm not really sure what to write for the schema. I've tried these approaches, and the third seems... the best of the worst?

components:
  schemas:
    Thing1:
      description: Doesn't generate anything because it's a free-form object.
      type: object
      properties: {}
    Thing2:
      description: |
        Obviously generates a struct with a `String` field with any of the below approaches.
      type: object
      properties:
        dummy_field:
          type: string
    Thing3:
      description: |
        Assigns it the type `HashMap<String, serde_json::Value>` or a `HashMap`
        of `String` to a wrapper of `serde_json::Value`, depending on generator.
      type: object
      additionalProperties: true
    Thing4:
      description: Tried in conjunction with a schema/type mapping; same result as 3.
      type: object
      format: thing4
      additionalProperties: true

The official docs say to use the --import-mappings argument to the CLI, or in a config passed with -c like this:

generatorName: rust-axum
skipSchemaValidation: true
additionalProperties:
  generateAliasAsModel: true
importMappings:
  Thing: "our_crate::thing::Thing"

This has no effect with either the rust-axum or rust-server generators, and the output is as described in the schema descriptions above. The very same documentation later says to use --schema-mappings in conjunction with --type-mappings in pretty much the same scenario, and my attempt at that with the schema as in Thing4 above

generatorName: rust-server
skipSchemaValidation: true
additionalProperties:
  generateAliasAsModel: true
schemaMappings:
  thing: "our_crate::thing::Thing"
typeMappings:
  "object+thing": "thing"

produces empty structs.

So I'm kind of lost at this point. I've read plenty of other SO posts about this that all have answers that roughly parallel the docs. I'm not very familiar with Java, or writing mustache templates, but at this point I get the sense that it's a problem with the generator itself, or there's some way I can modify a template to be aware of this mapping.

So my question is, what should I focus my efforts on now? Can a custom template achieve this?

Or, do I need to change the generator itself? I have noticed that in both the rust-axum generator and the rust-server generator there's only one mention ever of this import mapping option and they're both empty. And running it with the debug options indeed shows that there is no mention of this custom mapping. In my very limited understanding of this system, the mustache templates can access these variables only in doing its thing.

Else, is there a way I haven't conceived of yet to do this seemingly simple and probably very common thing?


Solution

  • This is ultimately the solution that is working for me. I would still really appreciate some perspective on the general problem from someone who has done this before. The documentation is vague to the point of being misleading on a number of aspects of it, and no search result I could find rose to the level of a complete description of a solution. I still have a lot of questions, most like, "Is this really the way this is meant to be done?" I hope that anyone else having the same problem to solve immediately finds this answer, and finds it useful, instead of the frustration I felt.

    Anyway, my OpenAPI schema was structured like this

    ├── openapi.yaml
    ├── parameters
    │   ├── path
    │   └── query
    ├── path_items
    │   ├── things.yaml
    └── schemas
        ├── errors
        └── responses
    

    where the schema I desired to resolve to an import of a type from an external crate was referenced by path via:

    # path_items/things.yaml
    get:
      operationId: GetThingsPage
      description: Get a single page of things.
      security:
        - ThingsAuth:
            - things:read
      parameters:
        - $ref: "../parameters/path/thing_id.yaml"
        - $ref: "../parameters/query/user.yaml"
        - $ref: "../parameters/query/page_token.yaml"
      responses:
        200:
          description: OK
          content:
            application/json:
              schema:
                $ref: "../schemas/responses/getthingspageresponse.yaml"
        400:
          description: BAD_REQUEST
          content:
            application/json:
              schema:
                $ref: "../schemas/errors/error.yaml"
        401:
          description: UNAUTHORIZED
          content:
            application/json:
              schema:
                $ref: "../schemas/errors/error.yaml"
        403:
          description: FORBIDDEN
          content:
            application/json:
              schema:
                $ref: "../schemas/errors/error.yaml"
        500:
          description: INTERNAL_SERVER_ERROR
          content:
            application/json:
              schema:
                $ref: "../schemas/errors/error.yaml"
    
    # schemas/responses/getthingspageresponse.yaml
    type: object
    properties:
      next_page_token:
        name: next_page_token
        type: string
        description: |
          If present, used to fetch the next set of results, otherwise it's the last page.
      things:
        description: Page of things.
        type: array
        items:
          $ref: "../../openapi.yaml#/components/schemas/Thing"
    required:
      - things
    

    and finally,

    # openapi.yaml
    components:
      schemas:
        Thing:
          # All of the different ways I tried to express that this is
          # a "placeholder," intended to be specified in some mapping option
          # as an import.
    

    The thing that got closet at first was

    # openapi.yaml
    components:
      schemas:
        Thing:
          description: A reference to `Thing` as defined in our global models.
          type: ExternalThingSchema
    

    and then a typeMapping (in a config passed to the CLI with the -c option):

    packageName: "things-internal-server"
    skipSchemaValidation: true
    additionalProperties:
      generateAliasAsModel: true
    typeMappings:
      ExternalThingSchema: "the_crate::models::Thing"
    

    This produced Rust code that was finally aware of my external dependency:

    /// A reference to `Thing` as defined in our global models.
    #[derive(Debug, Clone, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize)]
    #[cfg_attr(feature = "conversion", derive(frunk::LabelledGeneric))]
    pub struct Thing(the_crate::models::Thing);
    

    But it is annoying to have this newtype wrapper, so I thought of moving the external type to be directly in the response body:

    # schemas/responses/getthingspageresponse.yaml
    type: object
    properties:
      next_page_token:
        type: string
        description: |
          If present, used to fetch the next set of results, otherwise it's the last page.
      things:
        description: Page of things.
        type: array
        items:
          description: A reference to `Thing` as defined in our global models.
          type: ExternalThingSchema
    required:
      - things
    

    This doesn't work: the generator complains that, "[main] ERROR o.o.codegen.utils.ModelUtils - Undefined array inner type for 'null'. Default to String", and in the debugModel logs we see that the type assigned is a Vec<String>.

    So, in the end, it seems (and I cannot say why) that the ExternalThingSchema can only be mentioned in the top-level openapi.yaml file:

    components:
      schemas:
        GetThingsPageResponseBody:
          type: object
          properties:
            next_page_token:
              description:  If present, used to fetch the next set of results, otherwise it's the last page.
              type: string
            things:
              description: Page of things.
              type: array
              items:
                description: A reference to `Thing` as defined in our global models.
                type: ExternalThingSchema
          required:
            - things
    

    produces

    #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, validator::Validate)]
    #[cfg_attr(feature = "conversion", derive(frunk::LabelledGeneric))]
    pub struct GetThingsPageResponseBody {
    /// If present, used to fetch the next set of results, otherwise it's the last page.
        #[serde(rename = "next_page_token")]
        #[serde(skip_serializing_if="Option::is_none")]
        pub next_page_token: Option<String>,
    
    /// Page of things.
        #[serde(rename = "things")]
        pub things: Vec<the_crate::models::Thing>,
    
    }
    

    which is finally what I wanted.

    NB: Even with this typeMappings, and even though it generates what I want, the generator still spits out warnings:

    [main] WARN  o.o.codegen.DefaultCodegen - Unknown type found in the schema: ExternalThingSchema. To map it, please use the schema mapping option (e.g. --schema-mappings in CLI)
    

    Passing anything possible to schemaMappings has no effect, and I could not care less.