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";