Skip to content

Instantly share code, notes, and snippets.

@FrameMuse
Last active October 19, 2022 10:27
Show Gist options
  • Save FrameMuse/314b0ced9c30852431a2cb76300e9a10 to your computer and use it in GitHub Desktop.
Save FrameMuse/314b0ced9c30852431a2cb76300e9a10 to your computer and use it in GitHub Desktop.
OpenAPI Swagger Schema TypeScript Mapping, Parser, Reducer

OpenAPI Swagger Schema TypeScript Mapping, Parser

Reduces (or parses or maps) OpenAPI Swagger Schema to interface (or type) saving links to origin.

Motivation

I am a laziest person ever, I never wanted to write a bit of boilerplate code. I dreamed of having a parser of Swagger schema, so I just download it once and I have all actions (or path or endpoints) in one place. In the beginning, I wrote all endpoints manually, then I wrote this parser to parse to Actions.ts and Schemas.ts files, that was enough for that time.

But I was tired of importing each one action every time I wanted to use it and I also got some other problems with having separate declarations. I just wanted to pick an endpoint from suggestions menu and it would infer all the data automatically.

So I came to writing a TypeScript mapping of Swagger schema, so I only need that json file and that's it.

But unfortunately this parser has a problem too - it can't work with pure json file, the file needs to be put into ts interface (or type) [so it holds string literal].

Main Usage

API endpoints - See usage in usage.ts.

Features of usage

Have suggestions - just as I wanted! image

After word

The typings are still not perfect.

I hope somebody will ever need this. I believe that I made something great so far but I will test through out this year and see how it behaves.

I will also create automatic "transformer" of field names from snake case to camel and evaluation of date strings to Date class and others.

Author - me

