Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add OpenApi Specification (Swagger) #586

Open
2 tasks
brmcerqueira opened this issue Dec 4, 2021 · 11 comments · May be fixed by #620
Open
2 tasks

Add OpenApi Specification (Swagger) #586

brmcerqueira opened this issue Dec 4, 2021 · 11 comments · May be fixed by #620
Assignees

Comments

@brmcerqueira
Copy link

brmcerqueira commented Dec 4, 2021

Summary

What: Add OpenApi Specification (Swagger) on Resources using Typescript Decorators.

Why: Because many programmers need to this feature to make documentation about their rest apis.

Acceptance Criteria

  • Write documentation
  • Write unit tests

Example Pseudo Code (for implementation)

class HomeResource extends Drash.Resource {
  public paths = ["/"];

 @Oas({
      description: "It's a description.",
      body: { //We can be inspired by 'https://github.com/brmcerqueira/denvalidator' to validate the body.
              name: [required],
              child: array({
                  name: [required]
              })
          }
    })
  public POST(request: Drash.Request, response: Drash.Response): void {
    return response.json({
      hello: "world",
      time: new Date(),
    });
  }
}
@crookse
Copy link
Member

crookse commented Dec 4, 2021

hey @brmcerqueira !

  1. thanks for raising this!
  2. i completely agree with you that programmers need this to document their REST APIs without having to define the Swagger schema manually
  3. we made the decision to stay away from decorators when developing Drash v2 :(

i know the TypesScript decorators makes things convenient, but we really do not want to manage code around TypeScript decorators in the Drash codebase

there are a few options off the top of my head to introduce this feature though (and @ebebbington and @Guergeiro can probably give better ideas):

  1. we can use our service-based approach. that is, create a new service that can be plugged into the services config of a Drash.Server object. @ebebbington did something similar with our GraphQL service's playground.
  2. create a service that uses the TypeScript decorators and use your implementation. This service would possibly be in another repository so as not to have anything TypeScript decorator related in the Drash codebase.
  3. We write up documentation on the https://drash.land/drash pages to show users how to create their own Swagger service to introduce what you have mentioned

personally, i like the first option because it's our internal "pattern and approach" when introducing new features in Drash that aren't core to its server, but really we are open to your thoughts and anyone else who sees this issue :)

@brmcerqueira
Copy link
Author

hey @crookse

I like your suggestion. I'm going to think a way I can help you at development on this code.

@crookse
Copy link
Member

crookse commented Dec 4, 2021

awesome! feel free to hop in our discord for on-the-fly discussions: https://discord.gg/RFsCSaHRWK

@bradenmacdonald
Copy link

bradenmacdonald commented Dec 21, 2021

Hi folks, I thought you might be interested to see how I've approached this in my latest large Drash/Deno project. I'm a big fan of OpenAPI and generally of defining and validating API schemas using DRY code.

Although I don't get have OpenAPI generation implemented in this project, I do have the API shape fully defined and validated by code. I put the TypeScript schemas in a common library which is used by both my TypeScript/Deno backend and TypeScript/Node frontend, so I get all REST APIs fully typed (at compile time), validated (at runtime), and documented, in both frontend and backend.

It should be relatively easy to implement a layer on top of this which does generate OpenAPI.

This pattern doesn't use decorators and is implemented in a custom subclass of Drash.Resource that adds validation. As the request comes in, it validates that the body matches the body schema (if any), and after the response JSON is generated, it validates that the response also matches the schema.

The key that makes this all possible is the awesome computed-types library. It allows me to define the API shape once, and get full type information at both compile time and runtime, including custom rules like (a number greater than 5 or a string less than 4 characters long)

I define each resource like this:

import { MyHttpResource } from "api/mod.ts";
import { apiSchemas } from "deps/api-schemas.ts"

export class DraftIndexResource extends MyHttpResource {
    public paths = ["/api/draft"];

