-
Notifications
You must be signed in to change notification settings - Fork 32
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
Comments
hey @brmcerqueira !
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):
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 :) |
hey @crookse I like your suggestion. I'm going to think a way I can help you at development on this code. |
awesome! feel free to hop in our discord for on-the-fly discussions: https://discord.gg/RFsCSaHRWK |
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 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 implementationimport * 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: 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. |
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:
i think using again, thank you! |
@bradenmacdonald im with Eric, what you’ve done is awesome! |
@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. |
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! |
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.tsimport * 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 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. |
@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! |
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
Example Pseudo Code (for implementation)
The text was updated successfully, but these errors were encountered: