Undesirable Flow/TypeScript types being generated from OpenAPI's `oneOf` keyword. Is there another OpenAPI schema that would have better results?

Viewed 644

We have the following OpenAPI schema:

{
    "openapi": "3.0.1",
    "paths": {
        "/v1/tool/breadcrumbs/{hierarchyId}/{categoryId}": {
            "get": {
                "tags": [
                    "V1-tool"
                ],
                "summary": "Get Breadcrumbs details",
                "operationId": "getBreadcrumbs",
                "parameters": [
                    {
                        "name": "hierarchyId",
                        "in": "path",
                        "required": true,
                        "schema": {
                            "minimum": 1,
                            "type": "integer",
                            "format": "int32"
                        }
                    },
                    {
                        "name": "categoryId",
                        "in": "path",
                        "required": true,
                        "schema": {
                            "minimum": 1,
                            "type": "integer",
                            "format": "int32"
                        }
                    }
                ],
                "responses": {
                    "200": {
                        "description": "default response",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "type": "array",
                                    "items": {
                                        "$ref": "#/components/schemas/Kitten"
                                    }
                                }
                            }
                        }
                    }
                },
                "security": [
                    {
                        "Auth": []
                    }
                ]
            }
        },
        "/v1/tool/hierarchies": {
            "get": {
                "tags": [
                    "V1-tool"
                ],
                "summary": "Get all available hierarchies ",
                "operationId": "getAllHierarchies",
                "responses": {
                    "200": {
                        "description": "default response",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "type": "array",
                                    "items": {
                                        "$ref": "#/components/schemas/HierarchyResponse"
                                    }
                                }
                            }
                        }
                    }
                },
                "security": [
                    {
                        "Auth": []
                    }
                ]
            }
        },
        "/v1/tool/search/{hierarchyId}/{searchTerm}": {
            "get": {
                "tags": [
                    "V1-tool"
                ],
                "summary": "Free text search categories for a given hierarchy",
                "operationId": "searchBy",
                "parameters": [
                    {
                        "name": "hierarchyId",
                        "in": "path",
                        "required": true,
                        "schema": {
                            "minimum": 1,
                            "type": "integer",
                            "format": "int32"
                        }
                    },
                    {
                        "name": "searchTerm",
                        "in": "path",
                        "required": true,
                        "schema": {
                            "type": "string"
                        }
                    }
                ],
                "responses": {
                    "200": {
                        "description": "default response",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "type": "array",
                                    "items": {
                                        "$ref": "#/components/schemas/Kitten"
                                    }
                                }
                            }
                        }
                    }
                },
                "security": [
                    {
                        "Auth": []
                    }
                ]
            }
        },
        "/v1/tool/category/{hierarchyId}/{categoryId}": {
            "get": {
                "tags": [
                    "V1-tool"
                ],
                "summary": "Get Category data needed to render a Table View in the Category Management tool",
                "operationId": "getTableView",
                "parameters": [
                    {
                        "name": "hierarchyId",
                        "in": "path",
                        "required": true,
                        "schema": {
                            "minimum": 1,
                            "type": "integer",
                            "format": "int32"
                        }
                    },
                    {
                        "name": "categoryId",
                        "in": "path",
                        "required": true,
                        "schema": {
                            "minimum": 1,
                            "type": "integer",
                            "format": "int32"
                        }
                    }
                ],
                "responses": {
                    "200": {
                        "description": "default response",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "$ref": "#/components/schemas/TableViewResponse"
                                }
                            }
                        }
                    }
                },
                "security": [
                    {
                        "Auth": []
                    }
                ]
            }
        }
    },
    "components": {
        "schemas": {
            "Kitten": {
                "type": "object",
                "properties": {
                    "type": {
                        "type": "string",
                        "enum": [
                            "Kitten"
                        ]
                    },
                    "id": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "name": {
                        "type": "string"
                    },
                    "uri": {
                        "type": "string"
                    }
                }
            },
            "HierarchyResponse": {
                "type": "object",
                "properties": {
                    "type": {
                        "type": "string",
                        "enum": [
                            "Hierarchy"
                        ]
                    },
                    "id": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "name": {
                        "type": "string"
                    }
                }
            },
            "AliasCategoryResponse": {
                "type": "object",
                "properties": {
                    "type": {
                        "type": "string",
                        "enum": [
                            "Alias"
                        ]
                    },
                    "level": {
                        "type": "string"
                    },
                    "name": {
                        "type": "string"
                    },
                    "id": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "uri": {
                        "type": "string"
                    }
                }
            },
            "ChildCategoryResponse": {
                "type": "object",
                "properties": {
                    "type": {
                        "type": "string",
                        "enum": [
                            "Category"
                        ]
                    },
                    "level": {
                        "type": "string"
                    },
                    "name": {
                        "type": "string"
                    },
                    "id": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "uri": {
                        "type": "string"
                    }
                }
            },
            "GblSubheaderResponse": {
                "type": "object",
                "properties": {
                    "type": {
                        "type": "string",
                        "enum": [
                            "Translation"
                        ]
                    },
                    "brandCatalogId": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "value": {
                        "type": "string"
                    }
                }
            },
            "Item": {
                "type": "object",
                "properties": {
                    "type": {
                        "type": "string"
                    }
                },
                "oneOf": [
                    {
                        "$ref": "#/components/schemas/ChildCategoryResponse"
                    },
                    {
                        "$ref": "#/components/schemas/AliasCategoryResponse"
                    },
                    {
                        "$ref": "#/components/schemas/SubheaderResponse"
                    }
                ]
            },
            "ParentCategoryResponse": {
                "type": "object",
                "properties": {
                    "type": {
                        "type": "string",
                        "enum": [
                            "ParentCategory"
                        ]
                    },
                    "id": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "name": {
                        "type": "string"
                    }
                }
            },
            "SubheaderResponse": {
                "type": "object",
                "properties": {
                    "type": {
                        "type": "string",
                        "enum": [
                            "Subheader"
                        ]
                    },
                    "name": {
                        "type": "string"
                    },
                    "translatedNames": {
                        "type": "array",
                        "items": {
                            "$ref": "#/components/schemas/GblSubheaderResponse"
                        }
                    }
                }
            },
            "TableViewResponse": {
                "type": "object",
                "properties": {
                    "type": {
                        "type": "string",
                        "enum": [
                            "DefaultCategory"
                        ]
                    },
                    "uri": {
                        "type": "string"
                    },
                    "level": {
                        "type": "string"
                    },
                    "name": {
                        "type": "string"
                    },
                    "id": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "parentCategory": {
                        "$ref": "#/components/schemas/ParentCategoryResponse"
                    },
                    "items": {
                        "type": "array",
                        "items": {
                            "$ref": "#/components/schemas/Item"
                        }
                    }
                }
            }
        },
        "securitySchemes": {
            "Auth": {
                "type": "http",
                "description": "Token Authentication  e.g. Bearer <placeholder>",
                "scheme": "bearer",
                "bearerFormat": "JWT"
            }
        }
    }
}

We run the following command to generate Flow types from the above schema file:

npx swagger-to-flowtype path/to/schema/file -d generated_types.js

And that results in the following output:

// @flow strict
export type Kitten = { type: "Kitten", id: number, name: string, uri: string };
export type HierarchyResponse = { type: "Hierarchy", id: number, name: string };
export type AliasCategoryResponse = {
  type: "Alias",
  level: string,
  name: string,
  id: number,
  uri: string
};
export type ChildCategoryResponse = {
  type: "Category",
  level: string,
  name: string,
  id: number,
  uri: string
};
export type GblSubheaderResponse = {
  type: "Translation",
  brandCatalogId: number,
  value: string
};
export type Item = { type: string };
export type ParentCategoryResponse = {
  type: "ParentCategory",
  id: number,
  name: string
};
export type SubheaderResponse = {
  type: "Subheader",
  name: string,
  translatedNames: Array<GblSubheaderResponse>
};
export type TableViewResponse = {
  type: "DefaultCategory",
  uri: string,
  level: string,
  name: string,
  id: number,
  parentCategory: ParentCategoryResponse,
  items: Array<Item>
};

The problem is that the Item type is defined as:

export type Item = { type: string };

And we'd like it to be defined instead as:

export type Item = ChildCategoryResponse | AliasCategoryResponse | SubheaderResponse;

The relevant part of our OpenAPI schema is:

"Item": {
    "type": "object",
    "properties": {
        "type": {
            "type": "string"
        }
    },
    "oneOf": [
        {
            "$ref": "#/components/schemas/ChildCategoryResponse"
        },
        {
            "$ref": "#/components/schemas/AliasCategoryResponse"
        },
        {
            "$ref": "#/components/schemas/SubheaderResponse"
        }
    ]
},

We we're initially thinking that perhaps swagger-to-flowtype had a bug here, but using the same schema to generate TypeScript types via swagger-typescript-api outputs something similar:

export enum Item {
  Category = "Category",
  Alias = "Alias",
  Subheader = "Subheader",
}
...
export interface TableViewResponse {
  type?: "DefaultCategory";
  uri?: string;
  level?: string;
  name?: string;
  /** @format int32 */
  id?: number;
  parentCategory?: ParentCategoryResponse;
  items?: Item[];
}

Notice that in both the Flow and TypeScript types, we should have type information for the shape of each possible object that an Item could be, but we do not have any such type information.

Here's a concrete example of an array of items:

const items: Array<Item> = [
  {
    type: "Alias",
    level: "3",
    name: "name",
    id: 42,
    uri: "uri"
  },
  {
    type: "Category",
    level: "2",
    name: "name",
    id: 45,
    uri: "uri"
  }
]

Hopefully it is now clear why export type Item = { type: string }; isn't the correct type definition.

Question:

Is there a different schema that would generate the desired export type Item = ChildCategoryResponse | AliasCategoryResponse | SubheaderResponse output instead of the current export type Item = { type: string }; output?

1 Answers

From what I understood reading the OpenAPI specification, oneOf and type are not supposed to be used together. It doesn’t seem to have been stated explicitly, but examples in the specification use one or the other, never both. I assume the TypeScript binding generator, upon seeing that type is "object", simply constructs the type based on properties and ignores the oneOf.

If you want to distinguish the type property as disambiguating between the types in the union, you should use discriminator instead of properties:

"Item": {
    "oneOf": [
        {
            "$ref": "#/components/schemas/ChildCategoryResponse"
        },
        {
            "$ref": "#/components/schemas/AliasCategoryResponse"
        },
        {
            "$ref": "#/components/schemas/SubheaderResponse"
        }
    ],
    "discriminator": {
        "propertyName": "type",
        "mapping": {
            "Category": {
                "$ref": "#/components/schemas/ChildCategoryResponse"
            },
            "Alias": {
                "$ref": "#/components/schemas/AliasCategoryResponse"
            },
            "Subheader": {
                "$ref": "#/components/schemas/SubheaderResponse"
            }
        }
    }
},
Related