    // POST: create a new draft
    POST = this.method({
        // The request body must be CreateDraftSchema
        requestBodySchema: apiSchemas.CreateDraftSchema,
        // The response will be DraftSchema:
        responseSchema: apiSchemas.DraftSchema,
        // Describe this method for the auto-generated API docs:
        description: "Create a new draft",
    }, async ({request, bodyData}) => {
        // Handle the request.
        const {id, details, edits} = await createDraft(bodyData);
        // Return a response:
        return {
            id,
            ...details,
            edits,
        }
    });
}

And the schemas themselves look like this (simplified example):

import { Schema, string, nullable, array, DateType, object, } from "deps/computed-types.ts";

export const CreateDraftSchema = Schema({
    title: string.trim().min(2).max(300),  // A string between 2-300 characters, whitespace automatically trimmed
    description: nullable(string),
    edits: array.of(Schema({code: string, data: object})),
});

export const DraftSchema = Schema({
    id: string,
    author: Schema({
        username: string,
        fullName: nullable(string),
    }),
    title: string,
    description: nullable(string),
    edits: array.of(Schema({code: string, data: object})),
    created: DateType,
});

The implementation, which adds runtime validation for both request and response shape, is a fairly simple subclass:

Click to see Drash.Resource subclass implementation
import * as log from "std/log/mod.ts";
import { Drash } from "deps/drash.ts";
import { PathError, Type } from "deps/computed-types.ts";

type Validator<T> = (value: any) => T;
type JsonCompatibleValue = string | boolean | Record<string, unknown> | null | undefined;

/**
 * Base class for defining our API resources.
 * 
 * A resource has a path like "/user/profile" and can have one or more methods (like POST, GET, etc.)
 */
export abstract class MyHttpResource extends Drash.Resource {

    method<Response extends JsonCompatibleValue, RequestBody extends JsonCompatibleValue = undefined>(
        metadata: {
            responseSchema: Validator<Response>,
            requestBodySchema?: Validator<RequestBody>,
            description?: string,
            notes?: string,
        },
        fn: (args: {request: Drash.Request, response: Drash.Response, bodyData: Type<Validator<RequestBody>>}) => Promise<Type<Validator<Response>>>
    ) {
        return async (request: Drash.Request, response: Drash.Response): Promise<void> => {
            try {
                // Validate the request body, if any:
                const requestBodyValidated = this.validateRequestBody(request, metadata.requestBodySchema);

                // Run the request, and validate the response:
                const responseBodyValidated = metadata.responseSchema(await fn({request, response, bodyData: requestBodyValidated}));
                if (typeof responseBodyValidated !== "object" || responseBodyValidated === null) {
                    throw new Error(`Expected API implementation to return an object, not ${typeof responseBodyValidated}`);
                }
                // Return the response:
                response.json(responseBodyValidated);
            } catch (err: unknown) {
                // Log errors as structured JSON objects
                if (err instanceof MyApiError) {
                    // This error is safe to return to the API client:
                    response.json(err.asJSON());
                    log.warning(`Returned error response: ${err.message}`);
                } else {
                    // This error may leak internal information - just return a generic "internal error"
                    response.status = 500;
                    response.json({ message: "An internal error occurred" });
                    log.warning(`Returned "Internal error" response`);
                    log.error(err);
                }
            }
        };
    }

    private validateRequestBody<DataShape>(request: Drash.Request, schema?: Validator<DataShape>): Type<Validator<DataShape>> {
        if (schema === undefined) {
            return undefined as any;
        }
        try {
            return schema(request.bodyAll()) as any;
        } catch (validationError) {
            // Convert schema validation errors to a MyApiError subclass, for consistency:
            if (validationError instanceof Error && Array.isArray((validationError as any).errors)) {
                const errors: PathError[] = (validationError as any).errors;
                throw new InvalidFieldValue(errors.map(pe => ({
                    fieldPath: pe.path.join("."),
                    message: pe.error.message,
                })));
            }
            log.error(`validateRequestBody got an unexpected error type - expected a ValidationError, got: ${validationError}`);
            throw validationError;
        }
    }
}

Here's how it looks in VS Code when consuming this API from the frontend:
Screen Shot 2021-12-21 at 1 54 57 PM

In summary: I highly recommend using computed-types for this sort of purpose. It integrates well with TypeScript/Deno, and lets you define your schema once and then use it everywhere (with full TypeScript types and full runtime validation). Some work would be needed to actually generate OpenAPI, but I believe an approach like this would make an excellent foundation.

@crookse
Copy link
Member

crookse commented Dec 22, 2021

hey @bradenmacdonald , thanks for taking part in this discussion!

you have a very interesting approach to a base resource class. it's pretty awesome actually. so you've actually provided two solutions:

  1. i can see the way you define the request body schema being used in the swagger service we want to introduce. at first glance, it seems like it will be pretty easy to place a Drash.Service class around code like yours to help generate OpenAPI spec.
  2. @ebebbington @Guergeiro and i were discussing a validation service and it seems like you already have a solution 😄

i think using computed-types is definitely worth an investigation. could help with both the swagger service and the validation service.

again, thank you!

@ebebbington
Copy link
Member

@bradenmacdonald im with Eric, what you’ve done is awesome!

@Guergeiro
Copy link
Member

@bradenmacdonald if you can implement a community made OpenApi that integrates well with Drash, you get a ⭐ from us 😅

Seriously though, it wouldn't shock me if it was not part of Drash main repo.

@bradenmacdonald
Copy link

Thanks for the encouraging feedback everyone :)

I'm not sure I'll personally have time to package my approach up as a reusable service anytime soon, but it's something I would like to contribute to if I can eventually!

@crookse
Copy link
Member

crookse commented Mar 19, 2022

hey @brmcerqueira @bradenmacdonald , apologies for being a little slow on communication! i was a bit overconfident in thinking we could get this feature out this quarter. not looking so good on time honestly. i'm thinking we ship this feature out as internal releases though in case you both want to pull it down, test it, give feedback, etc. basically do this:

import * as Drash from "https://deno.land/x/drash@v2.6.0-internal-1/mod.ts";

i haven't added the validation yet, @brmcerqueira. also, @bradenmacdonald, i was having issues getting computed-types to work well with generating the Open API Spec. not sure if they would work well together (e.g., using computed-types to generate valid Open API Spec), or if i'm just doing something wrong.

anyways, here is the current implementation. would love your thoughts on it (positive and negative). does it look ok? could it be improved?

app.ts
import * as Drash from "...";
// Import the Open API Spec v2.0 Service with its resource that includes
// Swagger-related methods like `this.operationGet()`
import {
  Builders,
  OpenAPIService,
  Resource as OpenAPIResource
} from "...";

// Instantiate new service
export const oas = new OpenAPIService({
  // This takes in the Swagger Object in the Open API Spec
  swagger: {
    info: {
      title: "Drash API",
      version: "v1.0"
    }
  },

  // Also, you can specify where the Swagger UI can be viewed. In this case,
  // going to localhost:1447/swagger would show the Swagger UI
  path_to_swagger_ui: "/swagger"
});

// Destruct the builders that help build the Open API Spec. These are used in
// the resource below.
const {
  query,
  body,
  object,
  array,
  string,
  integer,
} = Builders;

// Create your resource. Notice how OpenAPIResource is used and not
// Drash.Resource. The OpenAPIResource extends Drash.Resource, but includes
// Swagger-related methods to help document the HTTP methods in the resource
// (took your approach, bradenmacdonald, with `this.method()`, but changed the
// method name to be closely named to Open API Spec.
export class HomeResource extends OpenAPIResource {
  public paths = ["/", "/home"];

  /**
   * Define a GET method for this resource using
   * `OpenAPIResource.operationGet()`. The first argument is an Operation
   * Object. To build this Operation Object, you use the builders above.
   */
  public GET = this.operationGet(
    {
      parameters: [
        query().name("test").type("string"),
      ],
      responses: {
        200: "Successful / OK",
      },
      // tags: [],
      // operationId: "something",
      // ... and anything that's in the Operation Object spec
    },
    (request: Drash.Request, response: Drash.Response) => {
      response.json({ hello: "world" });
    }
  );

  public POST = this.operationPost(
    {
      parameters: [
        query().name("test").type("string"),
        body().name("Request Payload").schema(
          object().properties({
            name: string().required(),
            type: array().items(object({
              account_id: integer(),
              user_id: integer().required(),
            })),
            nested_object: object().required(),
          }),
        )
      ]
    },
    (request: Drash.Request, response: Drash.Response): void => {
      return response.json({
        hello: "world",
        time: new Date(),
      });
    },
  );
}

// Create your server
const server = new Drash.Server({
  hostname: "0.0.0.0",
  port: 1447,
  protocol: "http",
  resources: [
    HomeResource,
  ],
  services: [oas],
});

// Run your server
server.run();

the above implementation would yield the following Open API v2.0 Spec

spec.json
{
  "info": {
    "title": "Drash",
    "version": "v1.0"
  },
  "swagger": "2.0",
  "paths": {
    "/": {
      "get": {
        "responses": {
          "200": {
            "description": "Successful / OK"
          }
        },
        "parameters": [
          {
            "in": "query",
            "name": "test",
            "type": "string"
          }
        ]
      },
      "post": {
        "responses": {
          "200": {
            "description": "OK"
          },
          "404": {
            "description": "Not Found"
          },
          "500": {
            "description": "Internal Server Error"
          }
        },
        "parameters": [
          {
            "in": "query",
            "name": "test",
            "type": "string"
          },
          {
            "in": "body",
            "name": "Request Payload",
            "schema": {
              "type": "object",
              "properties": {
                "name": {
                  "type": "string"
                },
                "type": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "account_id": {
                        "type": "integer",
                        "format": "int32"
                      },
                      "user_id": {
                        "type": "integer",
                        "format": "int32"
                      }
                    },
                    "required": [
                      "user_id"
                    ]
                  }
                },
                "nested_object": {
                  "type": "object"
                }
              },
              "required": [
                "name",
                "nested_object"
              ]
            }
          }
        ]
      }
    },
    "/home": {
      "get": {
        "responses": {
          "200": {
            "description": "Successful / OK"
          }
        },
        "parameters": [
          {
            "in": "query",
            "name": "test",
            "type": "string"
          }
        ]
      },
      "post": {
        "responses": {
          "200": {
            "description": "OK"
          },
          "404": {
            "description": "Not Found"
          },
          "500": {
            "description": "Internal Server Error"
          }
        },
        "parameters": [
          {
            "in": "query",
            "name": "test",
            "type": "string"
          },
          {
            "in": "body",
            "name": "Request Payload",
            "schema": {
              "type": "object",
              "properties": {
                "name": {
                  "type": "string"
                },
                "type": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "account_id": {
                        "type": "integer",
                        "format": "int32"
                      },
                      "user_id": {
                        "type": "integer",
                        "format": "int32"
                      }
                    },
                    "required": [
                      "user_id"
                    ]
                  }
                },
                "nested_object": {
                  "type": "object"
                }
              },
              "required": [
                "name",
                "nested_object"
              ]
            }
          }
        ]
      }
    }
  }
}

@crookse crookse self-assigned this Mar 19, 2022
@bradenmacdonald
Copy link

@crookse So sorry for the delayed reply here. Nice work! I'll try to test it out when I have some time.

I do prefer the computed-types approach because of its simple syntax and because it provides validation at the same time, but I haven't actually tested it for generating a schema spec like this, so you may be right that it has some issues when used for that. However, if you'd like me to try getting it to work, I'd be happy to give it a shot. In any case, your API here looks good to me as an alternative.

@crookse
Copy link
Member

crookse commented Mar 29, 2022

@bradenmacdonald no worries! thanks for responding! if you wanna give it a shot, please feel free! maybe the builders i have implemented could use computed types internally and parse them to make open api specs instead of doing it all themselves. i didn't try that but it seems doable. wish i had time to tinker with that idea, but probably won't have time for a while. ill see if i can come up with something in a couple weeks and post my findings in this thread if you don't get to it before i do. thanks again!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging a pull request may close this issue.

5 participants