// Helpers
import ParseSchema from "./parser"
import SwaggerSchema from "./SwaggerSchema"
import { ContentSample, OkResponseSample, RequestMethod, Schema, SchemaAny } from "./types"
export type ValuesOf<T> = T[keyof T]
export type ArrayType<A> = A extends (infer T)[] ? T : never
export type MakeRequired<O extends object, K extends string> = O & { [P in keyof O as K]-?: O[P] }
// https://stackoverflow.com/questions/50374908/transform-union-type-to-intersection-type
export type Intersect<U> = (U extends {} ? (o: U) => void : never) extends ((o: infer I) => void) ? I : never
export type Intersect__TEST__ = Intersect<{ b: 2 } | { a: 1 }>
// Schema helpers
export type IfNullable<S extends Schema> = S["nullable"] extends true ? null : never
export type GetRefSchemaName<P> = P extends `#/components/schemas/${infer T}` ? T : never
export type DeRefSchema<S extends SchemaAny> = GetRefSchemaName<S["$ref"]> extends keyof SwaggerSchema["components"]["schemas"] ? SwaggerSchema["components"]["schemas"][GetRefSchemaName<S["$ref"]>] : never
// Swagger schema helpers
export type ExtendsOkResponseSample<T> = T extends OkResponseSample ? T : never
export type ExtendsContentSample<T> = T extends ContentSample ? T : never
export type FindMethodInPaths<M extends Lowercase<RequestMethod>, Paths> = keyof { [P in keyof Paths as (keyof Paths[P] extends Exclude<keyof Paths[P], M> ? never : P)] }
import { ArrayType, DeRefSchema, IfNullable, Intersect, MakeRequired } from "./helpers"
import SwaggerSchema from "./SwaggerSchema"
import { Schema, SchemaAny, SchemaArray, SchemaNumber, SchemaObject, SchemaString } from "./types"
// Schema parser
type ParseSchema<S> = ParseSchemaEnhance<S, (
S extends SchemaObject ? ParseSchemaObject<S>
: S extends SchemaArray ? ParseSchemaArray<S>
: S extends SchemaNumber ? ParseSchemaNumber<S>
: S extends SchemaString ? ParseSchemaString<S>
: S extends Pick<SchemaAny, "allOf"> ? ParseSchemaAllOf<S>
: S extends Pick<SchemaAny, "anyOf"> ? ParseSchemaAnyOf<S>
: S extends Pick<SchemaAny, "$ref"> ? ParseSchema<DeRefSchema<S>>
: never
)>
type ParseSchemaEnhance<S, O> = S extends Schema ? (O | IfNullable<S>) : never
type ParseSchema__TEST__ = ParseSchema<SwaggerSchema["components"]["schemas"]["AccountsMe"]>
// Schema parser helpers
type ParseSchemaArray<S extends SchemaArray> = ParseSchema<S["items"]>
type ParseSchemaObject<S extends SchemaObject, O extends Record<string, Schema> = Exclude<S["properties"], undefined>> = MakeRequired<{ [K in (keyof S["properties"] | keyof S["additionalProperties"])]?: ParseSchema<O[K]> }, ArrayType<S["required"]>> & S["default"]
type ParseSchemaNumber<S extends SchemaNumber> = number
type ParseSchemaString<S extends SchemaString> = string
type ParseSchemaAllOf<S extends Pick<SchemaAny, "allOf">> = Intersect<ParseSchema<ArrayType<S["allOf"]>>>
type ParseSchemaAnyOf<S extends Pick<SchemaAny, "anyOf">> = ParseSchema<ArrayType<S["anyOf"]>>
export default ParseSchema
interface SwaggerSchema {
"openapi": "3.0.3",
"paths": {
"/__docs__/": {
"get": {
"operationId": "__docs___retrieve",
"description": "OpenApi3 schema for this API. Format can be selected via content negotiation.\n\n- YAML: application/vnd.oai.openapi\n- JSON: application/vnd.oai.openapi+json",
"parameters": [
{
"in": "query",
"name": "format",
"schema": {
"type": "string",
"enum": [
"json",
"yaml"
]
}
},
{
"in": "query",
"name": "lang",
"schema": {
"type": "string",
"enum": [
"de",
"en",
"ru"
]
}
}
],
"tags": [
"__docs__"
],
"security": [
{
"Token": []
},
{
"Cookie": []
},
{}
],
"responses": {
"200": {
"content": {
"application/vnd.oai.openapi": {
"schema": {
"type": "object",
"additionalProperties": {}
}
},
"application/yaml": {
"schema": {
"type": "object",
"additionalProperties": {}
}
},
"application/vnd.oai.openapi+json": {
"schema": {
"type": "object",
"additionalProperties": {}
}
},
"application/json": {
"schema": {
"type": "object",
"additionalProperties": {}
}
}
},
"description": ""
}
}
}
},
"/account/me/": {
"get": {
"operationId": "account_me_retrieve",
"tags": [
"account"
],
"security": [
{
"Token": []
},
{
"Cookie": []
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AccountsMe"
}
}
},
"description": ""
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthenticateInvalidToken"
}
}
},
"description": "\tНеверный токен"
}
}
},
"patch": {
"operationId": "account_me_partial_update",
"tags": [
"account"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PatchedAccountsMe"
}
},
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/PatchedAccountsMe"
}
},
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/PatchedAccountsMe"
}
}
}
},
"security": [
{
"Token": []
},
{
"Cookie": []
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AccountsMe"
}
}
},
"description": ""
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthenticateInvalidToken"
}
}
},
"description": "\tНеверный токен"
}
}
}
},
"/account/me/password/": {
"put": {
"operationId": "account_me_password_update",
"tags": [
"account"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AccountsMePassword"
}
},
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/AccountsMePassword"
}
},
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/AccountsMePassword"
}
}
},
"required": true
},
"security": [
{
"Token": []
},
{
"Cookie": []
}
],
"responses": {
"204": {
"description": "No response body"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthenticateInvalidToken"
}
}
},
"description": "\tНеверный токен"
},
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AccountsMePassword"
}
}
},
"description": null
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MePasswordInvalidOldPassword"
}
}
},
"description": "\tНеверный старый пароль"
}
}
}
},
"/account/me/supports/": {
"post": {
"operationId": "account_me_supports_create",
"tags": [
"account"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AccountsSupports"
}
},
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/AccountsSupports"
}
},
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/AccountsSupports"
}
}
},
"required": true
},
"security": [
{
"Token": []
},
{
"Cookie": []
}
],
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AccountsSupports"
}
}
},
"description": ""
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthenticateInvalidToken"
}
}
},
"description": "\tНеверный токен"
}
}
}
},
"/account/password/": {
"get": {
"operationId": "account_password_retrieve",
"tags": [
"account"
],
"security": [
{
"Token": []
},
{
"Cookie": []
},
{}
],
"responses": {
"302": {
"description": "redirect:\n\n&nbsp;&nbsp;&nbsp;&nbsp;что-то пошло не так: https://merlines-frontend.vercel.app/\n\n&nbsp;&nbsp;&nbsp;&nbsp;всё нормально: https://merlines-frontend.vercel.app/#!/?password_session=\\<session_id\\>"
}
}
},
"post": {
"operationId": "account_password_create",
"tags": [
"account"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AccountsPassword"
}
},
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/AccountsPassword"
}
},
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/AccountsPassword"
}
}
},
"required": true
},
"security": [
{
"Token": []
},
{
"Cookie": []
},
{}
],
"responses": {
"204": {
"description": "No response body"
}
}
},
"put": {
"operationId": "account_password_update",
"tags": [
"account"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AccountsPasswordUpdate"
}
},
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/AccountsPasswordUpdate"
}
},
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/AccountsPasswordUpdate"
}
}
},
"required": true
},
"security": [
{
"Token": []
},
{
"Cookie": []
},
{}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AccountsPasswordUpdate"
}
}
},
"description": null
},
"408": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasswordSessionCodeTimeOut"
}
}
},
"description": "\tКод просрочен"
}
}
}
},
"/account/register/": {
"get": {
"operationId": "account_register_retrieve",
"tags": [
"account"
],
"security": [
{
"Token": []
},
{
"Cookie": []
},
{}
],
"responses": {
"302": {
"description": "redirect:\n\n&nbsp;&nbsp;&nbsp;&nbsp;что-то пошло не так: https://merlines-frontend.vercel.app/\n\n&nbsp;&nbsp;&nbsp;&nbsp;всё нормально: https://merlines-frontend.vercel.app/#!/?token=<token>"
}
}
},
"post": {
"operationId": "account_register_create",
"tags": [
"account"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AccountsRegister"
}
},
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/AccountsRegister"
}
},
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/AccountsRegister"
}
}
},
"required": true
},
"security": [
{
"Token": []
},
{
"Cookie": []
},
{}
],
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AccountsRegister"
}
}
},
"description": null
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RegisterEmailUnique"
}
}
},
"description": "\tuser с таким email уже существует"
}
}
}
}
},
"components": {
"schemas": {
"AccountsMe": {
"type": "object",
"properties": {
"first_name": {
"type": "string"
},
"last_name": {
"type": "string",
"nullable": true
},
"email": {
"type": "string",
"format": "email",
"readOnly": true
},
"type": {
"allOf": [
{
"$ref": "#/components/schemas/TypeEnum"
},
{
"$ref": "#/components/schemas/FoodEnum"
}
],
"readOnly": true,
"description": "1 — BANNED (Banned)\n\n2 — DEFAULT (Default)\n\n3 — EDITOR (Editor)\n\n4 — ADMIN (Admin)\n\n5 — SUPER (Super)"
},
"avatar": {
"type": "string",
"format": "uri",
"nullable": true
}
},
"required": [
"email",
"first_name",
"type"
]
},
"AccountsMePassword": {
"type": "object",
"properties": {
"old_password": {
"type": "string",
"writeOnly": true,
"maxLength": 128
},
"new_password": {
"type": "string",
"writeOnly": true,
"maxLength": 128
}
},
"required": [
"new_password",
"old_password"
]
},
"AccountsPassword": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"writeOnly": true,
"maxLength": 254
}
},
"required": [
"email"
]
},
"AccountsPasswordUpdate": {
"type": "object",
"properties": {
"password": {
"type": "string",
"writeOnly": true,
"maxLength": 128
},
"session": {
"type": "string",
"writeOnly": true
},
"token": {
"type": "string",
"readOnly": true
}
},
"required": [
"password",
"session",
"token"
]
},
"AccountsRegister": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"writeOnly": true,
"maxLength": 254
},
"password": {
"type": "string",
"writeOnly": true,
"maxLength": 128
},
"first_name": {
"type": "string",
"writeOnly": true
},
"last_name": {
"type": "string",
"writeOnly": true
},
"id": {
"type": "integer",
"readOnly": true
}
},
"required": [
"email",
"first_name",
"id",
"password"
]
},
"AccountsSupports": {
"type": "object",
"properties": {
"text": {
"type": "string",
"writeOnly": true
}
},
"required": [
"text"
]
},
"AuthenticateInvalidToken": {
"type": "object",
"properties": {
"error": {
"type": "object",
"additionalProperties": {},
"readOnly": true,
"default": {
"type": "warning",
"code": "authenticate_invalid_token"
}
}
},
"required": [
"error"
]
},
"FoodEnum": {
"enum": [
0,
1,
2
],
"type": "integer"
},
"TypeEnum": {
"enum": [
1,
2,
3,
4,
5
],
"type": "integer"
},
},
"securitySchemes": {
"Cookie": {
"type": "apiKey",
"in": "cookie",
"name": "sessionid"
},
"Token": {
"type": "apiKey",
"in": "header",
"name": "Authorization",
"description": "Token-based authentication"
}
}
}
}
export default SwaggerSchema
export type Primitive = "string" | "integer" | "number" | "boolean" | "array" | "object"
export interface SchemaArray {
type: "array"
title?: string
/**
* Items that are represented by this `array`.
*/
items?: Schema
/**
* Means that the value of the `Schema` may be `null`.
*/
nullable?: boolean
}
export interface SchemaObject {
type: "object"
title?: string
properties?: Record<string, Schema>
additionalProperties?: Record<string, Schema>
/**
* List of required fields related to `properties` field.
* @example ["field1", "field2"]
*/
required?: string[]
/**
* Means that the value of the `Schema` may be `null`.
*/
nullable?: boolean
default?: Record<keyof never, unknown>
}
export interface SchemaNumber {
type: "integer"
minimum?: number
maximum?: number
nullable?: boolean
title?: string
enum?: number[]
}
export interface SchemaString {
type: "string"
maxLength?: number
nullable?: boolean
title?: string
enum?: string[]
}
export interface SchemaAny {
/**
* Type of the `Schema`.
*/
type?: Exclude<Primitive, "array" | "object">
format?: "date-time" | "int32"
title?: string
description?: string
readOnly?: boolean
/**
* Default value of the `Schema`.
*/
default?: Primitive
/**
* Reference to another `Schema`.
*
* @example "#/components/schemas/SchemaName"
*/
$ref?: string
/**
* Means that the value of the `Schema` may be `null`.
*/
nullable?: boolean
/**
* Represents union of possible `string` or `number` values.
* It means that the `Schema` has `string` type.
* The unions are joined by `|` sign.
*
* @example ["guest", "user"]
* @example [0, 1, 2]
*/
enum?: unknown[]
/**
* Represents union of possible values.
* The unions are joined by `&` sign.
*
* @example { foo: string } & { bar: number[] }
* @example SchemaName1 & SchemaName2
*/
allOf?: Schema[]
anyOf?: Schema[]
}
export type Schema = SchemaArray | SchemaObject | SchemaNumber | SchemaString | SchemaAny
/**
* Stringified schema.
*
* @example
* "UserType"
* @example
* {
* id: number
* name: string
* type: "admin" | "default"
* }
*/
export type SchemaType = string & {}
export interface Parameter {
name: string
in: "path" | "query"
required?: boolean
style?: "simple" | "form"
explode?: boolean
schema: Schema
description?: string
}
export type PathMethod = Record<string, {
description?: string
parameters?: Parameter[]
requestBody?: {
content: {
[k in string]: { schema: Schema }
}
}
responses: Record<string, {
content?: {
[k in string]: { schema: Schema }
}
description?: string
}>
}>
export type Paths = Record<string, PathMethod>
export type Schemas = Record<string, Schema>
export type PathArgs = Record<string, Omit<Parameter, "required" | "schema"> & { required: boolean, schemaType: string }>
export type RequestMethod = "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS"
// Samples (for extending)
export interface OkResponseSample {
responses: {
200: ContentSample
}
}
export interface ContentSample {
content: {
"application/json": {
schema: Schema
}
}
}
// Usage helpers
import { ExtendsContentSample, ExtendsOkResponseSample, FindMethodInPaths, Intersect, ValuesOf } from "./helpers"
import ParseSchema from "./parser"
import SwaggerSchema from "./SwaggerSchema"
// Example of usage via `@tanstack/react-query` useQuery hook
import { useQuery } from "@tanstack/react-query"
type TPaths = SwaggerSchema["paths"]
type TGetPaths = FindMethodInPaths<"get", TPaths> // Finds all paths that have a `get` method
type FindOkResponse<GetPath extends TPaths[TGetPaths][keyof TPaths[TGetPaths]]> = ParseSchema<ExtendsOkResponseSample<GetPath>["responses"]["200"]["content"]["application/json"]["schema"]>
type FindResponses<GetPath extends TPaths[TGetPaths][keyof TPaths[TGetPaths]]> = ParseSchema<ExtendsContentSample<ValuesOf<GetPath["responses"]>>["content"]["application/json"]["schema"]>
// Finds only a `get` response with 200 status code
type OkResponse<TPath extends TGetPaths> = FindOkResponse<TPaths[TPath]["get"]>
// Finds all `get` responses and intersects them (useful for errors)
// But you better write your own errors interpretation than inferring them from other responses
type AllResponses<TPath extends TGetPaths> = Intersect<FindResponses<TPaths[TPath]["get"]>>
// Usage
// [TIP]: Change `TData = AllResponses<TPath>` to `TData = OkResponse<TPath>`, so there is only one response inferring (without `error`).
function useAppQuery<TPath extends TGetPaths, TData = AllResponses<TPath>, TError = unknown>(path: TPath) {
const { error, data, isFetching, refetch } = useQuery<TData, TError, TData>({})
if (data == null) {
throw new Error("No payload")
}
return { error, data: data, isFetching, refetch }
}
function Example() {
// You only pick a path (endpoint) and it infers all the data automatically
const { data } = useAppQuery("/account/me/")
enum UserType {
Default, Editor, Owner, Admin
}
data.error
return (
<>
First Name: {data.first_name}
Last Name: {data.last_name}
Email: {data.email}
Avatar: {data.avatar}
Type: {UserType[data.type]}
</>
)
}
export default Example
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment