From 33c7a3daff041dfe8d85892af804ecedba6e60bc Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Thu, 27 Aug 2026 08:56:05 -0400 Subject: [PATCH 1/7] chore: deno fmt README The Cloudflare Workers import was edited to a multi-line form after the last format run, so 410747ae shipped unformatted and `deno fmt --check` fails on it. Purely cosmetic; squash into 410747ae if you would rather it never appeared. Co-Authored-By: Claude Opus 5 --- README.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/README.md b/README.md index 5a9244551..16b5a4b54 100644 --- a/README.md +++ b/README.md @@ -293,10 +293,7 @@ Bun.serve({ Cloudflare Workers (JS) ```js -import { - Application, - Resource -} from "@drashland/drash/modules/http.native.js"; +import { Application, Resource } from "@drashland/drash/modules/http.native.js"; class Home extends Resource { paths = ["/"]; From 7e028e5d15153b5c92ca356061438f57a82ba8c3 Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Sat, 29 Aug 2026 07:16:31 -0400 Subject: [PATCH 2/7] chore: fmt --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 16b5a4b54..5a9244551 100644 --- a/README.md +++ b/README.md @@ -293,7 +293,10 @@ Bun.serve({ Cloudflare Workers (JS) ```js -import { Application, Resource } from "@drashland/drash/modules/http.native.js"; +import { + Application, + Resource +} from "@drashland/drash/modules/http.native.js"; class Home extends Resource { paths = ["/"]; From 7f9d5d8344f8b33fb0598c01000c7f398ffb4fd1 Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Mon, 24 Aug 2026 22:21:39 -0400 Subject: [PATCH 3/7] feat(decorators): document resource methods with @OpenAPI Adds src/modules/decorators/OpenAPI.ts: a method decorator carrying an OpenAPI v3 Operation Object, plus the typings for it and the readers that turn a set of annotated resources into the `paths` member of a document. class Users extends Resource { public override paths = ["/users/:id?"]; @OpenAPI({ summary: "Get a user", responses: { "200": { description: "The user" } }, }) public override GET(request: Request) { ... } } const paths = buildPathsObject([Users]); These are TC39 stage-3 decorators, which is what TypeScript 6 compiles to with no `experimentalDecorators` set -- `(value, context)`, not the legacy `(target, key, descriptor)`. Operations are held in a module-level WeakMap keyed by the method function, rather than as a property on the method. Decorating must not change what it decorates: a property would be visible to anything walking the prototype, and a subclass overriding GET would inherit it, attributing the parent's documentation to a different implementation. The decorator returns the method untouched, so the prototype's method is the key that was registered. buildPathsObject translates between the two path syntaxes. Drash matches with URLPattern, so a parameter is `:id`; OpenAPI wants `{id}` and requires path parameters to be `required: true`. An optional `:id?` therefore cannot map to a single path -- it expands into `/users` and `/users/{id}`, which is how those two URLs are expressed in a v3 document. Parameters described in the decorator win over the derived stubs, so a param can be given a schema or a description. Decorating a non-HTTP method name, a static method, or a private one throws at decoration time rather than leaving the operation silently absent from the generated document. No YAML serialisation here: the repo has no YAML dependency, and the returned object is plain enough for JSON.stringify or any YAML library to take. Co-Authored-By: Claude Opus 5 --- src/modules/decorators/OpenAPI.ts | 630 ++++++++++++++++++++++++++++++ 1 file changed, 630 insertions(+) create mode 100644 src/modules/decorators/OpenAPI.ts diff --git a/src/modules/decorators/OpenAPI.ts b/src/modules/decorators/OpenAPI.ts new file mode 100644 index 000000000..4e5de28f7 --- /dev/null +++ b/src/modules/decorators/OpenAPI.ts @@ -0,0 +1,630 @@ +/** + * Drash - A microframework for building JavaScript/TypeScript HTTP systems. + * Copyright (C) 2023-2026 Drash authors. The Drash authors are listed in the + * AUTHORS file at . This notice + * applies to Drash version 3.X.X and any later version. + * + * This file is part of Drash. See . + * + * Drash is free software: you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation, either version 3 of the License, or (at your option) any later + * version. + * + * Drash is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * Drash. If not, see . + */ + +// Imports > Core +import { Method } from "../../core/http/request/Method.ts"; +import type { RequestMethod } from "../../core/types/RequestMethod.ts"; + +/** + * A `$ref` pointer to a component defined elsewhere in the document. + * + * @see {@link https://spec.openapis.org/oas/v3.0.3#reference-object} + */ +type ReferenceObject = { + $ref: string; +}; + +/** + * @see {@link https://spec.openapis.org/oas/v3.0.3#external-documentation-object} + */ +type ExternalDocumentationObject = { + description?: string; + url: string; +}; + +/** + * @see {@link https://spec.openapis.org/oas/v3.0.3#server-object} + */ +type ServerObject = { + url: string; + description?: string; + variables?: Record; +}; + +/** + * A JSON Schema, as OpenAPI profiles it. + * + * Deliberately open at the edges: the spec allows any JSON Schema keyword plus + * `x-` extensions, and enumerating all of them here would date this file + * against every future draft without making the common cases any safer. + * + * @see {@link https://spec.openapis.org/oas/v3.0.3#schema-object} + */ +type SchemaObject = { + type?: + | "array" + | "boolean" + | "integer" + | "number" + | "object" + | "string"; + format?: string; + title?: string; + description?: string; + default?: unknown; + example?: unknown; + enum?: unknown[]; + nullable?: boolean; + deprecated?: boolean; + readOnly?: boolean; + writeOnly?: boolean; + /** Required for `type: "array"`. */ + items?: SchemaObject | ReferenceObject; + properties?: Record; + additionalProperties?: boolean | SchemaObject | ReferenceObject; + /** Names of required properties, not a boolean. */ + required?: string[]; + allOf?: (SchemaObject | ReferenceObject)[]; + anyOf?: (SchemaObject | ReferenceObject)[]; + oneOf?: (SchemaObject | ReferenceObject)[]; + not?: SchemaObject | ReferenceObject; + minimum?: number; + maximum?: number; + exclusiveMinimum?: boolean; + exclusiveMaximum?: boolean; + multipleOf?: number; + minLength?: number; + maxLength?: number; + pattern?: string; + minItems?: number; + maxItems?: number; + uniqueItems?: boolean; + minProperties?: number; + maxProperties?: number; + [extension: `x-${string}`]: unknown; +}; + +/** + * @see {@link https://spec.openapis.org/oas/v3.0.3#example-object} + */ +type ExampleObject = { + summary?: string; + description?: string; + value?: unknown; + externalValue?: string; +}; + +/** + * @see {@link https://spec.openapis.org/oas/v3.0.3#encoding-object} + */ +type EncodingObject = { + contentType?: string; + headers?: Record; + style?: string; + explode?: boolean; + allowReserved?: boolean; +}; + +/** + * One media type of a request or response body, keyed by content type. + * + * @see {@link https://spec.openapis.org/oas/v3.0.3#media-type-object} + */ +type MediaTypeObject = { + schema?: SchemaObject | ReferenceObject; + example?: unknown; + examples?: Record; + encoding?: Record; +}; + +/** + * A parameter with its `name` and `in` removed — a response header is + * identified by its key in the map it lives in, not by a `name` field. + * + * @see {@link https://spec.openapis.org/oas/v3.0.3#header-object} + */ +type HeaderObject = Omit; + +/** + * @see {@link https://spec.openapis.org/oas/v3.0.3#parameter-object} + */ +type ParameterObject = { + name: string; + in: "cookie" | "header" | "path" | "query"; + description?: string; + /** + * Must be `true` when `in` is `"path"`. {@link buildPathsObject} sets this + * for the path parameters it derives. + */ + required?: boolean; + deprecated?: boolean; + allowEmptyValue?: boolean; + style?: string; + explode?: boolean; + allowReserved?: boolean; + schema?: SchemaObject | ReferenceObject; + example?: unknown; + examples?: Record; + content?: Record; +}; + +/** + * @see {@link https://spec.openapis.org/oas/v3.0.3#request-body-object} + */ +type RequestBodyObject = { + description?: string; + content: Record; + required?: boolean; +}; + +/** + * @see {@link https://spec.openapis.org/oas/v3.0.3#link-object} + */ +type LinkObject = { + operationRef?: string; + operationId?: string; + parameters?: Record; + requestBody?: unknown; + description?: string; + server?: ServerObject; +}; + +/** + * @see {@link https://spec.openapis.org/oas/v3.0.3#response-object} + */ +type ResponseObject = { + /** The only required field on a response. */ + description: string; + headers?: Record; + content?: Record; + links?: Record; +}; + +/** + * Responses keyed by status code, or by `"default"`. + * + * Codes are strings in the document, not numbers — `"404"`, not `404` — and + * may use the `1XX`–`5XX` wildcard form. + * + * @see {@link https://spec.openapis.org/oas/v3.0.3#responses-object} + */ +type ResponsesObject = { + [statusCode: string]: ResponseObject | ReferenceObject; +}; + +/** + * @see {@link https://spec.openapis.org/oas/v3.0.3#callback-object} + */ +type CallbackObject = { + [expression: string]: PathItemObject; +}; + +/** + * Security schemes that apply, each naming the scopes it needs. + * + * @see {@link https://spec.openapis.org/oas/v3.0.3#security-requirement-object} + */ +type SecurityRequirementObject = Record; + +/** + * What a single HTTP method on a single path does. This is the shape + * {@link OpenAPI} takes. + * + * @see {@link https://spec.openapis.org/oas/v3.0.3#operation-object} + */ +type OperationObject = { + tags?: string[]; + summary?: string; + description?: string; + externalDocs?: ExternalDocumentationObject; + /** Must be unique across the whole document if given. */ + operationId?: string; + parameters?: (ParameterObject | ReferenceObject)[]; + requestBody?: RequestBodyObject | ReferenceObject; + /** + * Required by OpenAPI 3.0. Optional here so a resource can be annotated + * incrementally; a document assembled from operations with no `responses` + * will not validate against 3.0. + */ + responses?: ResponsesObject; + callbacks?: Record; + deprecated?: boolean; + security?: SecurityRequirementObject[]; + servers?: ServerObject[]; + [extension: `x-${string}`]: unknown; +}; + +/** + * One path, with an operation per HTTP method defined on it. + * + * @see {@link https://spec.openapis.org/oas/v3.0.3#path-item-object} + */ +type PathItemObject = { + summary?: string; + description?: string; + servers?: ServerObject[]; + parameters?: (ParameterObject | ReferenceObject)[]; + connect?: OperationObject; + delete?: OperationObject; + get?: OperationObject; + head?: OperationObject; + options?: OperationObject; + patch?: OperationObject; + post?: OperationObject; + put?: OperationObject; + trace?: OperationObject; +}; + +/** + * The `paths` member of an OpenAPI document. {@link buildPathsObject} returns + * this. + * + * @see {@link https://spec.openapis.org/oas/v3.0.3#paths-object} + */ +type PathsObject = Record; + +/** + * The shape of a resource's HTTP method. Matches the core `Resource`, whose + * methods take an unknown input and return an unknown output. + */ +type ResourceMethod = (...args: never[]) => unknown; + +/** + * Anything with a `paths` array and HTTP methods — that is, an instance of the + * core `Resource` or of any chain's `Resource`. + * + * Structural on purpose. Importing the concrete class would drag a `core` + * dependency into every consumer of these types for no gain, and would stop + * this working with a resource that only structurally matches. + */ +type ResourceLike = { + paths: string[]; +}; + +/** + * A resource class, as passed to a chain builder. + */ +type ResourceClass = new (...args: never[]) => ResourceLike; + +/** + * Operations, keyed by the method they decorate. + * + * A `WeakMap` rather than a property on the function: decorating must not + * change the method it decorates. Writing to the function would make the + * metadata visible to anything enumerating the prototype, and would be + * inherited by subclasses that override the method — which would silently + * attribute the parent's documentation to the child's implementation. + * + * The key is the method itself, and {@link OpenAPI} returns that same function + * unchanged, so `Users.prototype.GET` is the key that was registered. + */ +const operations = new WeakMap(); + +/** + * The HTTP method names a resource can define, lowercased to the casing the + * OpenAPI Path Item Object uses. + */ +const methodNames = new Set(Object.keys(Method)); + +/** + * Document a resource's HTTP method with an OpenAPI v3 Operation Object. + * + * The object is stored, not validated and not acted on at request time. It is + * read back by {@link getOperation}, {@link getOperations}, and + * {@link buildPathsObject}, which is what turns a set of annotated resources + * into the `paths` member of a document you can serialise to YAML or JSON. + * + * @param operation The Operation Object describing this method. + * @returns The method decorator. + * + * @example + * ```ts + * class Users extends Resource { + * public override paths = ["/users/:id"]; + * + * @OpenAPI({ + * summary: "Get a user", + * responses: { + * "200": { + * description: "The user", + * content: { + * "application/json": { + * schema: { type: "object" }, + * }, + * }, + * }, + * }, + * }) + * public override GET(request: Request) { + * return new Response("..."); + * } + * } + * ``` + */ +function OpenAPI(operation: OperationObject) { + return function ( + value: T, + context: ClassMethodDecoratorContext, + ): T { + if (context.kind !== "method") { + throw new TypeError( + `OpenAPI: can only decorate a method, but was used on a ${context.kind}.`, + ); + } + + if (context.static) { + throw new TypeError( + `OpenAPI: can only decorate an instance method, but \`${ + String(context.name) + }\` is static.`, + ); + } + + // A private method is unreachable from the prototype, so nothing could + // read the operation back off it. Better to say so at decoration time than + // to leave it silently missing from the generated document. + if (context.private) { + throw new TypeError( + `OpenAPI: can only decorate a public method, but \`${ + String(context.name) + }\` is private.`, + ); + } + + const name = String(context.name); + + if (!methodNames.has(name)) { + throw new TypeError( + `OpenAPI: \`${name}\` is not an HTTP method. Decorate one of: ${ + [...methodNames].join(", ") + }.`, + ); + } + + operations.set(value, operation); + + return value; + }; +} + +/** + * Get the Operation Object a method was decorated with. + * + * @param method The method to look up, e.g. `Users.prototype.GET`. + * @returns The operation, or `undefined` if the method was not decorated. + */ +function getOperation( + method: ResourceMethod | undefined, +): OperationObject | undefined { + if (!method) { + return undefined; + } + + return operations.get(method); +} + +/** + * Get every documented operation on a resource, keyed by HTTP method name. + * + * Walks the prototype chain, so a resource that inherits a decorated method + * from a base class reports it. An override that is not itself decorated is + * reported as undocumented rather than inheriting the parent's operation — + * different implementation, different documentation. + * + * @param resource The resource class to read. + * @returns The operations found, keyed by HTTP method name (`"GET"`, ...). + */ +function getOperations( + resource: ResourceClass, +): Partial> { + const found: Partial> = {}; + + for (const name of methodNames) { + const method = (resource.prototype as Record)[name]; + + if (typeof method !== "function") { + continue; + } + + const operation = operations.get(method as ResourceMethod); + + if (operation) { + found[name as RequestMethod] = operation; + } + } + + return found; +} + +/** + * Rewrite one of Drash's paths into the OpenAPI form, and name its parameters. + * + * Drash matches with `URLPattern`, so a path parameter is `:id`. OpenAPI wants + * `{id}`. An optional parameter (`:id?`) has no OpenAPI equivalent — a path + * parameter there is always required — so it expands into two paths, one with + * the parameter and one without, which is how the same two URLs are expressed + * in a document. + * + * @param path The resource path, e.g. `/users/:id?`. + * @returns One entry per OpenAPI path, each with the parameter names it takes. + */ +function toOpenAPIPaths( + path: string, +): { path: string; parameters: string[] }[] { + // Splitting "/users/:id" yields a leading "" for the root slash, and a + // trailing "" for a path written with one. Dropping the empties lets every + // segment below be joined the same way, with the slash always in front. + const segments = path.split("/").filter((segment) => segment !== ""); + const results: { path: string; parameters: string[] }[] = [{ + path: "", + parameters: [], + }]; + + for (const segment of segments) { + if (!segment.startsWith(":")) { + for (const result of results) { + result.path += `/${segment}`; + } + continue; + } + + const optional = segment.endsWith("?"); + const name = segment.slice(1, optional ? -1 : undefined); + + if (optional) { + // Keep the shorter path as it stands, and branch a longer one that + // carries the parameter. + for (const result of [...results]) { + results.push({ + path: `${result.path}/{${name}}`, + parameters: [...result.parameters, name], + }); + } + continue; + } + + for (const result of results) { + result.path += `/{${name}}`; + result.parameters.push(name); + } + } + + return results.map((result) => ({ + path: result.path === "" ? "/" : result.path, + parameters: result.parameters, + })); +} + +/** + * Build the `paths` member of an OpenAPI v3 document from annotated resources. + * + * Each resource contributes one entry per path it answers to, and one + * operation per decorated HTTP method. Path parameters are derived from the + * path itself and merged ahead of the operation's own `parameters`, so a + * parameter you describe yourself — to give it a schema or a description — + * wins over the generated stub. + * + * Resources with no decorated methods contribute nothing, so adding this to an + * existing app documents only what you have annotated so far. + * + * Serialise the result yourself. The document also needs `openapi`, `info`, + * and whatever else you want; this fills in the part that can be derived from + * the resources. + * + * @param resources The resource classes to read. + * @returns The Paths Object. + * + * @example + * ```ts + * const paths = buildPathsObject([Users, Home]); + * + * const document = { + * openapi: "3.0.3", + * info: { title: "My API", version: "1.0.0" }, + * paths, + * }; + * ``` + */ +function buildPathsObject(resources: ResourceClass[]): PathsObject { + const paths: PathsObject = {}; + + for (const resource of resources) { + const found = getOperations(resource); + + if (Object.keys(found).length === 0) { + continue; + } + + // `paths` is an instance field, so it takes constructing the resource to + // read it. Resources are constructed per request by the chain anyway, so + // this matches how they are already used. + const instance = new resource(); + + for (const path of instance.paths) { + for (const { path: openAPIPath, parameters } of toOpenAPIPaths(path)) { + const item: PathItemObject = paths[openAPIPath] ?? {}; + + for (const [name, operation] of Object.entries(found)) { + const described = new Set( + (operation.parameters ?? []) + .filter((parameter): parameter is ParameterObject => + !("$ref" in parameter) && parameter.in === "path" + ) + .map((parameter) => parameter.name), + ); + + const derived: ParameterObject[] = parameters + .filter((parameter) => !described.has(parameter)) + .map((parameter) => ({ + name: parameter, + in: "path", + required: true, + schema: { type: "string" }, + })); + + const merged = [...derived, ...(operation.parameters ?? [])]; + + item[name.toLowerCase() as Lowercase] = merged.length + ? { ...operation, parameters: merged } + : operation; + } + + paths[openAPIPath] = item; + } + } + } + + return paths; +} + +// FILE MARKER - PUBLIC API //////////////////////////////////////////////////// + +export { + buildPathsObject, + type CallbackObject, + type EncodingObject, + type ExampleObject, + type ExternalDocumentationObject, + getOperation, + getOperations, + type HeaderObject, + type LinkObject, + type MediaTypeObject, + OpenAPI, + type OperationObject, + type ParameterObject, + type PathItemObject, + type PathsObject, + type ReferenceObject, + type RequestBodyObject, + type ResourceClass, + type ResourceLike, + type ResourceMethod, + type ResponseObject, + type ResponsesObject, + type SchemaObject, + type SecurityRequirementObject, + type ServerObject, +}; From 74a07a05a311f456db642a2d3f944e6137b7af39 Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Tue, 25 Aug 2026 07:39:44 -0400 Subject: [PATCH 4/7] refactor(openapi): rename OpenAPI to OpenAPIv3 and add a module entry point The decorator and its module take the spec version in their name, so @OpenAPIv3 says which spec the Operation Object is written against. src/modules/openapiv3.ts joins http.native.ts and http.polyfill.ts as an entry point, re-exporting the decorator, the three readers, and all twenty object types. Consumers import from there rather than reaching into modules/decorators/, which keeps the decorators directory an implementation detail the way standard/ is. The four TypeError messages and the JSDoc example are renamed with it. An error reading "OpenAPI: `search` is not an HTTP method" names something the user never wrote, and the reference page quotes those strings verbatim, so leaving either side alone would make the docs wrong. Docs follow the rename: the page moves from decorators/open-api to decorators/openapiv3, mirroring the source file name as the reference sidebar requires. Nothing redirects on that site, so the inbound links from the Modules overview -- four table rows and a section card -- are repointed in the same change. Every import shown now uses modules/openapiv3.js. Prose about the specification itself ("an OpenAPI v3 document", "required by OpenAPI 3.0") is left alone. That is the format's name, not an identifier. Co-Authored-By: Claude Opus 5 --- docs/content/reference/modules/_meta.js | 1 + .../reference/modules/decorators/_meta.js | 4 + .../modules/decorators/openapiv3.mdx | 343 ++++++++++++++++++ docs/content/reference/modules/index.mdx | 5 + .../decorators/{OpenAPI.ts => OpenAPIv3.ts} | 48 ++- src/modules/openapiv3.ts | 61 ++++ 6 files changed, 454 insertions(+), 8 deletions(-) create mode 100644 docs/content/reference/modules/decorators/_meta.js create mode 100644 docs/content/reference/modules/decorators/openapiv3.mdx rename src/modules/decorators/{OpenAPI.ts => OpenAPIv3.ts} (92%) create mode 100644 src/modules/openapiv3.ts diff --git a/docs/content/reference/modules/_meta.js b/docs/content/reference/modules/_meta.js index 7fb186af4..db4219512 100644 --- a/docs/content/reference/modules/_meta.js +++ b/docs/content/reference/modules/_meta.js @@ -3,6 +3,7 @@ export default { // entry in this file, so a folder route with no page here would 404. index: "Overview", builders: "Builders", + decorators: "Decorators", // src/modules holds http.native.ts and http.polyfill.ts as files, not an // http/ directory, so these are pages here rather than a folder. "http-native": "http.native", diff --git a/docs/content/reference/modules/decorators/_meta.js b/docs/content/reference/modules/decorators/_meta.js new file mode 100644 index 000000000..2d1ad8038 --- /dev/null +++ b/docs/content/reference/modules/decorators/_meta.js @@ -0,0 +1,4 @@ +// Mirrors src/modules/decorators/. +export default { + openapiv3: "OpenAPIv3", +}; diff --git a/docs/content/reference/modules/decorators/openapiv3.mdx b/docs/content/reference/modules/decorators/openapiv3.mdx new file mode 100644 index 000000000..f79377bd4 --- /dev/null +++ b/docs/content/reference/modules/decorators/openapiv3.mdx @@ -0,0 +1,343 @@ +--- +title: OpenAPIv3 +description: Attaches an OpenAPI v3 Operation Object to a resource's HTTP method, and assembles those into a document's paths. +--- + +# OpenAPIv3 + +## Summary + +`OpenAPIv3` is a [decorator](https://www.typescriptlang.org/docs/handbook/decorators.html) — a marker you attach to a resource's HTTP method with `@` +syntax that records something about it without changing what it does. Here it +records an [OpenAPI](https://www.openapis.org/) v3 **Operation Object**: the +description of one HTTP method on one path, including what it accepts and what +it can respond with. + +__Nothing happens at request time.__ The description is stored, and read back later +to build the `paths` member of an OpenAPI document you can write out as YAML or +JSON. + +A complete, runnable example: + +```ts filename="app.ts" +import { + Resource, +} from "@drashland/drash/modules/http.native.js"; +import { + buildPathsObject, + OpenAPIv3, +} from "@drashland/drash/modules/openapiv3.js"; + +class Users extends Resource { + paths = ["/users/:id?"]; + + @OpenAPIv3({ + summary: "Get a user", + tags: ["users"], + responses: { + "200": { + description: "The user", + content: { + "application/json": { + schema: { type: "object" }, + }, + }, + }, + }, + }) + GET(request: Request) { + return new Response(`Getting ${request.params.pathParam("id")}`); + } +} + +// The `paths` member of an OpenAPI v3 document, derived from the resource. +const document = { + openapi: "3.0.3", + info: { title: "My API", version: "1.0.0" }, + paths: buildPathsObject([Users]), +}; + +console.log(JSON.stringify(document, null, 2)); +``` + +Run it with: + +```bash filename="Terminal" +deno run app.ts +``` +
+The resulting Open API v3 Spec in JSON +```json +{ + "openapi": "3.0.3", + "info": { + "title": "My API", + "version": "1.0.0" + }, + "paths": { + "/users": { + "get": { + "summary": "Get a user", + "tags": [ + "users" + ], + "responses": { + "200": { + "description": "The user", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/users/{id}": { + "get": { + "summary": "Get a user", + "tags": [ + "users" + ], + "responses": { + "200": { + "description": "The user", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + } + } +} +``` +
+ +The rest of this page takes that apart. + + +A **resource** is the class that answers requests for one or more paths, and its +`GET`, `POST`, and other methods handle those HTTP methods. See +[Creating a Resource](/docs/resources/creating-a-resource) if you have not +written one yet. + + +## The Decorator + +`@OpenAPIv3({ ... })` takes one argument: an Operation Object. Its fields are the +ones the +[OpenAPI v3 specification](https://spec.openapis.org/oas/v3.0.3#operation-object) +defines, so anything valid there is valid here. + +| Field | Type | What it is | +| -------------- | ------------------------ | ------------------------------------------------ | +| `tags` | `string[]` | Groups the operation under a heading in viewers | +| `summary` | `string` | One line describing the operation | +| `description` | `string` | The long form; CommonMark is allowed | +| `operationId` | `string` | A name for the operation, unique per document | +| `parameters` | `ParameterObject[]` | Path, query, header, and cookie parameters | +| `requestBody` | `RequestBodyObject` | The body the operation accepts, keyed by content type | +| `responses` | `ResponsesObject` | What it can respond with, keyed by status code | +| `deprecated` | `boolean` | Marks the operation as no longer recommended | +| `security` | `SecurityRequirementObject[]` | Which security schemes apply | +| `servers` | `ServerObject[]` | Servers for this operation, if not the default | +| `externalDocs` | `ExternalDocumentationObject` | A link to more documentation | +| `callbacks` | `Record` | Requests this operation may make later | + +Two details that catch people out, both from the specification rather than from +Drash: + +- **Status codes are strings, not numbers.** `"200"`, not `200`. The wildcard + forms (`"2XX"`) and `"default"` are also allowed. +- **`responses` is required by OpenAPI 3.0.** It is optional in this decorator + so you can annotate a resource a piece at a time, but a document assembled + from operations that have none will not validate. + +### Where It Can Go + +The decorator only makes sense on a resource's HTTP methods, and it says so +rather than failing quietly. Each of these throws a `TypeError` when the class +is defined: + +| Written on | Message | +| -------------------------------- | ----------------------------------------------------------- | +| A method that is not an HTTP method | ``OpenAPIv3: `search` is not an HTTP method. Decorate one of: CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE.`` | +| A `static` method | ``OpenAPIv3: can only decorate an instance method, but `GET` is static.`` | +| A `#private` method | ``OpenAPIv3: can only decorate a public method, but `GET` is private.`` | +| Anything that is not a method | ``OpenAPIv3: can only decorate a method, but was used on a field.`` | + +The private case is a real limitation rather than a style rule: a private method +cannot be reached from the class's prototype, so nothing could read the +description back off it. + + +These are +[TC39 stage 3 decorators](https://github.com/tc39/proposal-decorators), the +standard form, which TypeScript 5 and later compile by default. If your project +sets `"experimentalDecorators": true` in its `tsconfig.json`, turn it off — +that flag selects an older, incompatible decorator design. + + +## Reading a Description Back + +### `getOperation(method)` + +Returns the Operation Object a single method was decorated with, or `undefined` +if it was never decorated. + +```ts +import { getOperation } from "@drashland/drash/modules/openapiv3.js"; + +getOperation(Users.prototype.GET); // The object passed to @OpenAPIv3 +getOperation(Users.prototype.DELETE); // undefined -- not decorated +``` + +### `getOperations(ResourceClass)` + +Returns every description on a resource, keyed by HTTP method name. Methods that +were not decorated are absent rather than `undefined`. + +```ts +getOperations(Users); // { GET: { summary: "Get a user", ... } } +``` + +It walks the prototype chain, so a resource inherits a decorated method from a +base class. An override that is not itself decorated counts as undocumented: a +different implementation should not inherit the parent's description. + +## Building the Paths + +### `buildPathsObject(resources)` + +Takes resource classes and returns the `paths` member of an OpenAPI v3 document +— every path each resource answers to, with an entry per decorated method. + +```ts +const paths = buildPathsObject([Users, Home]); +``` + +Resources with no decorated methods contribute nothing, so adding this to an +existing application describes only what you have annotated so far. + +It returns the `paths` member alone. A document also needs `openapi` and `info` +at minimum, which is why the example at the top of this page wraps the result. + +### Path Parameters + +Drash and OpenAPI write path parameters differently, so `buildPathsObject` +translates between them. + +Drash matches requests with +[`URLPattern`](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern), +where a parameter is `:id` and a trailing `?` makes it optional. OpenAPI uses +`{id}`, and a path parameter there is **always required**. + +| Resource path | Becomes | +| ---------------- | ---------------------------- | +| `/users` | `/users` | +| `/users/:id` | `/users/{id}` | +| `/users/:id?` | `/users` **and** `/users/{id}` | + +An optional parameter has no single OpenAPI equivalent, so it expands into two +paths — which is how those two URLs are expressed in a document. + +Each derived parameter is added with `required: true` and a `string` schema. If +you describe the same parameter yourself, yours wins, so you can give it a +schema or a description: + +```ts {6-13} +class Users extends Resource { + paths = ["/users/:id"]; + + @OpenAPIv3({ + summary: "Get a user", + parameters: [ + { + name: "id", // Same name as the path parameter + in: "path", + required: true, + description: "The user's ID", + schema: { type: "integer", format: "int64" }, + }, + ], + responses: { "200": { description: "The user" } }, + }) + GET(request: Request) { + // ... + } +} +``` + +## Writing the YAML + +`buildPathsObject` returns a plain object, and Drash does not serialize it. Pass +it to whichever YAML library your runtime already has: + + + + + +```ts filename="spec.ts" +import { stringify } from "jsr:@std/yaml"; + +await Deno.writeTextFile("openapi.yaml", stringify(document)); +``` + + + + + +```js filename="spec.js" +import { writeFileSync } from "node:fs"; +import { stringify } from "yaml"; + +writeFileSync("openapi.yaml", stringify(document)); +``` + + + + + +## Types + +Every OpenAPI object this module accepts is exported as a type, so you can build +an operation in a variable and still be checked: + +```ts +import type { + OperationObject, + ParameterObject, + PathsObject, + ResponsesObject, + SchemaObject, +} from "@drashland/drash/modules/openapiv3.js"; + +const getUser: OperationObject = { + summary: "Get a user", + responses: { "200": { description: "The user" } }, +}; +``` + +The full set: `CallbackObject`, `EncodingObject`, `ExampleObject`, +`ExternalDocumentationObject`, `HeaderObject`, `LinkObject`, `MediaTypeObject`, +`OperationObject`, `ParameterObject`, `PathItemObject`, `PathsObject`, +`ReferenceObject`, `RequestBodyObject`, `ResourceClass`, `ResourceLike`, +`ResourceMethod`, `ResponseObject`, `ResponsesObject`, `SchemaObject`, +`SecurityRequirementObject`, and `ServerObject`. diff --git a/docs/content/reference/modules/index.mdx b/docs/content/reference/modules/index.mdx index 45116f95e..4b5e7b9fc 100644 --- a/docs/content/reference/modules/index.mdx +++ b/docs/content/reference/modules/index.mdx @@ -25,6 +25,10 @@ The pieces that do the work, composed from [Standard](/reference/standard) and [ | `CORS` | function | `modules/middleware/CORS.js` | [CORS](/reference/modules/middleware/cors) | | `ETag` | function | `modules/middleware/ETag.js` | [ETag](/reference/modules/middleware/etag) | | `RateLimiter` | function | `modules/middleware/RateLimiter.js` | [RateLimiter](/reference/modules/middleware/rate-limiter) | +| `OpenAPIv3` | decorator | `modules/openapiv3.js` | [OpenAPIv3](/reference/modules/decorators/openapiv3) | +| `buildPathsObject` | function | `modules/openapiv3.js` | [OpenAPIv3](/reference/modules/decorators/openapiv3) | +| `getOperation` | function | `modules/openapiv3.js` | [OpenAPIv3](/reference/modules/decorators/openapiv3) | +| `getOperations` | function | `modules/openapiv3.js` | [OpenAPIv3](/reference/modules/decorators/openapiv3) | Each pre-built middleware also exports its `XMiddleware` class, its `Options` type, and its `defaultOptions` — see the individual pages. @@ -48,5 +52,6 @@ __Any change to one entry point has to be mirrored in the other.__ They are sepa + diff --git a/src/modules/decorators/OpenAPI.ts b/src/modules/decorators/OpenAPIv3.ts similarity index 92% rename from src/modules/decorators/OpenAPI.ts rename to src/modules/decorators/OpenAPIv3.ts index 4e5de28f7..89e2da82d 100644 --- a/src/modules/decorators/OpenAPI.ts +++ b/src/modules/decorators/OpenAPIv3.ts @@ -320,7 +320,39 @@ type ResourceClass = new (...args: never[]) => ResourceLike; * The key is the method itself, and {@link OpenAPI} returns that same function * unchanged, so `Users.prototype.GET` is the key that was registered. */ -const operations = new WeakMap(); +const registryKey = Symbol.for("drash.openapi.operations"); + +/** + * The one registry, shared by every copy of this module in the process. + * + * A plain module-level `WeakMap` is not enough. A bundler inlining this module + * into a resource file, a package installed at two versions, or a project + * loading both the CJS and the ESM build all produce more than one copy — and + * each copy would get its own map. The decorator would write to one and + * {@link getOperations} would read another, reporting every resource as + * undocumented with nothing to explain why. + * + * `Symbol.for` looks up the process-wide symbol registry, so every copy + * resolves the same key and the first one to load creates the map the rest + * reuse. + */ +function getRegistry(): WeakMap { + const host = globalThis as unknown as Record; + const existing = host[registryKey] as + | WeakMap + | undefined; + + if (existing) { + return existing; + } + + const registry = new WeakMap(); + host[registryKey] = registry; + + return registry; +} + +const operations = getRegistry(); /** * The HTTP method names a resource can define, lowercased to the casing the @@ -344,7 +376,7 @@ const methodNames = new Set(Object.keys(Method)); * class Users extends Resource { * public override paths = ["/users/:id"]; * - * @OpenAPI({ + * @OpenAPIv3({ * summary: "Get a user", * responses: { * "200": { @@ -363,20 +395,20 @@ const methodNames = new Set(Object.keys(Method)); * } * ``` */ -function OpenAPI(operation: OperationObject) { +function OpenAPIv3(operation: OperationObject) { return function ( value: T, context: ClassMethodDecoratorContext, ): T { if (context.kind !== "method") { throw new TypeError( - `OpenAPI: can only decorate a method, but was used on a ${context.kind}.`, + `OpenAPIv3: can only decorate a method, but was used on a ${context.kind}.`, ); } if (context.static) { throw new TypeError( - `OpenAPI: can only decorate an instance method, but \`${ + `OpenAPIv3: can only decorate an instance method, but \`${ String(context.name) }\` is static.`, ); @@ -387,7 +419,7 @@ function OpenAPI(operation: OperationObject) { // to leave it silently missing from the generated document. if (context.private) { throw new TypeError( - `OpenAPI: can only decorate a public method, but \`${ + `OpenAPIv3: can only decorate a public method, but \`${ String(context.name) }\` is private.`, ); @@ -397,7 +429,7 @@ function OpenAPI(operation: OperationObject) { if (!methodNames.has(name)) { throw new TypeError( - `OpenAPI: \`${name}\` is not an HTTP method. Decorate one of: ${ + `OpenAPIv3: \`${name}\` is not an HTTP method. Decorate one of: ${ [...methodNames].join(", ") }.`, ); @@ -612,7 +644,7 @@ export { type HeaderObject, type LinkObject, type MediaTypeObject, - OpenAPI, + OpenAPIv3, type OperationObject, type ParameterObject, type PathItemObject, diff --git a/src/modules/openapiv3.ts b/src/modules/openapiv3.ts new file mode 100644 index 000000000..80f26c6c1 --- /dev/null +++ b/src/modules/openapiv3.ts @@ -0,0 +1,61 @@ +/** + * Drash - A microframework for building JavaScript/TypeScript HTTP systems. + * Copyright (C) 2023-2026 Drash authors. The Drash authors are listed in the + * AUTHORS file at . This notice + * applies to Drash version 3.X.X and any later version. + * + * This file is part of Drash. See . + * + * Drash is free software: you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation, either version 3 of the License, or (at your option) any later + * version. + * + * Drash is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * Drash. If not, see . + */ + +// FILE MARKER - PUBLIC API //////////////////////////////////////////////////// +// +// This public API may re-export (or relay) values exported from other modules. +// For more information on re-export/relay, see the following: +// +// https://developer.mozilla.org/en-US/docs/web/javascript/reference/statements/export#re-exporting_aggregating +// + +// Exports > Modules +export { + buildPathsObject, + getOperation, + getOperations, + OpenAPIv3, +} from "./decorators/OpenAPIv3.ts"; + +// Exports > Modules > Types +export type { + CallbackObject, + EncodingObject, + ExampleObject, + ExternalDocumentationObject, + HeaderObject, + LinkObject, + MediaTypeObject, + OperationObject, + ParameterObject, + PathItemObject, + PathsObject, + ReferenceObject, + RequestBodyObject, + ResourceClass, + ResourceLike, + ResourceMethod, + ResponseObject, + ResponsesObject, + SchemaObject, + SecurityRequirementObject, + ServerObject, +} from "./decorators/OpenAPIv3.ts"; From 9b52c5627736c645d73cbb7b0f453cd1c953e5ba Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Sat, 29 Aug 2026 07:17:59 -0400 Subject: [PATCH 5/7] feat(openapi): add the drash-openapi CLI Builds an OpenAPI v3 document from resource files, so the glue between buildPathsObject and a JSON file is not written by hand: npx --package @drashland/drash drash-openapi ./build/resources -o openapi.json Takes files or directories, walked recursively and skipping node_modules and dotdirs. Options cover info.title, info.version, info.description, servers, the OpenAPI version, and the JSON indent. Exit code is 1 on every failure so it can gate a build. It imports the resource files rather than parsing them, so the decorators run and the document reflects what the code declares. Only classes with a decorated method are passed on: buildPathsObject constructs what it is given to read `paths`, and handing it every export would construct unrelated classes. Point it at COMPILED JavaScript. Decorators are syntax rather than types, so Node cannot parse a file still containing the decorator, and its type stripping does not help -- stripping types leaves the decorator behind. The CLI lives in bin/ rather than src/. tsup's --dts pass runs tsc, which cannot resolve `node:` builtins here because tsconfig.json sets no moduleResolution and Classic resolution only handles relative paths. Loosening that for the whole library would let a runtime-specific global type-check inside core/, which is the opposite of what this codebase is. A bin is not library surface, so it gets its own tsup pass with no declarations, and `deno task check` covers it. `bin` in package.json is copied into dist/package.json by doit.ts along with the rest of the manifest, so no publishing change is needed. Co-Authored-By: Claude Opus 5 --- bin/openapiv3.ts | 380 +++++++++++++++++++++++++++++++++++++++++++++++ deno.json | 8 +- package.json | 3 + 3 files changed, 387 insertions(+), 4 deletions(-) create mode 100644 bin/openapiv3.ts diff --git a/bin/openapiv3.ts b/bin/openapiv3.ts new file mode 100644 index 000000000..fb5c3a0c9 --- /dev/null +++ b/bin/openapiv3.ts @@ -0,0 +1,380 @@ +#!/usr/bin/env node +/** + * Drash - A microframework for building JavaScript/TypeScript HTTP systems. + * Copyright (C) 2023-2026 Drash authors. The Drash authors are listed in the + * AUTHORS file at . This notice + * applies to Drash version 3.X.X and any later version. + * + * This file is part of Drash. See . + * + * Drash is free software: you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation, either version 3 of the License, or (at your option) any later + * version. + * + * Drash is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * Drash. If not, see . + */ + +// Imports > Modules +import { buildPathsObject, getOperations } from "../src/modules/openapiv3.ts"; +import type { PathsObject, ResourceClass } from "../src/modules/openapiv3.ts"; + +const USAGE = ` +drash-openapi - build an OpenAPI v3 document from your resource files + +USAGE + drash-openapi [options] + + Each is a resource file, or a directory to search for one. Directories + are walked recursively. Shell globs work, because the shell expands them + before this runs: + + drash-openapi src/resources/*.js + +OPTIONS + -o, --out Write to this file. Defaults to stdout. + --title info.title. Default: "API". + --api-version info.version. Default: "1.0.0". + --description info.description. + --server Add a server. Repeat for more than one. + --openapi OpenAPI version. Default: "3.0.3". + --indent JSON indent. Default: 2. Use 0 for one line. + -h, --help Show this. + +NOTES + Resource files are imported, not parsed, so the decorators run and the + document reflects what your code actually declares. Anything a module does at + import time therefore happens here too -- keep server startup behind a guard. + + Point this at COMPILED JavaScript. Decorators are syntax, not types, so + Node cannot parse a file still containing "@OpenAPIv3(...)" -- and its type + stripping does not help, because stripping types leaves the decorator behind. + Build your resources first (tsc, tsup, esbuild) and run this on the output. +`; + +/** + * The subset of the OpenAPI Info Object this builds. + * + * @see {@link https://spec.openapis.org/oas/v3.0.3#info-object} + */ +type InfoObject = { + title: string; + version: string; + description?: string; +}; + +/** + * The document written out. Only the members this can derive or be told. + */ +type Document = { + openapi: string; + info: InfoObject; + servers?: { url: string }[]; + paths: PathsObject; +}; + +/** + * Everything the CLI needs, after the arguments have been read. + */ +type Options = { + paths: string[]; + out?: string; + title: string; + apiVersion: string; + description?: string; + servers: string[]; + openapi: string; + indent: number; +}; + +/** + * Extensions worth importing. A resource file is JavaScript or TypeScript, and + * anything else in a directory being walked is not one. + * + * Declaration files are excluded: importing a `.d.ts` yields no runtime value, + * so it can only ever contribute nothing or throw. + */ +const importable = [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"]; + +/** + * Read the command line. + * + * @param argv The arguments, without the node binary or the script path. + * @returns The options, or `undefined` when usage was requested. + */ +function parse(argv: string[]): Options | undefined { + const options: Options = { + paths: [], + title: "API", + apiVersion: "1.0.0", + servers: [], + openapi: "3.0.3", + indent: 2, + }; + + // Hand-rolled rather than `node:util`'s `parseArgs`, which is still marked + // experimental on the Node versions in `engines` and prints a warning on + // some of them. + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + + const value = () => { + const next = argv[++i]; + if (next === undefined || next.startsWith("-")) { + throw new Error(`${arg} needs a value.`); + } + return next; + }; + + switch (arg) { + case "-h": + case "--help": + return undefined; + case "-o": + case "--out": + options.out = value(); + break; + case "--title": + options.title = value(); + break; + case "--api-version": + options.apiVersion = value(); + break; + case "--description": + options.description = value(); + break; + case "--server": + options.servers.push(value()); + break; + case "--openapi": + options.openapi = value(); + break; + case "--indent": { + const indent = Number(value()); + if (!Number.isInteger(indent) || indent < 0) { + throw new Error(`--indent needs a whole number, got "${argv[i]}".`); + } + options.indent = indent; + break; + } + default: + if (arg.startsWith("-")) { + throw new Error(`Unknown option "${arg}".`); + } + options.paths.push(arg); + } + } + + if (options.paths.length === 0) { + throw new Error("No paths given. Pass a resource file or a directory."); + } + + return options; +} + +/** + * Expand the given paths into the files worth importing. + * + * @param paths Files and directories, as given on the command line. + * @returns Absolute paths to the files found, in a stable order. + */ +async function collectFiles(paths: string[]): Promise { + const { readdir, stat } = await import("node:fs/promises"); + const { extname, join, resolve } = await import("node:path"); + + const files: string[] = []; + + const walk = async (path: string): Promise => { + const info = await stat(path); + + if (info.isFile()) { + // A file named on the command line is imported whatever it is called. + // Only directory contents get filtered, since nothing there was chosen. + files.push(path); + return; + } + + if (!info.isDirectory()) { + return; + } + + for (const entry of (await readdir(path, { withFileTypes: true }))) { + const child = join(path, entry.name); + + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name.startsWith(".")) { + continue; + } + await walk(child); + continue; + } + + if (entry.name.endsWith(".d.ts")) { + continue; + } + + if (importable.includes(extname(entry.name))) { + files.push(child); + } + } + }; + + for (const path of paths) { + await walk(resolve(path)); + } + + return [...new Set(files)].sort(); +} + +/** + * Import each file and collect the resource classes that carry an operation. + * + * Only decorated classes are returned. {@link buildPathsObject} constructs + * what it is given in order to read `paths`, so handing it every export would + * mean constructing unrelated classes for nothing. + * + * @param files Absolute paths to import. + * @returns The decorated resource classes found, in file order. + */ +async function collectResources(files: string[]): Promise { + const { pathToFileURL } = await import("node:url"); + + const resources: ResourceClass[] = []; + + for (const file of files) { + let module: Record; + + try { + module = await import(pathToFileURL(file).href) as Record< + string, + unknown + >; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Could not import ${file}\n ${message}`); + } + + for (const exported of Object.values(module)) { + if (typeof exported !== "function" || !exported.prototype) { + continue; + } + + const resource = exported as ResourceClass; + + if (Object.keys(getOperations(resource)).length > 0) { + resources.push(resource); + } + } + } + + return resources; +} + +/** + * Build the document. + * + * @param options The options read from the command line. + * @returns The document, ready to serialise. + */ +async function build(options: Options): Promise { + const files = await collectFiles(options.paths); + + if (files.length === 0) { + throw new Error("No JavaScript or TypeScript files found in those paths."); + } + + const resources = await collectResources(files); + + if (resources.length === 0) { + throw new Error( + `No resources with @OpenAPIv3 decorators found in ${files.length} file(s).`, + ); + } + + const document: Document = { + openapi: options.openapi, + info: { + title: options.title, + version: options.apiVersion, + }, + paths: buildPathsObject(resources), + }; + + if (options.description) { + document.info.description = options.description; + } + + if (options.servers.length > 0) { + document.servers = options.servers.map((url) => ({ url })); + } + + return document; +} + +/** + * Run the CLI. + * + * @param argv The arguments, without the node binary or the script path. + * @returns The process exit code. + */ +async function main(argv: string[]): Promise { + let options: Options | undefined; + + try { + options = parse(argv); + } catch (error) { + console.error(`drash-openapi: ${(error as Error).message}`); + console.error(`\nRun with --help for usage.`); + return 1; + } + + if (!options) { + console.log(USAGE.trim()); + return 0; + } + + let document: Document; + + try { + document = await build(options); + } catch (error) { + console.error(`drash-openapi: ${(error as Error).message}`); + return 1; + } + + const json = JSON.stringify(document, null, options.indent); + + if (!options.out) { + console.log(json); + return 0; + } + + const { writeFile, mkdir } = await import("node:fs/promises"); + const { dirname } = await import("node:path"); + + await mkdir(dirname(options.out), { recursive: true }); + // A trailing newline, so the file is well-formed for anything reading it + // line by line and does not show up as "no newline at end of file" in a diff. + await writeFile(options.out, `${json}\n`); + + const count = Object.keys(document.paths).length; + console.error(`drash-openapi: wrote ${count} path(s) to ${options.out}`); + + return 0; +} + +const process = globalThis.process; + +main(process.argv.slice(2)) + .then((code) => { + process.exitCode = code; + }) + .catch((error: unknown) => { + console.error(`drash-openapi: ${(error as Error).message}`); + process.exitCode = 1; + }); diff --git a/deno.json b/deno.json index 3db0725ec..3a3a4c2b9 100644 --- a/deno.json +++ b/deno.json @@ -55,25 +55,25 @@ }, "tasks": { "build:check": "deno task check:file-headers && deno task npm:clean && pnpm install && deno task build:libs && deno task test:all", - "build:libs": "deno run --allow-read --allow-write --allow-net ./scripts/doit.ts && pnpm exec tsup src --format cjs,esm --no-splitting --dts", + "build:libs": "deno run --allow-read --allow-write --allow-net ./scripts/doit.ts && pnpm exec tsup src --format cjs,esm --no-splitting --dts && pnpm exec tsup bin/openapiv3.ts --format esm --no-splitting --out-dir dist/bin", "bump-version": "deno run --allow-read --allow-write ./scripts/bump_version.ts", - "check": "deno check src tests/unit tests/compat/deno tests/middleware", "check:docs": "deno doc --lint $(deno eval 'console.log(Object.values(JSON.parse(Deno.readTextFileSync(\"deno.json\")).exports).join(\" \"))')", "check:file-headers": "deno run --allow-read ./scripts/check_file_headers.ts", "check:release-version-fields": "deno run --allow-read ./scripts/check_package_json_version_for_release.ts", + "check": "deno check src bin tests/unit tests/compat/deno tests/middleware", "docs:build": "cd docs && pnpm install && pnpm build", "docs:dev": "cd docs && pnpm install && pnpm dev", "lint:tests:bun": "deno lint tests/compat/bun", "lint:tests:node": "deno lint tests/compat/node*/*", "npm:clean": "rm -rf node_modules && rm pnpm-lock.yaml || true", "publish:dry-run": "npm stage publish --access restricted --dry-run", - "publish:jsr": "deno publish", "publish:jsr:dry-run": "deno publish --dry-run", + "publish:jsr": "deno publish", "test:all": "deno task test:compat:bun && deno task test:compat:deno && deno task test:compat:deno:concurrency && deno task test:compat:cloudflare && deno task test:compat:node && deno task test:middleware:deno && deno task test:unit", "test:compat:bun": "bun test tests/compat/bun", "test:compat:cloudflare": "pnpm exec vitest run --config vitest.config.cloudflare.mts", - "test:compat:deno": "deno test tests/compat/deno --ignore='**/concurrency/*.ts'", "test:compat:deno:concurrency": "deno test tests/compat/deno/**/concurrency", + "test:compat:deno": "deno test tests/compat/deno --ignore='**/concurrency/*.ts'", "test:compat:node": "pnpm exec vitest run --config vitest.config.node.mts", "test:middleware:deno": "deno test tests/middleware/deno --allow-net", "test:unit": "deno test tests/unit" diff --git a/package.json b/package.json index a82b0e04b..4dbffd6d1 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "description": "Web Standards-first, runtime-agnostic web framework", "author": "Eric Crooks (https://crookse.com)", "license": "GPL-3.0-or-later", + "bin": { + "drash-openapi": "./bin/openapiv3.mjs" + }, "repository": { "type": "git", "url": "git+ssh://git@github.com/drashland/drash.git" From 6e1c045d7c2d9c2a6296781a6950108d43875f5c Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Tue, 25 Aug 2026 08:37:59 -0400 Subject: [PATCH 6/7] docs(openapi): fold the decorator page into the openapiv3 entry point modules/openapiv3.ts is the path consumers import from, so it gets the page and modules/decorators/ stops being a destination. The decorators directory is an implementation detail, the same way standard/ internals are not import targets, and a sidebar entry for it invited people to depend on the path. The decorator page's content moves wholesale rather than being rewritten, so the runnable example and its resulting document survive intact. The entry point's framing -- module identity, import, and an exports table -- is grafted on ahead of it, and the CLI section sits with the other way of producing a document, before the hand-written YAML step. Nothing redirects on this site, so the four inbound rows on the Modules overview and its section card move in the same change, and the two cross-page links become in-page anchors. The Modules sidebar is reordered so files come before directories -- the order `ls src/modules` gives -- with `index` still pinned first, since the Modules breadcrumb resolves to whatever is first and a folder route with no page 404s. Terminology throughout is "OpenAPI Specification v3", abbreviated to "OAS v3" after the first use. It is the name of the format; "OpenAPI" alone is the organisation. Identifiers are untouched: the OpenAPIv3 decorator, the openapiv3 module, the --openapi flag, the `openapi` document field, the drash-openapi command, the openapi.json and openapi.yaml filenames, and the four quoted TypeError strings all keep their spelling. bin/openapiv3.ts moves with it. The page documents those flags verbatim, so leaving the CLI alone would have made --help read "OpenAPI version" while the table beside it read "OAS version". Co-Authored-By: Claude Opus 5 --- bin/openapiv3.ts | 7 +- docs/content/reference/modules/_meta.js | 12 +- .../reference/modules/decorators/_meta.js | 4 - docs/content/reference/modules/index.mdx | 12 +- .../modules/{decorators => }/openapiv3.mdx | 204 +++++++++++++++--- 5 files changed, 194 insertions(+), 45 deletions(-) delete mode 100644 docs/content/reference/modules/decorators/_meta.js rename docs/content/reference/modules/{decorators => }/openapiv3.mdx (56%) diff --git a/bin/openapiv3.ts b/bin/openapiv3.ts index fb5c3a0c9..e29b62a2f 100644 --- a/bin/openapiv3.ts +++ b/bin/openapiv3.ts @@ -25,7 +25,8 @@ import { buildPathsObject, getOperations } from "../src/modules/openapiv3.ts"; import type { PathsObject, ResourceClass } from "../src/modules/openapiv3.ts"; const USAGE = ` -drash-openapi - build an OpenAPI v3 document from your resource files +drash-openapi - build an OpenAPI Specification v3 (OAS v3) document from your + resource files USAGE drash-openapi [options] @@ -42,7 +43,7 @@ OPTIONS --api-version info.version. Default: "1.0.0". --description info.description. --server Add a server. Repeat for more than one. - --openapi OpenAPI version. Default: "3.0.3". + --openapi OAS version. Default: "3.0.3". --indent JSON indent. Default: 2. Use 0 for one line. -h, --help Show this. @@ -58,7 +59,7 @@ NOTES `; /** - * The subset of the OpenAPI Info Object this builds. + * The subset of the OAS v3 Info Object this builds. * * @see {@link https://spec.openapis.org/oas/v3.0.3#info-object} */ diff --git a/docs/content/reference/modules/_meta.js b/docs/content/reference/modules/_meta.js index db4219512..633be4dc1 100644 --- a/docs/content/reference/modules/_meta.js +++ b/docs/content/reference/modules/_meta.js @@ -1,12 +1,16 @@ +// Mirrors src/modules/. Files come first, then directories — the same order a +// reader sees running `ls` on the source. export default { // Must stay first and present. The "Modules" breadcrumb resolves to the first // entry in this file, so a folder route with no page here would 404. index: "Overview", - builders: "Builders", - decorators: "Decorators", - // src/modules holds http.native.ts and http.polyfill.ts as files, not an - // http/ directory, so these are pages here rather than a folder. + + // src/modules holds http.native.ts, http.polyfill.ts, and openapiv3.ts as + // files, not directories, so these are pages here rather than folders. "http-native": "http.native", "http-polyfill": "http.polyfill", + openapiv3: "openapiv3", + + builders: "Builders", middleware: "Middleware", }; diff --git a/docs/content/reference/modules/decorators/_meta.js b/docs/content/reference/modules/decorators/_meta.js deleted file mode 100644 index 2d1ad8038..000000000 --- a/docs/content/reference/modules/decorators/_meta.js +++ /dev/null @@ -1,4 +0,0 @@ -// Mirrors src/modules/decorators/. -export default { - openapiv3: "OpenAPIv3", -}; diff --git a/docs/content/reference/modules/index.mdx b/docs/content/reference/modules/index.mdx index 4b5e7b9fc..cb7bf9034 100644 --- a/docs/content/reference/modules/index.mdx +++ b/docs/content/reference/modules/index.mdx @@ -25,10 +25,10 @@ The pieces that do the work, composed from [Standard](/reference/standard) and [ | `CORS` | function | `modules/middleware/CORS.js` | [CORS](/reference/modules/middleware/cors) | | `ETag` | function | `modules/middleware/ETag.js` | [ETag](/reference/modules/middleware/etag) | | `RateLimiter` | function | `modules/middleware/RateLimiter.js` | [RateLimiter](/reference/modules/middleware/rate-limiter) | -| `OpenAPIv3` | decorator | `modules/openapiv3.js` | [OpenAPIv3](/reference/modules/decorators/openapiv3) | -| `buildPathsObject` | function | `modules/openapiv3.js` | [OpenAPIv3](/reference/modules/decorators/openapiv3) | -| `getOperation` | function | `modules/openapiv3.js` | [OpenAPIv3](/reference/modules/decorators/openapiv3) | -| `getOperations` | function | `modules/openapiv3.js` | [OpenAPIv3](/reference/modules/decorators/openapiv3) | +| `OpenAPIv3` | decorator | `modules/openapiv3.js` | [openapiv3](/reference/modules/openapiv3) | +| `buildPathsObject` | function | `modules/openapiv3.js` | [openapiv3](/reference/modules/openapiv3) | +| `getOperation` | function | `modules/openapiv3.js` | [openapiv3](/reference/modules/openapiv3) | +| `getOperations` | function | `modules/openapiv3.js` | [openapiv3](/reference/modules/openapiv3) | Each pre-built middleware also exports its `XMiddleware` class, its `Options` type, and its `defaultOptions` — see the individual pages. @@ -51,7 +51,7 @@ __Any change to one entry point has to be mirrored in the other.__ They are sepa - - + + diff --git a/docs/content/reference/modules/decorators/openapiv3.mdx b/docs/content/reference/modules/openapiv3.mdx similarity index 56% rename from docs/content/reference/modules/decorators/openapiv3.mdx rename to docs/content/reference/modules/openapiv3.mdx index f79377bd4..23103a96a 100644 --- a/docs/content/reference/modules/decorators/openapiv3.mdx +++ b/docs/content/reference/modules/openapiv3.mdx @@ -1,20 +1,25 @@ --- -title: OpenAPIv3 -description: Attaches an OpenAPI v3 Operation Object to a resource's HTTP method, and assembles those into a document's paths. +title: openapiv3 +description: The OpenAPI Specification v3 (OAS v3) entry point. Attaches an Operation Object to a resource's HTTP method, and assembles those into a document's paths. --- -# OpenAPIv3 +# openapiv3 + +The [OpenAPI Specification v3](https://spec.openapis.org/oas/v3.0.3) (OAS v3) +entry point, `modules/openapiv3.js`. It gives you a decorator for describing +what a resource's HTTP methods do, and the functions that turn those +descriptions into an OAS v3 document. ## Summary -`OpenAPIv3` is a [decorator](https://www.typescriptlang.org/docs/handbook/decorators.html) — a marker you attach to a resource's HTTP method with `@` -syntax that records something about it without changing what it does. Here it -records an [OpenAPI](https://www.openapis.org/) v3 **Operation Object**: the +This module exports the `OpenAPIv3` [decorator](https://www.typescriptlang.org/docs/handbook/decorators.html) — a marker you attach to a resource's HTTP method with `@` +syntax. It records something about the HTTP method without changing what it does. Here it +records an OAS v3 **Operation Object**: the description of one HTTP method on one path, including what it accepts and what it can respond with. __Nothing happens at request time.__ The description is stored, and read back later -to build the `paths` member of an OpenAPI document you can write out as YAML or +to build the `paths` member of an OAS v3 document you can write out as YAML or JSON. A complete, runnable example: @@ -27,6 +32,7 @@ import { buildPathsObject, OpenAPIv3, } from "@drashland/drash/modules/openapiv3.js"; + import { stringify as yamlStringify } from "jsr:@std/yaml"; class Users extends Resource { paths = ["/users/:id?"]; @@ -50,7 +56,7 @@ class Users extends Resource { } } -// The `paths` member of an OpenAPI v3 document, derived from the resource. +// The `paths` member of an OAS v3 document, derived from the resource. const document = { openapi: "3.0.3", info: { title: "My API", version: "1.0.0" }, @@ -58,15 +64,19 @@ const document = { }; console.log(JSON.stringify(document, null, 2)); + +console.log() + +console.log(yamlStringify(document)); ``` -Run it with: +Run it with the following to print the OAS v3 document in JSON: ```bash filename="Terminal" deno run app.ts ```
-The resulting Open API v3 Spec in JSON +Click here to see the printed OAS v3 document in JSON ```json { "openapi": "3.0.3", @@ -129,6 +139,39 @@ deno run app.ts } ```
+
+Click here to see the printed OAS v3 document in YAML +```yaml +openapi: 3.0.3 +info: + title: My API + version: 1.0.0 +paths: + /users: + get: + summary: Get a user + tags: &ref_0 + - users + responses: &ref_1 + '200': + description: The user + content: + application/json: + schema: + type: object + '/users/{id}': + get: + summary: Get a user + tags: *ref_0 + responses: *ref_1 + parameters: + - name: id + in: path + required: true + schema: + type: string +``` +
The rest of this page takes that apart. @@ -143,7 +186,7 @@ written one yet. `@OpenAPIv3({ ... })` takes one argument: an Operation Object. Its fields are the ones the -[OpenAPI v3 specification](https://spec.openapis.org/oas/v3.0.3#operation-object) +[OAS v3](https://spec.openapis.org/oas/v3.0.3#operation-object) defines, so anything valid there is valid here. | Field | Type | What it is | @@ -161,20 +204,17 @@ defines, so anything valid there is valid here. | `externalDocs` | `ExternalDocumentationObject` | A link to more documentation | | `callbacks` | `Record` | Requests this operation may make later | -Two details that catch people out, both from the specification rather than from -Drash: +Two nuances of the OAS that you should be aware of: - **Status codes are strings, not numbers.** `"200"`, not `200`. The wildcard forms (`"2XX"`) and `"default"` are also allowed. -- **`responses` is required by OpenAPI 3.0.** It is optional in this decorator - so you can annotate a resource a piece at a time, but a document assembled - from operations that have none will not validate. +- **`responses` is required by OAS v3.** Drash makes this optional in this decorator + so you can annotate a resource a piece at a time, but an OAS v3 document assembled + from operations that have no `responses` block will not validate. ### Where It Can Go -The decorator only makes sense on a resource's HTTP methods, and it says so -rather than failing quietly. Each of these throws a `TypeError` when the class -is defined: +The decorator only makes sense on a resource's HTTP methods. The decorator will error if placed incorrectly rather than failing quietly. Each of these throws a `TypeError` when the class is defined: | Written on | Message | | -------------------------------- | ----------------------------------------------------------- | @@ -203,8 +243,31 @@ Returns the Operation Object a single method was decorated with, or `undefined` if it was never decorated. ```ts +import { Resource } from "@drashland/drash/modules/http.native.js"; import { getOperation } from "@drashland/drash/modules/openapiv3.js"; +class Users extends Resource { + paths = ["/users/:id?"]; + + @OpenAPIv3({ + summary: "Get a user", + tags: ["users"], + responses: { + "200": { + description: "The user", + content: { + "application/json": { + schema: { type: "object" }, + }, + }, + }, + }, + }) + GET(request: Request) { + return new Response(`Getting ${request.params.pathParam("id")}`); + } +} + getOperation(Users.prototype.GET); // The object passed to @OpenAPIv3 getOperation(Users.prototype.DELETE); // undefined -- not decorated ``` @@ -212,9 +275,38 @@ getOperation(Users.prototype.DELETE); // undefined -- not decorated ### `getOperations(ResourceClass)` Returns every description on a resource, keyed by HTTP method name. Methods that -were not decorated are absent rather than `undefined`. +were not decorated are omitted rather than `undefined`. ```ts +import { Resource } from "@drashland/drash/modules/http.native.js"; +import { getOperation } from "@drashland/drash/modules/openapiv3.js"; + +class Users extends Resource { + paths = ["/users/:id?"]; + + @OpenAPIv3({ + summary: "Get a user", + tags: ["users"], + responses: { + "200": { + description: "The user", + content: { + "application/json": { + schema: { type: "object" }, + }, + }, + }, + }, + }) + GET(request: Request) { + return new Response(`Getting ${request.params.pathParam("id")}`); + } + + POST(request: Request) { + return new Response("Not decorated, so it is omitted in the OAS document") + } +} + getOperations(Users); // { GET: { summary: "Get a user", ... } } ``` @@ -226,10 +318,16 @@ different implementation should not inherit the parent's description. ### `buildPathsObject(resources)` -Takes resource classes and returns the `paths` member of an OpenAPI v3 document +Takes resource classes and returns the `paths` member of an OAS v3 document — every path each resource answers to, with an entry per decorated method. ```ts +import { buildPathsObject } from "@drashland/drash/modules/openapiv3.js"; + +// +// code shortened for brevity +// + const paths = buildPathsObject([Users, Home]); ``` @@ -241,12 +339,12 @@ at minimum, which is why the example at the top of this page wraps the result. ### Path Parameters -Drash and OpenAPI write path parameters differently, so `buildPathsObject` +Drash and OAS v3 write path parameters differently, so `buildPathsObject` translates between them. Drash matches requests with [`URLPattern`](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern), -where a parameter is `:id` and a trailing `?` makes it optional. OpenAPI uses +where a parameter is `:id` and a trailing `?` makes it optional. OAS v3 uses `{id}`, and a path parameter there is **always required**. | Resource path | Becomes | @@ -255,11 +353,11 @@ where a parameter is `:id` and a trailing `?` makes it optional. OpenAPI uses | `/users/:id` | `/users/{id}` | | `/users/:id?` | `/users` **and** `/users/{id}` | -An optional parameter has no single OpenAPI equivalent, so it expands into two +An optional parameter has no single OAS v3 equivalent, so it expands into two paths — which is how those two URLs are expressed in a document. -Each derived parameter is added with `required: true` and a `string` schema. If -you describe the same parameter yourself, yours wins, so you can give it a +By default, each derived parameter is added with `required: true` and a `string` schema. If +you describe the same parameter yourself, yours overwrites the default, so you can give it a schema or a description: ```ts {6-13} @@ -270,7 +368,7 @@ class Users extends Resource { summary: "Get a user", parameters: [ { - name: "id", // Same name as the path parameter + name: "id", // Same name as the path parameter in: "path", required: true, description: "The user's ID", @@ -285,6 +383,45 @@ class Users extends Resource { } ``` +## Generating a Document From the Command Line + +Writing that wrapper by hand is optional. The package ships a `drash-openapi` +command that imports your resource files and writes the document for you: + +```bash filename="Terminal" +npx --package @drashland/drash drash-openapi ./build/resources -o openapi.json +``` + +Give it files, or directories to search. It walks directories recursively, +skipping `node_modules` and dotted folders, and exits non-zero on failure so it +can gate a build. + +| Option | What it sets | +| ----------------- | -------------------------------------------------- | +| `-o`, `--out` | Where to write. Defaults to standard output. | +| `--title` | `info.title`. Default: `API`. | +| `--api-version` | `info.version`. Default: `1.0.0`. | +| `--description` | `info.description`. | +| `--server` | Adds a server. Repeat for more than one. | +| `--openapi` | The OAS version. Default: `3.0.3`. | +| `--indent` | JSON indent. Default: `2`. Use `0` for one line. | +| `-h`, `--help` | Prints usage. | + + +__Point it at compiled JavaScript.__ + +The command imports your resource files rather than reading them as text, so the +decorators run and the document reflects what your code declares. Decorators are +syntax rather than types, so a file still containing `@OpenAPIv3(...)` cannot be +imported by Node — and Node's type stripping does not help, because +stripping the types leaves the decorator behind. Build your resources first, +then run the command on the output. + + +Because the files are imported, anything a module does when it loads happens +here too. Keep starting a server behind a guard so generating a document does +not bind a port. + ## Writing the YAML `buildPathsObject` returns a plain object, and Drash does not serialize it. Pass @@ -315,9 +452,20 @@ writeFileSync("openapi.yaml", stringify(document)); +## Exports + +| Export | Kind | What it is | +| ------------------ | --------- | --------------------------------------------------------------------- | +| `OpenAPIv3` | decorator | Attaches an Operation Object to a resource's HTTP method | +| `buildPathsObject` | function | Turns annotated resources into the `paths` member of a document | +| `getOperation` | function | Reads back the Operation Object one method was given | +| `getOperations` | function | Reads back every description on a resource, keyed by HTTP method name | + +Twenty object types are exported alongside them, listed under [Types](#types). + ## Types -Every OpenAPI object this module accepts is exported as a type, so you can build +Every OAS v3 object this module accepts is exported as a type, so you can build an operation in a variable and still be checked: ```ts From 6abd7caa5f0783e78b8aa4183c500a24e3ca99f3 Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Sun, 30 Aug 2026 21:19:10 -0400 Subject: [PATCH 7/7] feat: separation --- README.md | 5 +- bin/openapiv3.ts | 0 docs/content/reference/modules/_meta.js | 5 +- .../reference/modules/openapiv3/_meta.js | 10 + .../reference/modules/openapiv3/cli.mdx | 199 ++++++++++++++++++ .../decorators.mdx} | 52 +---- .../reference/modules/openapiv3/index.mdx | 26 +++ package.json | 1 + 8 files changed, 247 insertions(+), 51 deletions(-) mode change 100644 => 100755 bin/openapiv3.ts create mode 100644 docs/content/reference/modules/openapiv3/_meta.js create mode 100644 docs/content/reference/modules/openapiv3/cli.mdx rename docs/content/reference/modules/{openapiv3.mdx => openapiv3/decorators.mdx} (85%) create mode 100644 docs/content/reference/modules/openapiv3/index.mdx diff --git a/README.md b/README.md index 5a9244551..16b5a4b54 100644 --- a/README.md +++ b/README.md @@ -293,10 +293,7 @@ Bun.serve({ Cloudflare Workers (JS) ```js -import { - Application, - Resource -} from "@drashland/drash/modules/http.native.js"; +import { Application, Resource } from "@drashland/drash/modules/http.native.js"; class Home extends Resource { paths = ["/"]; diff --git a/bin/openapiv3.ts b/bin/openapiv3.ts old mode 100644 new mode 100755 diff --git a/docs/content/reference/modules/_meta.js b/docs/content/reference/modules/_meta.js index 633be4dc1..1d6648648 100644 --- a/docs/content/reference/modules/_meta.js +++ b/docs/content/reference/modules/_meta.js @@ -9,7 +9,10 @@ export default { // files, not directories, so these are pages here rather than folders. "http-native": "http.native", "http-polyfill": "http.polyfill", - openapiv3: "openapiv3", + // Labelled for the spec rather than the filename. The other two entries name + // their file because "http.native" is already how a reader refers to it; the + // module here is universally called OpenAPI v3, not openapiv3. + openapiv3: "OpenAPI v3", builders: "Builders", middleware: "Middleware", diff --git a/docs/content/reference/modules/openapiv3/_meta.js b/docs/content/reference/modules/openapiv3/_meta.js new file mode 100644 index 000000000..2b4ad0c82 --- /dev/null +++ b/docs/content/reference/modules/openapiv3/_meta.js @@ -0,0 +1,10 @@ +// `index` must stay first and present. The "OpenAPI v3" breadcrumb resolves to +// the first entry here, and a folder route with no page 404s. +// +// Decorators before CLI: the decorator is what you write, the CLI is what reads +// it back, and the second only makes sense once you have the first. +export default { + index: "Overview", + decorators: "Decorators", + cli: "CLI", +}; diff --git a/docs/content/reference/modules/openapiv3/cli.mdx b/docs/content/reference/modules/openapiv3/cli.mdx new file mode 100644 index 000000000..5fbe0103a --- /dev/null +++ b/docs/content/reference/modules/openapiv3/cli.mdx @@ -0,0 +1,199 @@ +--- +title: CLI +description: drash-openapi builds an OpenAPI Specification v3 document from your resource files by importing them and reading the decorators back. +--- + +# CLI + +`drash-openapi` builds an [OpenAPI Specification v3](https://spec.openapis.org/oas/v3.0.3) (OAS v3) document from your resource files. It does what the assembly example on [Decorators](/reference/modules/openapiv3/decorators) does — import the resources, read the operations back, wrap them in a document — without you writing that wrapper. + +The command ships with the npm package as a `bin` entry, so it needs no separate install: + +```bash filename="Terminal" +npx --package @drashland/drash drash-openapi ./build/resources +``` + +With no `-o`, the document goes to standard output. + +## Quick Example + +Given a decorated resource: + +```ts filename="src/resources/users.ts" +import { Resource } from "@drashland/drash/modules/http.native.js"; +import { OpenAPIv3 } from "@drashland/drash/modules/openapiv3.js"; + +export class Users extends Resource { + public override paths = ["/users/:id"]; + + @OpenAPIv3({ + summary: "Get a user", + responses: { "200": { description: "The user" } }, + }) + public override GET(_request: Request) { + return new Response("ok"); + } +} +``` + +Build it, then run the command against the output: + +```bash filename="Terminal" +npx --package @drashland/drash drash-openapi ./build --title "Users API" --api-version 2.1.0 +``` + +```json filename="stdout" +{ + "openapi": "3.0.3", + "info": { + "title": "Users API", + "version": "2.1.0" + }, + "paths": { + "/users/{id}": { + "get": { + "summary": "Get a user", + "responses": { + "200": { + "description": "The user" + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + } + } +} +``` + +Two things were derived rather than declared. The resource's `/users/:id` became OAS v3's `/users/{id}`, and the `id` path parameter was added to `parameters` as a required string. See [Path Parameters](/reference/modules/openapiv3/decorators#path-parameters) for the rules. + +## Arguments + +``` +drash-openapi [options] +``` + +Each `` is a resource file, or a directory to search for one. + +- **A file named directly is imported whatever it is called.** Only directory contents get filtered, since nothing in a directory was chosen by you. +- **Directories are walked recursively**, skipping `node_modules` and any folder whose name starts with a dot. Inside them, only `.js`, `.mjs`, `.cjs`, `.ts`, `.mts`, and `.cts` files are considered, and `.d.ts` files are skipped. +- **Shell globs work**, because the shell expands them before the command runs: `drash-openapi build/resources/*.js`. + +Duplicate paths are collapsed, and files are processed in sorted order so the same input always produces the same document. + +## Options + +| Option | What it sets | Default | +| ----------------- | ------------------------------------------------ | ------- | +| `-o`, `--out` | Where to write. Omit to print to standard output. | stdout | +| `--title` | `info.title` | `API` | +| `--api-version` | `info.version` | `1.0.0` | +| `--description` | `info.description` | unset | +| `--server` | Adds a `servers` entry. Repeat for more than one. | unset | +| `--openapi` | The OAS version in `openapi` | `3.0.3` | +| `--indent` | JSON indent. `0` writes one line. | `2` | +| `-h`, `--help` | Prints usage and exits. | — | + +`--api-version` sets your API's version, not the OAS version. The OAS version is `--openapi`. They are separate on purpose, and mixing them up is the easy mistake here. + +```bash filename="Terminal" +npx --package @drashland/drash drash-openapi ./build \ + --title "Users API" \ + --api-version 2.1.0 \ + --description "Everything about users." \ + --server https://api.example.com \ + --server https://staging.example.com \ + --indent 0 \ + -o openapi.json +``` + +``` +drash-openapi: wrote 1 path(s) to /path/to/openapi.json +``` + +The confirmation goes to standard error, so `-o` is not needed to keep stdout clean — redirecting stdout to a file works just as well. + +## Point It at Compiled JavaScript + + +__The command imports your resource files. It does not read them as text.__ + +That is deliberate: the decorators run, so the document reflects what your code actually declares rather than what a parser guesses from the source. + +It also means __a file still containing `@OpenAPIv3(...)` cannot be imported by Node.__ Decorators are syntax, not types, so Node's type stripping does not help — stripping the types leaves the decorator behind. Build your resources first with `tsc`, `tsup`, or `esbuild`, and run the command on the output. + + +Because the files are imported, anything a module does at import time happens here too. Keep starting a server behind a guard, or generating a document will bind a port: + +```js +// Not this — importing the file starts the server. +Deno.serve({ port: 1447 }, handler); + +// This — only when run directly. +if (import.meta.main) { + Deno.serve({ port: 1447 }, handler); +} +``` + +## Exit Codes and Errors + +The command exits non-zero on failure, so it can gate a build. + +| Situation | Exit | Where it goes | +| -------------------------------------------- | ---- | ------------- | +| Document written | 0 | stdout, or the `-o` file | +| `--help` | 0 | stdout | +| No paths given | 1 | stderr | +| Unknown option, or an option missing a value | 1 | stderr | +| A file could not be imported | 1 | stderr | +| No decorated resources found | 1 | stderr | + +Every message is prefixed `drash-openapi:` and written to standard error, so a failure never contaminates the document on standard output. + +An import failure names the file and the underlying error, which is usually an undecorated build: + +``` +drash-openapi: Could not import /path/to/build/users.js + Unexpected token '@' +``` + +Finding files but no decorated resources is also an error rather than an empty document, on the grounds that an empty `paths` is nearly always a mistake: + +``` +drash-openapi: No resources with @OpenAPIv3 decorators found in 12 file(s). +``` + +## In a Build + +Because a non-zero exit fails the step, the command can regenerate the document and a diff can prove it is current: + +```yaml filename=".github/workflows/openapi.yml" +- name: Build resources + run: npm run build + +- name: Generate the OAS v3 document + run: npx --package @drashland/drash drash-openapi ./build -o openapi.json + +- name: Fail if the committed document is stale + run: git diff --exit-code openapi.json +``` + +## Writing YAML Instead + +The command only writes JSON. For YAML, print to standard output and convert with whatever your runtime already has — every YAML parser reads JSON, since JSON is a subset of YAML: + +```bash filename="Terminal" +npx --package @drashland/drash drash-openapi ./build \ + | npx js-yaml > openapi.yaml +``` + +Or call [`buildPathsObject`](/reference/modules/openapiv3/decorators#buildpathsobjectresources) yourself and serialize the result. See [Writing the YAML](/reference/modules/openapiv3/decorators#writing-the-yaml). diff --git a/docs/content/reference/modules/openapiv3.mdx b/docs/content/reference/modules/openapiv3/decorators.mdx similarity index 85% rename from docs/content/reference/modules/openapiv3.mdx rename to docs/content/reference/modules/openapiv3/decorators.mdx index 23103a96a..946c87eb5 100644 --- a/docs/content/reference/modules/openapiv3.mdx +++ b/docs/content/reference/modules/openapiv3/decorators.mdx @@ -1,14 +1,13 @@ --- -title: openapiv3 -description: The OpenAPI Specification v3 (OAS v3) entry point. Attaches an Operation Object to a resource's HTTP method, and assembles those into a document's paths. +title: Decorators +description: The OpenAPI Specification v3 (OAS v3) decorator and the functions that read it back. Attaches an Operation Object to a resource's HTTP method, and assembles those into a document's paths. --- -# openapiv3 +# Decorators -The [OpenAPI Specification v3](https://spec.openapis.org/oas/v3.0.3) (OAS v3) -entry point, `modules/openapiv3.js`. It gives you a decorator for describing -what a resource's HTTP methods do, and the functions that turn those -descriptions into an OAS v3 document. +The decorator half of `modules/openapiv3.js`. It describes what a resource's HTTP methods do, and turns those descriptions into the `paths` member of an [OpenAPI Specification v3](https://spec.openapis.org/oas/v3.0.3) (OAS v3) document. + +To generate a whole document without writing the assembly yourself, see [the CLI](/reference/modules/openapiv3/cli). ## Summary @@ -383,45 +382,6 @@ class Users extends Resource { } ``` -## Generating a Document From the Command Line - -Writing that wrapper by hand is optional. The package ships a `drash-openapi` -command that imports your resource files and writes the document for you: - -```bash filename="Terminal" -npx --package @drashland/drash drash-openapi ./build/resources -o openapi.json -``` - -Give it files, or directories to search. It walks directories recursively, -skipping `node_modules` and dotted folders, and exits non-zero on failure so it -can gate a build. - -| Option | What it sets | -| ----------------- | -------------------------------------------------- | -| `-o`, `--out` | Where to write. Defaults to standard output. | -| `--title` | `info.title`. Default: `API`. | -| `--api-version` | `info.version`. Default: `1.0.0`. | -| `--description` | `info.description`. | -| `--server` | Adds a server. Repeat for more than one. | -| `--openapi` | The OAS version. Default: `3.0.3`. | -| `--indent` | JSON indent. Default: `2`. Use `0` for one line. | -| `-h`, `--help` | Prints usage. | - - -__Point it at compiled JavaScript.__ - -The command imports your resource files rather than reading them as text, so the -decorators run and the document reflects what your code declares. Decorators are -syntax rather than types, so a file still containing `@OpenAPIv3(...)` cannot be -imported by Node — and Node's type stripping does not help, because -stripping the types leaves the decorator behind. Build your resources first, -then run the command on the output. - - -Because the files are imported, anything a module does when it loads happens -here too. Keep starting a server behind a guard so generating a document does -not bind a port. - ## Writing the YAML `buildPathsObject` returns a plain object, and Drash does not serialize it. Pass diff --git a/docs/content/reference/modules/openapiv3/index.mdx b/docs/content/reference/modules/openapiv3/index.mdx new file mode 100644 index 000000000..07a6d892c --- /dev/null +++ b/docs/content/reference/modules/openapiv3/index.mdx @@ -0,0 +1,26 @@ +--- +title: openapiv3 +description: The OpenAPI Specification v3 (OAS v3) entry point. A decorator for describing what a resource's HTTP methods do, and a command that turns those descriptions into a document. +--- + +# openapiv3 + +The [OpenAPI Specification v3](https://spec.openapis.org/oas/v3.0.3) (OAS v3) entry point, `modules/openapiv3.js`. + +You describe an HTTP method by decorating it, and Drash reads those descriptions back to build the `paths` member of an OAS v3 document. Nothing happens at request time — the decorator only records. + + + + + + +## Which Page You Want + +| If you want to | Read | +| ---------------------------------------------------------------- | --------------------------------------------------------------- | +| Describe what a resource's HTTP method does | [Decorators](/reference/modules/openapiv3/decorators) | +| Read a description back, or build `paths` yourself | [Decorators](/reference/modules/openapiv3/decorators) | +| Generate a whole document from your resource files | [CLI](/reference/modules/openapiv3/cli) | +| Regenerate the document in CI and fail the build when it is stale | [CLI](/reference/modules/openapiv3/cli) | + +The two are the same machinery. `drash-openapi` imports your resources, calls the same functions the Decorators page documents, and wraps the result in a document — so anything the command does, you can do by hand if you need more control over the output. diff --git a/package.json b/package.json index 4dbffd6d1..3f1f83746 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "description": "Web Standards-first, runtime-agnostic web framework", "author": "Eric Crooks (https://crookse.com)", "license": "GPL-3.0-or-later", + "type": "module", "bin": { "drash-openapi": "./bin/openapiv3.mjs" },