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 new file mode 100755 index 000000000..e29b62a2f --- /dev/null +++ b/bin/openapiv3.ts @@ -0,0 +1,381 @@ +#!/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 Specification v3 (OAS 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 OAS 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 OAS v3 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/docs/content/reference/modules/_meta.js b/docs/content/reference/modules/_meta.js index 7fb186af4..1d6648648 100644 --- a/docs/content/reference/modules/_meta.js +++ b/docs/content/reference/modules/_meta.js @@ -1,11 +1,19 @@ +// 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", - // 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", + // 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/index.mdx b/docs/content/reference/modules/index.mdx index 45116f95e..cb7bf9034 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/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. @@ -47,6 +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/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/decorators.mdx b/docs/content/reference/modules/openapiv3/decorators.mdx new file mode 100644 index 000000000..946c87eb5 --- /dev/null +++ b/docs/content/reference/modules/openapiv3/decorators.mdx @@ -0,0 +1,451 @@ +--- +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. +--- + +# Decorators + +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 + +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 OAS v3 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"; + import { stringify as yamlStringify } from "jsr:@std/yaml"; + +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 OAS 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)); + +console.log() + +console.log(yamlStringify(document)); +``` + +Run it with the following to print the OAS v3 document in JSON: + +```bash filename="Terminal" +deno run app.ts +``` +
+Click here to see the printed OAS v3 document 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" + } + } + ] + } + } + } +} +``` +
+
+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. + + +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 +[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 | +| -------------- | ------------------------ | ------------------------------------------------ | +| `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 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 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. 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 | +| -------------------------------- | ----------------------------------------------------------- | +| 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 { 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 +``` + +### `getOperations(ResourceClass)` + +Returns every description on a resource, keyed by HTTP method name. Methods that +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", ... } } +``` + +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 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]); +``` + +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 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. OAS v3 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 OAS v3 equivalent, so it expands into two +paths — which is how those two URLs are expressed in a document. + +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} +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)); +``` + + + + + +## 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 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 +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/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 a82b0e04b..3f1f83746 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,10 @@ "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" + }, "repository": { "type": "git", "url": "git+ssh://git@github.com/drashland/drash.git" diff --git a/src/modules/decorators/OpenAPIv3.ts b/src/modules/decorators/OpenAPIv3.ts new file mode 100644 index 000000000..89e2da82d --- /dev/null +++ b/src/modules/decorators/OpenAPIv3.ts @@ -0,0 +1,662 @@ +/** + * 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 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 + * 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"]; + * + * @OpenAPIv3({ + * summary: "Get a user", + * responses: { + * "200": { + * description: "The user", + * content: { + * "application/json": { + * schema: { type: "object" }, + * }, + * }, + * }, + * }, + * }) + * public override GET(request: Request) { + * return new Response("..."); + * } + * } + * ``` + */ +function OpenAPIv3(operation: OperationObject) { + return function ( + value: T, + context: ClassMethodDecoratorContext, + ): T { + if (context.kind !== "method") { + throw new TypeError( + `OpenAPIv3: can only decorate a method, but was used on a ${context.kind}.`, + ); + } + + if (context.static) { + throw new TypeError( + `OpenAPIv3: 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( + `OpenAPIv3: can only decorate a public method, but \`${ + String(context.name) + }\` is private.`, + ); + } + + const name = String(context.name); + + if (!methodNames.has(name)) { + throw new TypeError( + `OpenAPIv3: \`${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, + OpenAPIv3, + 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, +}; 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";