openapi-ts / openapi-typescript

Generate TypeScript types from OpenAPI 3 specs
https://openapi-ts.dev
MIT License
5.69k stars 458 forks source link

Documentation on how to use the generated model #748

Closed jfnu closed 1 year ago

jfnu commented 3 years ago

Hi, Is there any documentation on how to use generated model/interface? for example: using this model https://raw.githubusercontent.com/drwpow/openapi-typescript/main/examples/stripe-openapi3.ts Is there a sample on what the client looks like?

Thank you

drwpow commented 3 years ago

We have a brief snippet here in the README: https://github.com/drwpow/openapi-typescript#using-in-typescript

import { components } from './generated-schema.ts';

type APIResponse = components["schemas"]["APIResponse"];

Would love help adding a more robust example if that would benefit people!

ChuckJonas commented 2 years ago

A good example would be how to leverage the generated types to define an express, restify or serverless function handler (or abstraction that the handler can call).

I know this is kinda of backwards to using the handler to generate the "openAPI" spec, but would be very powerful for "contract first" development use-cases.

aperkaz commented 2 years ago

@ChuckJonas @drwpow in our company we use the tool to generate the express handler types, as we follow a schema-first approach.

The core idea leverages optional generic types, and for a Json centric API it looks like the following:

import { Request, NextFunction } from 'express';
import { Response } from 'express-serve-static-core'; // required, 

import { operations } from '../types/open-api-schema-types';

/**
 * Available operation in the API
 */
type ApiOperations = keyof operations;

// -- REQUEST --

type RequestPathParamType<T extends ApiOperations> =
  'path' extends keyof operations[T]['parameters']
    ? operations[T]['parameters']['path']
    : never;

type RequestQueryParamType<T extends ApiOperations> =
  'query' extends keyof operations[T]['parameters']
    ? operations[T]['parameters']['query']
    : never;

type RequestBodyType<T extends ApiOperations> =
  'requestBody' extends keyof operations[T]
    ? 'content' extends keyof operations[T]['requestBody']
      ? 'application/json' extends keyof operations[T]['requestBody']['content']
        ? operations[T]['requestBody']['content']['application/json']
        : never
      : never
    : never;

/**
 * General express req typing
 */
type RequestType<T extends ApiOperations> = Request<
  RequestPathParamType<T>,
  unknown,
  RequestBodyType<T>,
  RequestQueryParamType<T>
>;

// -- RESPONSE --

type ResponseType<T extends ApiOperations> =
  200 extends keyof operations[T]['responses']
    ? 'content' extends keyof operations[T]['responses'][200]
      ? 'application/json' extends keyof operations[T]['responses'][200]['content']
        ? operations[T]['responses'][200]['content']['application/json']
        : never
      : never
    : never;

type PayloadType<
  operationType extends ApiOperations & string,
  codeType extends keyof operations[operationType]['responses'] & number
> = 'content' extends keyof operations[operationType]['responses'][codeType]
  ? 'application/json' extends keyof operations[operationType]['responses'][codeType]['content']
    ? operations[operationType]['responses'][codeType]['content']['application/json']
    : null
  : null;

/**
 * Send response with json payload
 *
 * @param res Express Response object
 * @param _operationId OpenAPI's operation id
 * @param code HTTP code
 * @param payload response payload
 */
export function resSendJson<
  operationType extends ApiOperations & string,
  codeType extends keyof operations[operationType]['responses'] & number,
  payloadType extends PayloadType<operationType, codeType>
>(
  res: Response,
  _operationId: operationType,
  code: codeType,
  payload: payloadType
): Response {
  return res.status(code).json(payload);
}

/**
 * Send response with status code 204, without payload.
 *
 * @param res Express Response object
 * @param _operationId OpenAPI's operation id
 * @param code HTTP code
 */
export function resSendStatus<
  operationType extends ApiOperations & string,
  codeType extends keyof operations[operationType]['responses'] & 204
>(res: Response, _operationId: operationType, code: codeType): Response {
  return res.sendStatus(code);
}

I hope you find it helpful 🙂

ajaishankar commented 2 years ago

@aperkaz @ChuckJonas @drwpow

In the same spirit have been putting together a typed fetch client for the code generated by openapi-typescript.

Hope to publish in a couple of days - working on the readme and Openapi 3 support.

https://github.com/ajaishankar/openapi-typescript-fetch/blob/main/test/fetch.test.ts

const fetcher = Fetcher.for<paths>()

fetcher.configure({
  baseUrl: 'https://api.backend.dev',
  init: {
    headers: {
      Authorization: 'Bearer token',
    },
  },
})

// create typed fetch for an operation
const fun = fetcher.path('/query/{a}/{b}').method('get').create()

const { ok, status, statusText, data } = await fun({
  a: 1,
  b: 2,
  scalar: 'a',
  list: ['b', 'c'],
})
ChuckJonas commented 2 years ago

@aperkaz thanks, pretty much same thing I was looking to do. Should be able to modify slightly to work with a severless functions.

If you have a moment, would you mind updating your code to also include a very simple endpoint implementation example? Thanks!

(once it's complete, it might be worthwhile to add this to the "examples" directory)

ChuckJonas commented 2 years ago

@ajaishankar I'm using redux toolkit query to generate the client guessing your project does something similar 👍

The goal is to have both the front end and back end building off a single API contract (via a code generation step before build). That way any breaking API cannot make it through our build step (although they will more likely surface during dev).

ajaishankar commented 2 years ago

@ChuckJonas if you don't care about having a rest endpoint, then you could eliminate codegen altogether with tRPC

ajaishankar commented 2 years ago

@drwpow just published a typed fetch client implementation openapi-typescript-fetch

Feedback much appreciated! 🙏

aperkaz commented 2 years ago

@ajaishankar nice work on openapi-typescript-fetch! I personally use openapi-typescript-codegen which relays on the open api schema instead of on this module 🙂 . It provides full type safety over req body and responses too!

aperkaz commented 2 years ago

@ChuckJonas regarding how to use the snippet I provided in a handler, it would work as the following:

import {RequestType, ResponseType, resSendJson, resSendStatus} from './snippet-above';

// Should match the `operationId` property defined in the OpenAPI schema
const GET_OP = 'getTodos';

/**
* [GET] /users/{userId}/todos?dueDate='YYYY-MM-DD'&completed={true | false}
*
* For the sake of the example, lets assume this endpoint expects (specified in OpenAPI sceficiation):
*  - `userId` (a `string`) as path param
*  - `dueDate` (a `string`) as query param
*  - `completed` (a `boolean`) as query param
*
* The success response should be: [200] `{results: [{id: 'number', message: 'string'}]}`
**/
const todoCreationHandler = (req: RequestType<typeof GET_OP>, res: ResponseType<typeof GET_OP>) => {
    const {userId} = req.params; // fully typed
    const {dueDate, completed} = req.query; // fully typed

    // In production, get todos from persistence layer
    const todos = {results:[
         {id: 1, message: 'Todo1'},
         {id: 2, message: 'Todo2'},
    ]}

    // instead of using the `res` object directly, use the helpers `resSendStatus` and `resSendJson` for type safety.
    resSendJson(res, GET_OP, 200, todos); // the HTTP code (`200`) and the response (`todos`) are fully typed.
}
ajaishankar commented 2 years ago

@ajaishankar nice work on openapi-typescript-fetch! I personally use openapi-typescript-codegen which relays on the open api schema instead of on this module 🙂 . It provides full type safety over req body and responses too!

Thanks @aperkaz!

So right now have a sweet spot with openapi-typescript and my typed client implementation

ChuckJonas commented 2 years ago

@aperkaz just getting around to hooking this up. Mostly seems to be working, except it's not finding the response['code'] type.

I think my generated schema doesn't match the types it's expecting... I'm using the "pet store" swagger doc. My generate types don't have the content properties that it looks like your types are keying off of.

// express handler return

resSendJson(res, GET_OP, 200, null); // this is expecting null instead of definitions["Pet"]

//... generated schema
 getPetById: {
    parameters: {
      path: {
        /** ID of pet to return */
        petId: number;
      };
    };
    responses: {
      /** successful operation */
      200: {
        schema: definitions["Pet"];
      };
      /** Invalid ID supplied */
      400: unknown;
      /** Pet not found */
      404: unknown;
    };
  };

Could you provide the schema you used for this example so I could try generating it from there?

UPDATE:

I got it to mostly work by switching to an OpenAPI definition. I'm surprised that this library generates drastically different types between the two different specs. Seems like it should conform to one universal type.

also, I think your function signature might be wrong in your example. res: ResponseType<typeof GET_OP>, results in the response being typed as the response "body"? I think this should just be the express Response

aperkaz commented 2 years ago

@ChuckJonas the schema I am using is proprietary, so unfortunately I cant share it...

Regarding the res type, it will depend on you use case. In mine (a JSON API), I find it useful to have fully typed responses at the API handler level 🙂

ChuckJonas commented 2 years ago

@aperkaz ya agreed with the fully typed response types.

But the input response object itself isn't the object you are returning. It should be something more like:

Response & { body: ResponseType<typeof GET_OP> }
aperkaz commented 2 years ago

@ChuckJonas could you bootstrap a project with that approach? Unfortunately I dont have time for it now, but I would be great to build up an example to share with other too 🙂

KilianB commented 2 years ago

Thank you very much for this library. It belongs to my go to backend server stack for a bit and is used in a combination of express-openapi-validator, openapi-typescript, swagger-ui-express and some custom middlewares.

I got insipired by @aperkaz and now use fully typed express routes with slight modifications to get rid of the need to additionally type the res functions. The setup is quite handy. The process beings by writing a openapi file and brings the benefit of properly typed express routes, a swagger-ui and generated CORS settings.

Sample Route

  app.post(
    '/recoveryCode/check',
    async (req: TypedRequest<'PostPasswordRecoveryCheck'>, res: TypedResponse<'PostPasswordRecoveryCheck'>) => {
      try {
        return resSendJson(res, 200, {
          valid: true,
        });
      } catch (err) {
        //Log error and return a 500 error status code
        return internalServerError(err, res, 'Server thrown in recoveryCode/check');
      }
    }
  );

package.json

"scripts": {
    "build-dev": "yarn run clean && npx openapi-typescript openapi.yml --output src/types/openapischema.ts && tsc --watch",
  },

TypedResponse.ts

import { Response } from 'express-serve-static-core';
import type { operations } from '../types/openapischema';

type ApiOperations = keyof operations;

export type ResponseType<T extends ApiOperations> = 200 extends keyof operations[T]['responses']
  ? 'content' extends keyof operations[T]['responses'][200]
    ? 'application/json' extends keyof operations[T]['responses'][200]['content']
      ? operations[T]['responses'][200]['content']['application/json']
      : never
    : never
  : never;

export type TypedResponse<T extends ApiOperations> = Response<ResponseType<T>>;

type PayloadType<
  operationType extends ApiOperations & string,
  codeType extends keyof operations[operationType]['responses'] & number
> = 'content' extends keyof operations[operationType]['responses'][codeType]
  ? 'application/json' extends keyof operations[operationType]['responses'][codeType]['content']
    ? operations[operationType]['responses'][codeType]['content']['application/json']
    : null
  : null;

/**
 * Send response with json payload
 *
 * @param res Express Response object
 * @param _operationId OpenAPI's operation id
 * @param code HTTP code
 * @param payload response payload
 */
export function resSendJson<
  operationType extends ApiOperations & string,
  codeType extends keyof operations[operationType]['responses'] & number,
  payloadType extends PayloadType<operationType, codeType>
>(res: TypedResponse<operationType>, code: codeType, payload: payloadType): Response {
  return res.status(code).json(payload as unknown as any);
}

/**
 * Send response with status code 204, without payload.
 *
 * @param res Express Response object
 * @param _operationId OpenAPI's operation id
 * @param code HTTP code
 */
export function resSendStatus<
  operationType extends ApiOperations & string,
  codeType extends keyof operations[operationType]['responses'] & number
>(res: TypedResponse<operationType>, code: codeType): Response {
  return res.sendStatus(code);
}

TypedRequest.ts

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-nocheck Still some type errors in this file that need to be worked out
import { Request } from 'express-serve-static-core';
import type { operations } from '../types/openapischema';

/**
 * Available operation in the API
 */
type ApiOperations = keyof operations;

// -- REQUEST --

type RequestPathParamType<T extends ApiOperations> = 'path' extends keyof operations[T]['parameters']
  ? operations[T]['parameters']['path']
  : never;

type RequestQueryParamType<T extends ApiOperations> = 'query' extends keyof operations[T]['parameters']
  ? operations[T]['parameters']['query']
  : never;

type RequestBodyType<T extends ApiOperations> = 'requestBody' extends keyof operations[T]
  ? 'content' extends keyof operations[T]['requestBody']
    ? 'application/json' extends keyof operations[T]['requestBody']['content']
      ? operations[T]['requestBody']['content']['application/json']
      : ?('multipart/form-data' extends keyof operations[T]['requestBody']['content']
          ? operations[T]['requestBody']['content']['multipart/form-data']
          : never)
    : never
  : never;

/**
 * General express req typing
 */
export type TypedRequest<T extends ApiOperations> = Request<
  RequestPathParamType<T>,
  unknown,
  RequestBodyType<T>,
  RequestQueryParamType<T>
>;

Added benefit: auto generate option routes based on swagger file with correct method headers. Run this before any other CORS stuff.

/**
 * Automatically register OPTION routes for all endpoints specified in the openapi spec and return 
 * the correct CORS headers
 * 
 * @param app Express object
 * @param corsOptions configuration object for cors middleware
 * @param optionsMiddleware other middleware running before the cors middleware
 */
export const generateOptionsRouteFromSwagger = (
  app: Express,
  corsOptions?: Omit<Options, 'allowMethod'>,
  optionsMiddleware?: RequestHandler[]
) => {

 //Load spec from disk
  const __dirname = path.resolve();
  const specFile = path.join(__dirname, '/openapi.yml');

  const openApiSpec = yaml.load(fs.readFileSync(specFile, 'utf8')) as SwaggerDoc;

  const paths = Object.keys(openApiSpec.paths);

  for (const path of paths) {
    const pathInfo = openApiSpec.paths[path];
    const methodsForPath = Object.keys(pathInfo);

    //Rewrite open api paths to express routes
    // /partner/venues/{venueId} => /partner/venues/:venueId
    let expressRoute = path.replaceAll('{', ':');
    expressRoute = expressRoute.replaceAll('}', '');

    const extendedCorsOptions: Options = {
      ...corsOptions,
      allowMethod: methodsForPath.map((method) => method.toUpperCase()),
    };

    const middleWare =
      optionsMiddleware === undefined
        ? [corsMiddlewarePreflight(extendedCorsOptions)]
        : [...optionsMiddleware, corsMiddlewarePreflight(extendedCorsOptions)];
    app.options(expressRoute, middleWare);
  }
};

app.ts

  const corsOptions = {
    allowOrigin: ['http://localhost:3000', 'http://patex.duckdns.org;7000'],
  };
  generateOptionsRouteFromSwagger(app, corsOptions);
  //Handle cors after generating the options route
  app.use(cors(corsOptions));

  //Serve before swagger
  app.use(
    '/static',
    express.static(APP_DIRECTORY + '/public', {
      setHeaders: (res) => {
        if (process.env.NODE_ENV === 'development') {
          res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
        }
      },
    })
  );

  // make swagger ui available under /api-docs
  swaggerRoute(app);
  // load frontend routes (everything here will be validated automatically)
  frontendRoutes(app);
import OpenApiValidator from 'express-openapi-validator';
import { Express, NextFunction, Request, Response } from 'express-serve-static-core';
import fs from 'fs';
import yaml from 'js-yaml';
import path from 'path';
import swaggerUi from 'swagger-ui-express';

import { logger } from '../logger.js';
import { internalServerError } from './backend/internalServerError.js';

export class HttpException extends Error {
  status: number;
  message: string;
  errors?: any;
  constructor(status: number, message: string) {
    super(message);
    this.status = status;
    this.message = message;
  }
}

export const swaggerRoute = (app: Express) => {
  const __dirname = path.resolve();

  const swaggerUiOptions = {
    customCss: '.swagger-ui .response-col_status {font-weight: 600;} .parameters-col_name {white-space: nowrap;}',
  };

  const specFile = path.join(__dirname, '/openapi.yml');

  logger.info('Api Path ' + specFile);

  const openApiSpec = yaml.load(fs.readFileSync(specFile, 'utf8'));
  app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(openApiSpec as any, swaggerUiOptions));

  app.use(
    OpenApiValidator.middleware({
      apiSpec: openApiSpec as any,
      validateRequests: true,
      validateResponses: false,
    })
  );

  //Error handler for open api validator
  app.use((err: HttpException, req: Request, res: Response, next: NextFunction) => {
    console.log(err);
    try {
      if (err.errors && Array.isArray(err.errors)) {
        res.status(err.status || 500).json({
          message: err.message,
          errors: err.errors,
        });
      } else {
        return internalServerError(err, res, 'Uncaught express error');
      }
    } catch (errCatch) {
      logger.error('Error during express error handler', {
        err: errCatch,
      });
      return res.sendStatus(500);
    }
  });
};

I created a small npx module which automatically generates the express stubs for me in a different file to just copy and paste them. But this is a rather opinionated setup. I might consider publishing a sample project, but the overall idea should come across.

mitchell-merry commented 1 year ago

Going to close this one! If anyone feels there's something still we can do, feel free to comment.