From baefd6dc34a079b0748de4e880bc3128f5c5c1a2 Mon Sep 17 00:00:00 2001 From: Adrian Kahali Date: Wed, 3 Jun 2026 14:51:09 -0400 Subject: [PATCH 01/10] wip: openapi package --- bun.lock | 41 + packages/openapi/README.md | 3 + packages/openapi/package.json | 51 + packages/openapi/src/index.ts | 21 + packages/openapi/src/loader.ts | 28 + packages/openapi/src/openapi.ts | 148 +++ packages/openapi/src/schema.ts | 60 + packages/openapi/src/types.ts | 137 ++ packages/openapi/src/utils.ts | 6 + packages/openapi/test/fixtures.ts | 5 + packages/openapi/test/fixtures/petstore.json | 1193 ++++++++++++++++++ packages/openapi/test/loader.test.ts | 21 + packages/openapi/test/openapi.test.ts | 75 ++ packages/openapi/test/schema.test.ts | 12 + packages/openapi/tsconfig.build.json | 13 + packages/openapi/tsconfig.json | 8 + 16 files changed, 1822 insertions(+) create mode 100644 packages/openapi/README.md create mode 100644 packages/openapi/package.json create mode 100644 packages/openapi/src/index.ts create mode 100644 packages/openapi/src/loader.ts create mode 100644 packages/openapi/src/openapi.ts create mode 100644 packages/openapi/src/schema.ts create mode 100644 packages/openapi/src/types.ts create mode 100644 packages/openapi/src/utils.ts create mode 100644 packages/openapi/test/fixtures.ts create mode 100644 packages/openapi/test/fixtures/petstore.json create mode 100644 packages/openapi/test/loader.test.ts create mode 100644 packages/openapi/test/openapi.test.ts create mode 100644 packages/openapi/test/schema.test.ts create mode 100644 packages/openapi/tsconfig.build.json create mode 100644 packages/openapi/tsconfig.json diff --git a/bun.lock b/bun.lock index e01adbe..97c82db 100644 --- a/bun.lock +++ b/bun.lock @@ -23,8 +23,29 @@ "astro": "^6.0.0", }, }, + "packages/openapi": { + "name": "cod-openapi", + "version": "0.0.0", + "dependencies": { + "@apidevtools/swagger-parser": "^12.1.0", + }, + "devDependencies": { + "astro": "^6.4.2", + }, + "peerDependencies": { + "astro": "^6.0.0", + }, + }, }, "packages": { + "@apidevtools/json-schema-ref-parser": ["@apidevtools/json-schema-ref-parser@14.0.1", "", { "dependencies": { "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0" } }, "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw=="], + + "@apidevtools/openapi-schemas": ["@apidevtools/openapi-schemas@2.1.0", "", {}, "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ=="], + + "@apidevtools/swagger-methods": ["@apidevtools/swagger-methods@3.0.2", "", {}, "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg=="], + + "@apidevtools/swagger-parser": ["@apidevtools/swagger-parser@12.1.0", "", { "dependencies": { "@apidevtools/json-schema-ref-parser": "14.0.1", "@apidevtools/openapi-schemas": "^2.1.0", "@apidevtools/swagger-methods": "^3.0.2", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "call-me-maybe": "^1.0.2" }, "peerDependencies": { "openapi-types": ">=7" } }, "sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng=="], + "@astrojs/compiler": ["@astrojs/compiler@4.0.0", "", {}, "sha512-eouss7G8ygdZqHuke033VMcVw5HTZUu+PXd/h06DGDUg/jt5btPYPqh66ENWw/mU78rBrf/oeC4oqoBwMtDMNA=="], "@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.10.0", "", { "dependencies": { "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "js-yaml": "^4.1.1", "picomatch": "^4.0.4", "retext-smartypants": "^6.2.0", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "unified": "^11.0.5" } }, "sha512-Ry2R3VPeIN4uPCSA4xQc+e+vsJXkalKpEbDc07hV+a/o5Bs2N/s/uDcPJH/05L19DKh9tAy7e6JM3YZ6Cxfezw=="], @@ -309,6 +330,8 @@ "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], @@ -321,6 +344,10 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -339,6 +366,8 @@ "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "call-me-maybe": ["call-me-maybe@1.0.2", "", {}, "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ=="], + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], @@ -355,6 +384,8 @@ "cod-core": ["cod-core@workspace:packages/core"], + "cod-openapi": ["cod-openapi@workspace:packages/openapi"], + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], @@ -417,10 +448,14 @@ "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -477,6 +512,8 @@ "js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], "lefthook": ["lefthook@2.1.9", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.9", "lefthook-darwin-x64": "2.1.9", "lefthook-freebsd-arm64": "2.1.9", "lefthook-freebsd-x64": "2.1.9", "lefthook-linux-arm64": "2.1.9", "lefthook-linux-x64": "2.1.9", "lefthook-openbsd-arm64": "2.1.9", "lefthook-openbsd-x64": "2.1.9", "lefthook-windows-arm64": "2.1.9", "lefthook-windows-x64": "2.1.9" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-bwDaIOViTktE8kJLf9jP0p+H2/RDTlFFlc43Am2YgUsX22hI6Sq4RbzsrecwzY5y+MHTipOH7WsmWSEniePHWQ=="], @@ -623,6 +660,8 @@ "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + "oxfmt": ["oxfmt@0.52.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.52.0", "@oxfmt/binding-android-arm64": "0.52.0", "@oxfmt/binding-darwin-arm64": "0.52.0", "@oxfmt/binding-darwin-x64": "0.52.0", "@oxfmt/binding-freebsd-x64": "0.52.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.52.0", "@oxfmt/binding-linux-arm-musleabihf": "0.52.0", "@oxfmt/binding-linux-arm64-gnu": "0.52.0", "@oxfmt/binding-linux-arm64-musl": "0.52.0", "@oxfmt/binding-linux-ppc64-gnu": "0.52.0", "@oxfmt/binding-linux-riscv64-gnu": "0.52.0", "@oxfmt/binding-linux-riscv64-musl": "0.52.0", "@oxfmt/binding-linux-s390x-gnu": "0.52.0", "@oxfmt/binding-linux-x64-gnu": "0.52.0", "@oxfmt/binding-linux-x64-musl": "0.52.0", "@oxfmt/binding-openharmony-arm64": "0.52.0", "@oxfmt/binding-win32-arm64-msvc": "0.52.0", "@oxfmt/binding-win32-ia32-msvc": "0.52.0", "@oxfmt/binding-win32-x64-msvc": "0.52.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-nJlYM35F64zTDMecCNhoHNkf+D/eHv7xcjj9XDSj+bFAVtN93m7v8DQMdHd6nDG6Akf/kEYYHmDUBs2Dz27Sug=="], "oxlint": ["oxlint@1.68.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.68.0", "@oxlint/binding-android-arm64": "1.68.0", "@oxlint/binding-darwin-arm64": "1.68.0", "@oxlint/binding-darwin-x64": "1.68.0", "@oxlint/binding-freebsd-x64": "1.68.0", "@oxlint/binding-linux-arm-gnueabihf": "1.68.0", "@oxlint/binding-linux-arm-musleabihf": "1.68.0", "@oxlint/binding-linux-arm64-gnu": "1.68.0", "@oxlint/binding-linux-arm64-musl": "1.68.0", "@oxlint/binding-linux-ppc64-gnu": "1.68.0", "@oxlint/binding-linux-riscv64-gnu": "1.68.0", "@oxlint/binding-linux-riscv64-musl": "1.68.0", "@oxlint/binding-linux-s390x-gnu": "1.68.0", "@oxlint/binding-linux-x64-gnu": "1.68.0", "@oxlint/binding-linux-x64-musl": "1.68.0", "@oxlint/binding-openharmony-arm64": "1.68.0", "@oxlint/binding-win32-arm64-msvc": "1.68.0", "@oxlint/binding-win32-ia32-msvc": "1.68.0", "@oxlint/binding-win32-x64-msvc": "1.68.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-dXcbq+xsmLrMy6T8d0euf3IYUfLmjHIE11pOxiUSi5LHkFZaYPv568R6sEjcavVpUxoaQe66UBuK4HEi74NxpA=="], @@ -679,6 +718,8 @@ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "retext": ["retext@9.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "retext-latin": "^4.0.0", "retext-stringify": "^4.0.0", "unified": "^11.0.0" } }, "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA=="], diff --git a/packages/openapi/README.md b/packages/openapi/README.md new file mode 100644 index 0000000..6999692 --- /dev/null +++ b/packages/openapi/README.md @@ -0,0 +1,3 @@ +# cod-openapi + +OpenAPI data loading and schemas for Cod. diff --git a/packages/openapi/package.json b/packages/openapi/package.json new file mode 100644 index 0000000..6b3ff11 --- /dev/null +++ b/packages/openapi/package.json @@ -0,0 +1,51 @@ +{ + "name": "cod-openapi", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./loader": { + "types": "./dist/loader.d.ts", + "default": "./dist/loader.js" + }, + "./schema": { + "types": "./dist/schema.d.ts", + "default": "./dist/schema.js" + }, + "./types": { + "types": "./dist/types.d.ts", + "default": "./dist/types.js" + }, + "./openapi": { + "types": "./dist/openapi.d.ts", + "default": "./dist/openapi.js" + }, + "./utils": { + "types": "./dist/utils.d.ts", + "default": "./dist/utils.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "check": "tsc -p tsconfig.json --noEmit", + "test": "bun test" + }, + "dependencies": { + "@apidevtools/swagger-parser": "^12.1.0" + }, + "peerDependencies": { + "astro": "^6.0.0" + }, + "devDependencies": { + "astro": "^6.4.2" + } +} diff --git a/packages/openapi/src/index.ts b/packages/openapi/src/index.ts new file mode 100644 index 0000000..86bdb1b --- /dev/null +++ b/packages/openapi/src/index.ts @@ -0,0 +1,21 @@ +export { apiLoader } from './loader.js' +export { extractApiEntries, getApiEntryIds, loadOpenApiSpec } from './openapi.js' +export { apiCollectionSchema, endpointSchema, parameterSchema, schemaSchema, securitySchemeSchema } from './schema.js' +export type { + ApiEntryData, + ApiLoaderOptions, + ApiSpecSource, + Endpoint, + HttpMethod, + OpenApiOperation, + OpenApiPathItem, + OpenApiServer, + OpenApiSpec, + Parameter, + RequestBody, + ResponseObject, + Schema, + SecurityRequirement, + SecurityScheme, + ServerVariable, +} from './types.js' diff --git a/packages/openapi/src/loader.ts b/packages/openapi/src/loader.ts new file mode 100644 index 0000000..8595005 --- /dev/null +++ b/packages/openapi/src/loader.ts @@ -0,0 +1,28 @@ +import type { Loader } from 'astro/loaders' +import { extractApiEntries } from './openapi.js' +import type { ApiLoaderOptions } from './types.js' + +export function apiLoader(options: ApiLoaderOptions): Loader { + return { + name: 'cod-openapi-loader', + async load({ store, logger }) { + store.clear() + const entries = await extractApiEntries(options) + for (const entry of entries) { + store.set({ + id: entry.id, + data: { + title: entry.title, + description: entry.description, + method: entry.method, + apiSlug: entry.apiSlug, + apiLabel: entry.apiLabel, + sortOrder: entry.sortOrder, + endpoint: entry.endpoint, + }, + }) + } + if (entries.length === 0) logger.warn(`[Cod API] No operations found for ${options.slug}`) + }, + } +} diff --git a/packages/openapi/src/openapi.ts b/packages/openapi/src/openapi.ts new file mode 100644 index 0000000..dd5d0b2 --- /dev/null +++ b/packages/openapi/src/openapi.ts @@ -0,0 +1,148 @@ +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import SwaggerParser from '@apidevtools/swagger-parser' +import type { + ApiLoaderOptions, + Endpoint, + HttpMethod, + OpenApiOperation, + OpenApiPathItem, + OpenApiSpec, + ServerVariable, +} from './types.js' +import { slugify } from './utils.js' + +const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'] as const + +export interface ExtractedApiEntry { + id: string + title: string + description?: string + method: string + apiSlug: string + apiLabel: string + sortOrder: number + endpoint: Endpoint +} + +export async function loadOpenApiSpec(source: ApiLoaderOptions['source']): Promise { + if (typeof source === 'function') return source() + if (source instanceof URL) { + return dereference(source.protocol === 'file:' ? fileURLToPath(source) : source.href) + } + if (typeof source === 'string') { + const absolutePath = resolve(process.cwd(), source) + return dereference(absolutePath) + } + return dereferenceObject(source) +} + +export async function extractApiEntries(options: ApiLoaderOptions): Promise { + const spec = (await loadOpenApiSpec(options.source)) as OpenApiSpec + const entries: ExtractedApiEntry[] = [] + const excludedTags = new Set(options.excludeTags ?? []) + const securitySchemes = spec.components?.securitySchemes + const server = spec.servers?.[0] + + for (const [path, pathItem] of Object.entries(spec.paths ?? {})) { + for (const method of HTTP_METHODS) { + const operation = pathItem[method] + if (!operation || shouldExcludeOperation(operation, excludedTags)) continue + + const buildOptions: Parameters[0] = { + method, + operation, + path, + pathItem, + } + if (server?.url !== undefined) buildOptions.baseUrl = server.url + if (spec.security !== undefined) buildOptions.security = spec.security + if (securitySchemes !== undefined) buildOptions.securitySchemes = securitySchemes + if (server) { + const serverVariables = getServerVariables(server.variables) + if (serverVariables !== undefined) buildOptions.serverVariables = serverVariables + } + + const endpoint = buildEndpoint(buildOptions) + const title = operation.summary ?? operation.operationId ?? `${method.toUpperCase()} ${path}` + const operationSlug = slugify(operation.operationId ?? `${method}-${path}`) + + const entry: ExtractedApiEntry = { + id: `${options.slug}/${operationSlug}`, + title, + method: method.toUpperCase(), + apiSlug: options.slug, + apiLabel: options.label, + sortOrder: entries.length, + endpoint, + } + if (operation.description !== undefined) entry.description = operation.description + entries.push(entry) + } + } + + return entries +} + +export async function getApiEntryIds(options: ApiLoaderOptions): Promise { + const entries = await extractApiEntries(options) + return entries.map((entry) => entry.id) +} + +function shouldExcludeOperation(operation: OpenApiOperation, excludedTags: Set): boolean { + return (operation.tags ?? []).some((tag) => excludedTags.has(tag)) +} + +function buildEndpoint(options: { + method: HttpMethod + path: string + pathItem: OpenApiPathItem + operation: OpenApiOperation + baseUrl?: string + security?: Endpoint['security'] + securitySchemes?: Endpoint['securitySchemes'] + serverVariables?: ServerVariable[] +}): Endpoint { + const parameters = [...(options.pathItem.parameters ?? []), ...(options.operation.parameters ?? [])] + const endpoint: Endpoint = { + method: options.method.toUpperCase(), + path: options.path, + } + + if (options.operation.operationId !== undefined) endpoint.operationId = options.operation.operationId + if (options.operation.summary !== undefined) endpoint.summary = options.operation.summary + if (options.operation.description !== undefined) endpoint.description = options.operation.description + if (options.baseUrl !== undefined) endpoint.baseUrl = options.baseUrl + if (options.serverVariables !== undefined) endpoint.serverVariables = options.serverVariables + if (parameters.length > 0) endpoint.parameters = parameters + if (options.operation.requestBody !== undefined) endpoint.requestBody = options.operation.requestBody + if (options.operation.responses !== undefined) endpoint.responses = options.operation.responses + + const security = options.operation.security ?? options.security + if (security !== undefined) endpoint.security = security + + if (options.securitySchemes !== undefined) endpoint.securitySchemes = options.securitySchemes + if (options.operation.deprecated !== undefined) endpoint.deprecated = options.operation.deprecated + if (options.operation.tags !== undefined) endpoint.tags = options.operation.tags + + return endpoint +} + +function getServerVariables(variables: Record | undefined) { + if (!variables) return undefined + return Object.entries(variables).map(([name, variable]) => { + const serverVariable: ServerVariable = { name, default: variable.default ?? '' } + if (variable.description !== undefined) serverVariable.description = variable.description + return serverVariable + }) +} + +async function dereference(source: string): Promise { + const spec = await SwaggerParser.dereference(source) + return spec as unknown as OpenApiSpec +} + +async function dereferenceObject(source: OpenApiSpec): Promise { + const spec = await SwaggerParser.dereference(source as never) + return spec as unknown as OpenApiSpec +} diff --git a/packages/openapi/src/schema.ts b/packages/openapi/src/schema.ts new file mode 100644 index 0000000..5372d3c --- /dev/null +++ b/packages/openapi/src/schema.ts @@ -0,0 +1,60 @@ +import { z } from 'astro/zod' + +export const schemaSchema = z.record(z.string(), z.unknown()) + +export const parameterSchema = z + .object({ + name: z.string(), + in: z.string(), + required: z.boolean().optional(), + description: z.string().optional(), + schema: schemaSchema.optional(), + }) + .passthrough() + +export const securitySchemeSchema = z + .object({ + type: z.string(), + scheme: z.string().optional(), + bearerFormat: z.string().optional(), + description: z.string().optional(), + name: z.string().optional(), + in: z.string().optional(), + }) + .passthrough() + +export const serverVariableSchema = z.object({ + name: z.string(), + default: z.string(), + description: z.string().optional(), +}) + +export const endpointSchema = z + .object({ + method: z.string(), + path: z.string(), + operationId: z.string().optional(), + summary: z.string().optional(), + description: z.string().optional(), + baseUrl: z.string().optional(), + serverUrlSuffix: z.string().optional(), + serverVariables: z.array(serverVariableSchema).optional(), + parameters: z.array(parameterSchema).optional(), + requestBody: z.record(z.string(), z.unknown()).optional(), + responses: z.record(z.string(), z.record(z.string(), z.unknown())).optional(), + security: z.array(z.record(z.string(), z.array(z.string()))).optional(), + securitySchemes: z.record(z.string(), securitySchemeSchema).optional(), + deprecated: z.boolean().optional(), + tags: z.array(z.string()).optional(), + }) + .passthrough() + +export const apiCollectionSchema = z.object({ + title: z.string(), + description: z.string().optional(), + method: z.string(), + apiSlug: z.string(), + apiLabel: z.string(), + sortOrder: z.number(), + endpoint: endpointSchema, +}) diff --git a/packages/openapi/src/types.ts b/packages/openapi/src/types.ts new file mode 100644 index 0000000..edbb08a --- /dev/null +++ b/packages/openapi/src/types.ts @@ -0,0 +1,137 @@ +export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head' | 'options' | 'trace' + +export type OpenApiSpec = Record & { + paths?: Record + servers?: OpenApiServer[] + security?: SecurityRequirement[] + components?: { + securitySchemes?: Record + [key: string]: unknown + } +} + +export interface OpenApiServer { + url: string + variables?: Record +} + +export interface OpenApiPathItem extends Record { + parameters?: Parameter[] + get?: OpenApiOperation + post?: OpenApiOperation + put?: OpenApiOperation + patch?: OpenApiOperation + delete?: OpenApiOperation + head?: OpenApiOperation + options?: OpenApiOperation + trace?: OpenApiOperation +} + +export interface OpenApiOperation extends Record { + operationId?: string + summary?: string + description?: string + deprecated?: boolean + tags?: string[] + parameters?: Parameter[] + requestBody?: RequestBody + responses?: Record + security?: SecurityRequirement[] +} + +export interface Schema extends Record { + type?: string + properties?: Record + items?: Schema + required?: string[] + description?: string + enum?: unknown[] + default?: unknown + format?: string + example?: unknown + oneOf?: Schema[] + anyOf?: Schema[] + allOf?: Schema[] + nullable?: boolean + minLength?: number + maxLength?: number + minimum?: number + maximum?: number + pattern?: string + title?: string + deprecated?: boolean + additionalProperties?: boolean | Schema +} + +export interface Parameter extends Record { + name: string + in: string + required?: boolean + description?: string + schema?: Schema +} + +export interface RequestBody extends Record { + required?: boolean + description?: string + content?: Record +} + +export interface ResponseObject extends Record { + description?: string + content?: Record +} + +export interface SecurityScheme extends Record { + type: string + scheme?: string + bearerFormat?: string + description?: string + name?: string + in?: string +} + +export type SecurityRequirement = Record + +export interface ServerVariable { + name: string + default: string + description?: string +} + +export interface Endpoint { + method: string + path: string + operationId?: string + summary?: string + description?: string + baseUrl?: string + serverUrlSuffix?: string + serverVariables?: ServerVariable[] + parameters?: Parameter[] + requestBody?: RequestBody + responses?: Record + security?: SecurityRequirement[] + securitySchemes?: Record + deprecated?: boolean + tags?: string[] +} + +export type ApiSpecSource = string | URL | OpenApiSpec | (() => OpenApiSpec | Promise) + +export interface ApiLoaderOptions { + slug: string + label: string + source: ApiSpecSource + excludeTags?: string[] +} + +export interface ApiEntryData { + title: string + description?: string + method: string + apiSlug: string + apiLabel: string + sortOrder: number + endpoint: Endpoint +} diff --git a/packages/openapi/src/utils.ts b/packages/openapi/src/utils.ts new file mode 100644 index 0000000..baf5433 --- /dev/null +++ b/packages/openapi/src/utils.ts @@ -0,0 +1,6 @@ +export function slugify(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') +} diff --git a/packages/openapi/test/fixtures.ts b/packages/openapi/test/fixtures.ts new file mode 100644 index 0000000..90bd596 --- /dev/null +++ b/packages/openapi/test/fixtures.ts @@ -0,0 +1,5 @@ +import { join } from 'node:path' + +export const fixturePath = join(import.meta.dir, 'fixtures/petstore.json') + +export const fixtureUrl = new URL('./fixtures/petstore.json', import.meta.url) diff --git a/packages/openapi/test/fixtures/petstore.json b/packages/openapi/test/fixtures/petstore.json new file mode 100644 index 0000000..0cc73af --- /dev/null +++ b/packages/openapi/test/fixtures/petstore.json @@ -0,0 +1,1193 @@ +{ + "openapi": "3.0.4", + "info": { + "title": "Swagger Petstore - OpenAPI 3.0", + "description": "This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about\nSwagger at [https://swagger.io](https://swagger.io). In the third iteration of the pet store, we've switched to the design first approach!\nYou can now help us improve the API whether it's by making changes to the definition itself or to the code.\nThat way, with time, we can improve the API in general, and expose some of the new features in OAS3.\n\nSome useful links:\n- [The Pet Store repository](https://github.com/swagger-api/swagger-petstore)\n- [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml)", + "termsOfService": "https://swagger.io/terms/", + "contact": { + "email": "apiteam@swagger.io" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "1.0.27" + }, + "externalDocs": { + "description": "Find out more about Swagger", + "url": "https://swagger.io" + }, + "servers": [ + { + "url": "/api/v3" + } + ], + "tags": [ + { + "name": "pet", + "description": "Everything about your Pets", + "externalDocs": { + "description": "Find out more", + "url": "https://swagger.io" + } + }, + { + "name": "store", + "description": "Access to Petstore orders", + "externalDocs": { + "description": "Find out more about our store", + "url": "https://swagger.io" + } + }, + { + "name": "user", + "description": "Operations about user" + } + ], + "paths": { + "/pet": { + "put": { + "tags": ["pet"], + "summary": "Update an existing pet.", + "description": "Update an existing pet by Id.", + "operationId": "updatePet", + "requestBody": { + "description": "Update an existent pet in the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Pet not found" + }, + "422": { + "description": "Validation exception" + }, + "default": { + "description": "Unexpected error" + } + }, + "security": [ + { + "petstore_auth": ["write:pets", "read:pets"] + } + ] + }, + "post": { + "tags": ["pet"], + "summary": "Add a new pet to the store.", + "description": "Add a new pet to the store.", + "operationId": "addPet", + "requestBody": { + "description": "Create a new pet in the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "400": { + "description": "Invalid input" + }, + "422": { + "description": "Validation exception" + }, + "default": { + "description": "Unexpected error" + } + }, + "security": [ + { + "petstore_auth": ["write:pets", "read:pets"] + } + ] + } + }, + "/pet/findByStatus": { + "get": { + "tags": ["pet"], + "summary": "Finds Pets by status.", + "description": "Multiple status values can be provided with comma separated strings.", + "operationId": "findPetsByStatus", + "parameters": [ + { + "name": "status", + "in": "query", + "description": "Status values that need to be considered for filter", + "required": true, + "explode": true, + "schema": { + "type": "string", + "default": "available", + "enum": ["available", "pending", "sold"] + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "application/xml": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + }, + "400": { + "description": "Invalid status value" + }, + "default": { + "description": "Unexpected error" + } + }, + "security": [ + { + "petstore_auth": ["write:pets", "read:pets"] + } + ] + } + }, + "/pet/findByTags": { + "get": { + "tags": ["pet"], + "summary": "Finds Pets by tags.", + "description": "Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.", + "operationId": "findPetsByTags", + "parameters": [ + { + "name": "tags", + "in": "query", + "description": "Tags to filter by", + "required": true, + "explode": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "application/xml": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + }, + "400": { + "description": "Invalid tag value" + }, + "default": { + "description": "Unexpected error" + } + }, + "security": [ + { + "petstore_auth": ["write:pets", "read:pets"] + } + ] + } + }, + "/pet/{petId}": { + "get": { + "tags": ["pet"], + "summary": "Find pet by ID.", + "description": "Returns a single pet.", + "operationId": "getPetById", + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet to return", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Pet not found" + }, + "default": { + "description": "Unexpected error" + } + }, + "security": [ + { + "api_key": [] + }, + { + "petstore_auth": ["write:pets", "read:pets"] + } + ] + }, + "post": { + "tags": ["pet"], + "summary": "Updates a pet in the store with form data.", + "description": "Updates a pet resource based on the form data.", + "operationId": "updatePetWithForm", + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet that needs to be updated", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "name", + "in": "query", + "description": "Name of pet that needs to be updated", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "description": "Status of pet that needs to be updated", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "400": { + "description": "Invalid input" + }, + "default": { + "description": "Unexpected error" + } + }, + "security": [ + { + "petstore_auth": ["write:pets", "read:pets"] + } + ] + }, + "delete": { + "tags": ["pet"], + "summary": "Deletes a pet.", + "description": "Delete a pet.", + "operationId": "deletePet", + "parameters": [ + { + "name": "api_key", + "in": "header", + "description": "", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "petId", + "in": "path", + "description": "Pet id to delete", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Pet deleted" + }, + "400": { + "description": "Invalid pet value" + }, + "default": { + "description": "Unexpected error" + } + }, + "security": [ + { + "petstore_auth": ["write:pets", "read:pets"] + } + ] + } + }, + "/pet/{petId}/uploadImage": { + "post": { + "tags": ["pet"], + "summary": "Uploads an image.", + "description": "Upload image of the pet.", + "operationId": "uploadFile", + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet to update", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "additionalMetadata", + "in": "query", + "description": "Additional Metadata", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "400": { + "description": "No file uploaded" + }, + "404": { + "description": "Pet not found" + }, + "default": { + "description": "Unexpected error" + } + }, + "security": [ + { + "petstore_auth": ["write:pets", "read:pets"] + } + ] + } + }, + "/store/inventory": { + "get": { + "tags": ["store"], + "summary": "Returns pet inventories by status.", + "description": "Returns a map of status codes to quantities.", + "operationId": "getInventory", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + } + } + } + }, + "default": { + "description": "Unexpected error" + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, + "/store/order": { + "post": { + "tags": ["store"], + "summary": "Place an order for a pet.", + "description": "Place a new order in the store.", + "operationId": "placeOrder", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Order" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "400": { + "description": "Invalid input" + }, + "422": { + "description": "Validation exception" + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/store/order/{orderId}": { + "get": { + "tags": ["store"], + "summary": "Find purchase order by ID.", + "description": "For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions.", + "operationId": "getOrderById", + "parameters": [ + { + "name": "orderId", + "in": "path", + "description": "ID of order that needs to be fetched", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Order not found" + }, + "default": { + "description": "Unexpected error" + } + } + }, + "delete": { + "tags": ["store"], + "summary": "Delete purchase order by identifier.", + "description": "For valid response try integer IDs with value < 1000. Anything above 1000 or non-integers will generate API errors.", + "operationId": "deleteOrder", + "parameters": [ + { + "name": "orderId", + "in": "path", + "description": "ID of the order that needs to be deleted", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "order deleted" + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Order not found" + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/user": { + "post": { + "tags": ["user"], + "summary": "Create user.", + "description": "This can only be done by the logged in user.", + "operationId": "createUser", + "requestBody": { + "description": "Created user object", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/user/createWithList": { + "post": { + "tags": ["user"], + "summary": "Creates list of users with given input array.", + "description": "Creates list of users with given input array.", + "operationId": "createUsersWithListInput", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + } + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/user/login": { + "get": { + "tags": ["user"], + "summary": "Logs user into the system.", + "description": "Log into the system.", + "operationId": "loginUser", + "parameters": [ + { + "name": "username", + "in": "query", + "description": "The user name for login", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "password", + "in": "query", + "description": "The password for login in clear text", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "headers": { + "X-Rate-Limit": { + "description": "calls per hour allowed by the user", + "schema": { + "type": "integer", + "format": "int32" + } + }, + "X-Expires-After": { + "description": "date in UTC when token expires", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/xml": { + "schema": { + "type": "string" + } + }, + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Invalid username/password supplied" + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/user/logout": { + "get": { + "tags": ["user"], + "summary": "Logs out current logged in user session.", + "description": "Log user out of the system.", + "operationId": "logoutUser", + "parameters": [], + "responses": { + "200": { + "description": "successful operation" + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/user/{username}": { + "get": { + "tags": ["user"], + "summary": "Get user by user name.", + "description": "Get user detail based on username.", + "operationId": "getUserByName", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "The name that needs to be fetched. Use user1 for testing", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "Invalid username supplied" + }, + "404": { + "description": "User not found" + }, + "default": { + "description": "Unexpected error" + } + } + }, + "put": { + "tags": ["user"], + "summary": "Update user resource.", + "description": "This can only be done by the logged in user.", + "operationId": "updateUser", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "name that need to be deleted", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Update an existent user in the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "responses": { + "200": { + "description": "successful operation" + }, + "400": { + "description": "bad request" + }, + "404": { + "description": "user not found" + }, + "default": { + "description": "Unexpected error" + } + } + }, + "delete": { + "tags": ["user"], + "summary": "Delete user resource.", + "description": "This can only be done by the logged in user.", + "operationId": "deleteUser", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "The name that needs to be deleted", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "User deleted" + }, + "400": { + "description": "Invalid username supplied" + }, + "404": { + "description": "User not found" + }, + "default": { + "description": "Unexpected error" + } + } + } + } + }, + "components": { + "schemas": { + "Order": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 10 + }, + "petId": { + "type": "integer", + "format": "int64", + "example": 198772 + }, + "quantity": { + "type": "integer", + "format": "int32", + "example": 7 + }, + "shipDate": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string", + "description": "Order Status", + "example": "approved", + "enum": ["placed", "approved", "delivered"] + }, + "complete": { + "type": "boolean" + } + }, + "xml": { + "name": "order" + } + }, + "Category": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 1 + }, + "name": { + "type": "string", + "example": "Dogs" + } + }, + "xml": { + "name": "category" + } + }, + "User": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 10 + }, + "username": { + "type": "string", + "example": "theUser" + }, + "firstName": { + "type": "string", + "example": "John" + }, + "lastName": { + "type": "string", + "example": "James" + }, + "email": { + "type": "string", + "example": "john@email.com" + }, + "password": { + "type": "string", + "example": "12345" + }, + "phone": { + "type": "string", + "example": "12345" + }, + "userStatus": { + "type": "integer", + "description": "User Status", + "format": "int32", + "example": 1 + } + }, + "xml": { + "name": "user" + } + }, + "Tag": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + }, + "xml": { + "name": "tag" + } + }, + "Pet": { + "required": ["name", "photoUrls"], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 10 + }, + "name": { + "type": "string", + "example": "doggie" + }, + "category": { + "$ref": "#/components/schemas/Category" + }, + "photoUrls": { + "type": "array", + "xml": { + "wrapped": true + }, + "items": { + "type": "string", + "xml": { + "name": "photoUrl" + } + } + }, + "tags": { + "type": "array", + "xml": { + "wrapped": true + }, + "items": { + "$ref": "#/components/schemas/Tag" + } + }, + "status": { + "type": "string", + "description": "pet status in the store", + "enum": ["available", "pending", "sold"] + } + }, + "xml": { + "name": "pet" + } + }, + "ApiResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "xml": { + "name": "##default" + } + } + }, + "requestBodies": { + "Pet": { + "description": "Pet object that needs to be added to the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "UserArray": { + "description": "List of user object", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + }, + "securitySchemes": { + "petstore_auth": { + "type": "oauth2", + "flows": { + "implicit": { + "authorizationUrl": "https://petstore3.swagger.io/oauth/authorize", + "scopes": { + "write:pets": "modify pets in your account", + "read:pets": "read your pets" + } + } + } + }, + "api_key": { + "type": "apiKey", + "name": "api_key", + "in": "header" + } + } + } +} diff --git a/packages/openapi/test/loader.test.ts b/packages/openapi/test/loader.test.ts new file mode 100644 index 0000000..470f821 --- /dev/null +++ b/packages/openapi/test/loader.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'bun:test' +import { apiLoader } from '../src/loader.js' +import { fixturePath } from './fixtures.js' + +describe('apiLoader', () => { + test('writes entries into the Astro content store shape', async () => { + const stored = new Map() + const loader = apiLoader({ slug: 'api', label: 'API', source: fixturePath }) + + await loader.load({ + store: { + clear: () => stored.clear(), + set: (entry: { id: string; data: unknown }) => stored.set(entry.id, entry.data), + }, + logger: { warn: () => undefined }, + } as never) + + expect(stored.size).toBe(19) + expect(stored.get('api/addpet')).toMatchObject({ title: 'Add a new pet to the store.', method: 'POST' }) + }) +}) diff --git a/packages/openapi/test/openapi.test.ts b/packages/openapi/test/openapi.test.ts new file mode 100644 index 0000000..18e2f05 --- /dev/null +++ b/packages/openapi/test/openapi.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from 'bun:test' +import { extractApiEntries, getApiEntryIds, loadOpenApiSpec } from '../src/openapi.js' +import { fixturePath, fixtureUrl } from './fixtures.js' + +const petstoreEntryIds = [ + 'api/addpet', + 'api/updatepet', + 'api/findpetsbystatus', + 'api/findpetsbytags', + 'api/getpetbyid', + 'api/updatepetwithform', + 'api/deletepet', + 'api/uploadfile', + 'api/getinventory', + 'api/placeorder', + 'api/getorderbyid', + 'api/deleteorder', + 'api/createuser', + 'api/createuserswithlistinput', + 'api/loginuser', + 'api/logoutuser', + 'api/getuserbyname', + 'api/updateuser', + 'api/deleteuser', +] + +describe('OpenAPI extraction', () => { + test('loads string paths and extracts visible operations', async () => { + const entries = await extractApiEntries({ slug: 'api', label: 'API', source: fixturePath }) + + expect(entries).toHaveLength(19) + expect(entries.map((entry) => entry.id)).toEqual(petstoreEntryIds) + expect(entries[0]).toMatchObject({ + title: 'Add a new pet to the store.', + method: 'POST', + apiSlug: 'api', + apiLabel: 'API', + sortOrder: 0, + endpoint: { + method: 'POST', + path: '/pet', + baseUrl: '/api/v3', + security: [{ petstore_auth: ['write:pets', 'read:pets'] }], + securitySchemes: { + api_key: { type: 'apiKey', name: 'api_key', in: 'header' }, + petstore_auth: { type: 'oauth2' }, + }, + }, + }) + expect(entries[0]?.description).toBe('Add a new pet to the store.') + expect(entries[0]?.endpoint.requestBody).toBeDefined() + }) + + test('excludes tags explicitly', async () => { + const entries = await extractApiEntries({ + slug: 'api', + label: 'API', + source: fixturePath, + excludeTags: ['store', 'user'], + }) + + expect(entries.map((entry) => entry.id)).toEqual(petstoreEntryIds.slice(0, 8)) + }) + + test('loads raw specs and source functions', async () => { + const spec = await loadOpenApiSpec(fixturePath) + const entries = await extractApiEntries({ slug: 'api', label: 'API', source: () => spec }) + + expect(entries).toHaveLength(19) + }) + + test('returns generated API entry ids', async () => { + await expect(getApiEntryIds({ slug: 'api', label: 'API', source: fixtureUrl })).resolves.toEqual(petstoreEntryIds) + }) +}) diff --git a/packages/openapi/test/schema.test.ts b/packages/openapi/test/schema.test.ts new file mode 100644 index 0000000..dd5fbf3 --- /dev/null +++ b/packages/openapi/test/schema.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from 'bun:test' +import { extractApiEntries } from '../src/openapi.js' +import { apiCollectionSchema } from '../src/schema.js' +import { fixturePath } from './fixtures.js' + +describe('schemas and guards', () => { + test('validates API collection entries', async () => { + const [entry] = await extractApiEntries({ slug: 'api', label: 'API', source: fixturePath }) + + expect(() => apiCollectionSchema.parse(entry)).not.toThrow() + }) +}) diff --git a/packages/openapi/tsconfig.build.json b/packages/openapi/tsconfig.build.json new file mode 100644 index 0000000..1fc9814 --- /dev/null +++ b/packages/openapi/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "noEmit": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["test/**/*.ts"] +} diff --git a/packages/openapi/tsconfig.json b/packages/openapi/tsconfig.json new file mode 100644 index 0000000..c4193e3 --- /dev/null +++ b/packages/openapi/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} From de4bf048a60ae9f0186cfafbb514c2999c009adb Mon Sep 17 00:00:00 2001 From: Adrian Kahali Date: Wed, 3 Jun 2026 19:21:40 -0400 Subject: [PATCH 02/10] use parseData --- packages/openapi/src/loader.ts | 9 +++++++-- packages/openapi/test/loader.test.ts | 7 +++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/openapi/src/loader.ts b/packages/openapi/src/loader.ts index 8595005..3759c14 100644 --- a/packages/openapi/src/loader.ts +++ b/packages/openapi/src/loader.ts @@ -5,11 +5,11 @@ import type { ApiLoaderOptions } from './types.js' export function apiLoader(options: ApiLoaderOptions): Loader { return { name: 'cod-openapi-loader', - async load({ store, logger }) { + async load({ store, logger, parseData }) { store.clear() const entries = await extractApiEntries(options) for (const entry of entries) { - store.set({ + const data = await parseData({ id: entry.id, data: { title: entry.title, @@ -21,6 +21,11 @@ export function apiLoader(options: ApiLoaderOptions): Loader { endpoint: entry.endpoint, }, }) + + store.set({ + id: entry.id, + data, + }) } if (entries.length === 0) logger.warn(`[Cod API] No operations found for ${options.slug}`) }, diff --git a/packages/openapi/test/loader.test.ts b/packages/openapi/test/loader.test.ts index 470f821..4243f91 100644 --- a/packages/openapi/test/loader.test.ts +++ b/packages/openapi/test/loader.test.ts @@ -5,6 +5,7 @@ import { fixturePath } from './fixtures.js' describe('apiLoader', () => { test('writes entries into the Astro content store shape', async () => { const stored = new Map() + const parsedIds: string[] = [] const loader = apiLoader({ slug: 'api', label: 'API', source: fixturePath }) await loader.load({ @@ -12,9 +13,15 @@ describe('apiLoader', () => { clear: () => stored.clear(), set: (entry: { id: string; data: unknown }) => stored.set(entry.id, entry.data), }, + parseData: ({ id, data }: { id: string; data: unknown }) => { + parsedIds.push(id) + return data + }, logger: { warn: () => undefined }, } as never) + expect(parsedIds).toContain('api/addpet') + expect(parsedIds).toHaveLength(19) expect(stored.size).toBe(19) expect(stored.get('api/addpet')).toMatchObject({ title: 'Add a new pet to the store.', method: 'POST' }) }) From 66c87e066f59ed754899332da0b767636b1e7aa5 Mon Sep 17 00:00:00 2001 From: Adrian Kahali Date: Wed, 3 Jun 2026 19:25:03 -0400 Subject: [PATCH 03/10] replace .passthrough() with .looseObject() --- packages/openapi/src/schema.ts | 70 ++++++++++++++++------------------ 1 file changed, 32 insertions(+), 38 deletions(-) diff --git a/packages/openapi/src/schema.ts b/packages/openapi/src/schema.ts index 5372d3c..ef88dd2 100644 --- a/packages/openapi/src/schema.ts +++ b/packages/openapi/src/schema.ts @@ -2,26 +2,22 @@ import { z } from 'astro/zod' export const schemaSchema = z.record(z.string(), z.unknown()) -export const parameterSchema = z - .object({ - name: z.string(), - in: z.string(), - required: z.boolean().optional(), - description: z.string().optional(), - schema: schemaSchema.optional(), - }) - .passthrough() +export const parameterSchema = z.looseObject({ + name: z.string(), + in: z.string(), + required: z.boolean().optional(), + description: z.string().optional(), + schema: schemaSchema.optional(), +}) -export const securitySchemeSchema = z - .object({ - type: z.string(), - scheme: z.string().optional(), - bearerFormat: z.string().optional(), - description: z.string().optional(), - name: z.string().optional(), - in: z.string().optional(), - }) - .passthrough() +export const securitySchemeSchema = z.looseObject({ + type: z.string(), + scheme: z.string().optional(), + bearerFormat: z.string().optional(), + description: z.string().optional(), + name: z.string().optional(), + in: z.string().optional(), +}) export const serverVariableSchema = z.object({ name: z.string(), @@ -29,25 +25,23 @@ export const serverVariableSchema = z.object({ description: z.string().optional(), }) -export const endpointSchema = z - .object({ - method: z.string(), - path: z.string(), - operationId: z.string().optional(), - summary: z.string().optional(), - description: z.string().optional(), - baseUrl: z.string().optional(), - serverUrlSuffix: z.string().optional(), - serverVariables: z.array(serverVariableSchema).optional(), - parameters: z.array(parameterSchema).optional(), - requestBody: z.record(z.string(), z.unknown()).optional(), - responses: z.record(z.string(), z.record(z.string(), z.unknown())).optional(), - security: z.array(z.record(z.string(), z.array(z.string()))).optional(), - securitySchemes: z.record(z.string(), securitySchemeSchema).optional(), - deprecated: z.boolean().optional(), - tags: z.array(z.string()).optional(), - }) - .passthrough() +export const endpointSchema = z.looseObject({ + method: z.string(), + path: z.string(), + operationId: z.string().optional(), + summary: z.string().optional(), + description: z.string().optional(), + baseUrl: z.string().optional(), + serverUrlSuffix: z.string().optional(), + serverVariables: z.array(serverVariableSchema).optional(), + parameters: z.array(parameterSchema).optional(), + requestBody: z.record(z.string(), z.unknown()).optional(), + responses: z.record(z.string(), z.record(z.string(), z.unknown())).optional(), + security: z.array(z.record(z.string(), z.array(z.string()))).optional(), + securitySchemes: z.record(z.string(), securitySchemeSchema).optional(), + deprecated: z.boolean().optional(), + tags: z.array(z.string()).optional(), +}) export const apiCollectionSchema = z.object({ title: z.string(), From cc723a576c09af340dae062097567066f5b0d88a Mon Sep 17 00:00:00 2001 From: Adrian Kahali Date: Wed, 3 Jun 2026 20:32:20 -0400 Subject: [PATCH 04/10] fixes --- packages/openapi/src/openapi.ts | 37 ++++++++++-- packages/openapi/test/fixtures.ts | 3 + packages/openapi/test/openapi.test.ts | 81 ++++++++++++++++++++++++++- 3 files changed, 114 insertions(+), 7 deletions(-) diff --git a/packages/openapi/src/openapi.ts b/packages/openapi/src/openapi.ts index dd5d0b2..ee6044e 100644 --- a/packages/openapi/src/openapi.ts +++ b/packages/openapi/src/openapi.ts @@ -8,6 +8,7 @@ import type { OpenApiOperation, OpenApiPathItem, OpenApiSpec, + Parameter, ServerVariable, } from './types.js' import { slugify } from './utils.js' @@ -26,7 +27,7 @@ export interface ExtractedApiEntry { } export async function loadOpenApiSpec(source: ApiLoaderOptions['source']): Promise { - if (typeof source === 'function') return source() + if (typeof source === 'function') return dereferenceObject(await source()) if (source instanceof URL) { return dereference(source.protocol === 'file:' ? fileURLToPath(source) : source.href) } @@ -40,6 +41,7 @@ export async function loadOpenApiSpec(source: ApiLoaderOptions['source']): Promi export async function extractApiEntries(options: ApiLoaderOptions): Promise { const spec = (await loadOpenApiSpec(options.source)) as OpenApiSpec const entries: ExtractedApiEntry[] = [] + const entrySources = new Map() const excludedTags = new Set(options.excludeTags ?? []) const securitySchemes = spec.components?.securitySchemes const server = spec.servers?.[0] @@ -65,10 +67,19 @@ export async function extractApiEntries(options: ApiLoaderOptions): Promise excludedTags.has(tag)) } +function getOperationSlug(method: HttpMethod, path: string, operation: OpenApiOperation): string { + if (operation.operationId !== undefined) return slugify(operation.operationId) + return slugify(`${method}-${path.replaceAll('{', 'by-').replaceAll('}', '')}`) +} + function buildEndpoint(options: { method: HttpMethod path: string @@ -103,7 +119,7 @@ function buildEndpoint(options: { securitySchemes?: Endpoint['securitySchemes'] serverVariables?: ServerVariable[] }): Endpoint { - const parameters = [...(options.pathItem.parameters ?? []), ...(options.operation.parameters ?? [])] + const parameters = mergeParameters(options.pathItem.parameters, options.operation.parameters) const endpoint: Endpoint = { method: options.method.toUpperCase(), path: options.path, @@ -128,6 +144,17 @@ function buildEndpoint(options: { return endpoint } +function mergeParameters(pathParameters: Parameter[] = [], operationParameters: Parameter[] = []): Parameter[] { + const parameters = new Map() + for (const parameter of pathParameters) { + parameters.set(`${parameter.in}:${parameter.name}`, parameter) + } + for (const parameter of operationParameters) { + parameters.set(`${parameter.in}:${parameter.name}`, parameter) + } + return [...parameters.values()] +} + function getServerVariables(variables: Record | undefined) { if (!variables) return undefined return Object.entries(variables).map(([name, variable]) => { @@ -143,6 +170,6 @@ async function dereference(source: string): Promise { } async function dereferenceObject(source: OpenApiSpec): Promise { - const spec = await SwaggerParser.dereference(source as never) + const spec = await SwaggerParser.dereference(source as unknown as Parameters[0]) return spec as unknown as OpenApiSpec } diff --git a/packages/openapi/test/fixtures.ts b/packages/openapi/test/fixtures.ts index 90bd596..57af81f 100644 --- a/packages/openapi/test/fixtures.ts +++ b/packages/openapi/test/fixtures.ts @@ -1,5 +1,8 @@ import { join } from 'node:path' +import petstore from './fixtures/petstore.json' export const fixturePath = join(import.meta.dir, 'fixtures/petstore.json') export const fixtureUrl = new URL('./fixtures/petstore.json', import.meta.url) + +export { petstore } diff --git a/packages/openapi/test/openapi.test.ts b/packages/openapi/test/openapi.test.ts index 18e2f05..df25f11 100644 --- a/packages/openapi/test/openapi.test.ts +++ b/packages/openapi/test/openapi.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' import { extractApiEntries, getApiEntryIds, loadOpenApiSpec } from '../src/openapi.js' -import { fixturePath, fixtureUrl } from './fixtures.js' +import type { OpenApiSpec } from '../src/types.js' +import { fixturePath, fixtureUrl, petstore } from './fixtures.js' const petstoreEntryIds = [ 'api/addpet', @@ -69,7 +70,83 @@ describe('OpenAPI extraction', () => { expect(entries).toHaveLength(19) }) + test('dereferences source function results', async () => { + const entries = await extractApiEntries({ + slug: 'api', + label: 'API', + source: () => petstore as unknown as OpenApiSpec, + }) + + expect(entries[0]?.endpoint.requestBody?.content?.['application/json']?.schema).toMatchObject({ + type: 'object', + required: ['name', 'photoUrls'], + }) + }) + + test('operation parameters override path parameters by name and location', async () => { + const entries = await extractApiEntries({ + slug: 'api', + label: 'API', + source: { + openapi: '3.0.0', + info: { title: 'Test API', version: '1.0.0' }, + paths: { + '/pets/{petId}': { + parameters: [ + { name: 'petId', in: 'path', required: true, description: 'Path-level ID' }, + { name: 'include', in: 'query', description: 'Path-level include' }, + ], + get: { + operationId: 'getPet', + parameters: [{ name: 'petId', in: 'path', required: true, description: 'Operation-level ID' }], + responses: { '200': { description: 'OK' } }, + }, + }, + }, + }, + }) + + expect(entries[0]?.endpoint.parameters).toEqual([ + { name: 'petId', in: 'path', required: true, description: 'Operation-level ID' }, + { name: 'include', in: 'query', description: 'Path-level include' }, + ]) + }) + + test('distinguishes fallback ids for path parameters and literal segments', async () => { + const entries = await extractApiEntries({ + slug: 'api', + label: 'API', + source: { + openapi: '3.0.0', + info: { title: 'Test API', version: '1.0.0' }, + paths: { + '/pets/{petId}': { get: { summary: 'Get pet by ID', responses: { '200': { description: 'OK' } } } }, + '/pets/petId': { get: { summary: 'Get literal petId', responses: { '200': { description: 'OK' } } } }, + }, + }, + }) + + expect(entries.map((entry) => entry.id)).toEqual(['api/get-pets-by-petid', 'api/get-pets-petid']) + }) + + test('throws on duplicate generated entry ids', async () => { + expect( + extractApiEntries({ + slug: 'api', + label: 'API', + source: { + openapi: '3.0.0', + info: { title: 'Test API', version: '1.0.0' }, + paths: { + '/pets': { get: { operationId: 'list_pets', responses: { '200': { description: 'OK' } } } }, + '/animals': { get: { operationId: 'list-pets', responses: { '200': { description: 'OK' } } } }, + }, + }, + }) + ).rejects.toThrow('Duplicate OpenAPI entry id "api/list-pets"') + }) + test('returns generated API entry ids', async () => { - await expect(getApiEntryIds({ slug: 'api', label: 'API', source: fixtureUrl })).resolves.toEqual(petstoreEntryIds) + expect(getApiEntryIds({ slug: 'api', label: 'API', source: fixtureUrl })).resolves.toEqual(petstoreEntryIds) }) }) From 13f381ac3fd2d7b79338def96d0747b7ee9a4d1d Mon Sep 17 00:00:00 2001 From: Adrian Kahali Date: Wed, 3 Jun 2026 20:54:08 -0400 Subject: [PATCH 05/10] use spec type from openapi-types --- bun.lock | 1 + packages/openapi/package.json | 3 ++- packages/openapi/src/index.ts | 1 + packages/openapi/src/openapi.ts | 15 ++++++++------- packages/openapi/src/types.ts | 6 +++++- packages/openapi/test/openapi.test.ts | 9 ++++++--- 6 files changed, 23 insertions(+), 12 deletions(-) diff --git a/bun.lock b/bun.lock index 97c82db..da08849 100644 --- a/bun.lock +++ b/bun.lock @@ -28,6 +28,7 @@ "version": "0.0.0", "dependencies": { "@apidevtools/swagger-parser": "^12.1.0", + "openapi-types": "^12.1.3", }, "devDependencies": { "astro": "^6.4.2", diff --git a/packages/openapi/package.json b/packages/openapi/package.json index 6b3ff11..c2deb79 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -40,7 +40,8 @@ "test": "bun test" }, "dependencies": { - "@apidevtools/swagger-parser": "^12.1.0" + "@apidevtools/swagger-parser": "^12.1.0", + "openapi-types": "^12.1.3" }, "peerDependencies": { "astro": "^6.0.0" diff --git a/packages/openapi/src/index.ts b/packages/openapi/src/index.ts index 86bdb1b..b4d4e2b 100644 --- a/packages/openapi/src/index.ts +++ b/packages/openapi/src/index.ts @@ -5,6 +5,7 @@ export type { ApiEntryData, ApiLoaderOptions, ApiSpecSource, + DereferencedOpenApiSpec, Endpoint, HttpMethod, OpenApiOperation, diff --git a/packages/openapi/src/openapi.ts b/packages/openapi/src/openapi.ts index ee6044e..3e4f107 100644 --- a/packages/openapi/src/openapi.ts +++ b/packages/openapi/src/openapi.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url' import SwaggerParser from '@apidevtools/swagger-parser' import type { ApiLoaderOptions, + DereferencedOpenApiSpec, Endpoint, HttpMethod, OpenApiOperation, @@ -26,7 +27,7 @@ export interface ExtractedApiEntry { endpoint: Endpoint } -export async function loadOpenApiSpec(source: ApiLoaderOptions['source']): Promise { +export async function loadOpenApiSpec(source: ApiLoaderOptions['source']): Promise { if (typeof source === 'function') return dereferenceObject(await source()) if (source instanceof URL) { return dereference(source.protocol === 'file:' ? fileURLToPath(source) : source.href) @@ -39,7 +40,7 @@ export async function loadOpenApiSpec(source: ApiLoaderOptions['source']): Promi } export async function extractApiEntries(options: ApiLoaderOptions): Promise { - const spec = (await loadOpenApiSpec(options.source)) as OpenApiSpec + const spec = await loadOpenApiSpec(options.source) const entries: ExtractedApiEntry[] = [] const entrySources = new Map() const excludedTags = new Set(options.excludeTags ?? []) @@ -164,12 +165,12 @@ function getServerVariables(variables: Record { +async function dereference(source: string): Promise { const spec = await SwaggerParser.dereference(source) - return spec as unknown as OpenApiSpec + return spec as unknown as DereferencedOpenApiSpec } -async function dereferenceObject(source: OpenApiSpec): Promise { - const spec = await SwaggerParser.dereference(source as unknown as Parameters[0]) - return spec as unknown as OpenApiSpec +async function dereferenceObject(source: OpenApiSpec): Promise { + const spec = await SwaggerParser.dereference(source) + return spec as unknown as DereferencedOpenApiSpec } diff --git a/packages/openapi/src/types.ts b/packages/openapi/src/types.ts index edbb08a..8d2da18 100644 --- a/packages/openapi/src/types.ts +++ b/packages/openapi/src/types.ts @@ -1,6 +1,10 @@ +import type { OpenAPI } from 'openapi-types' + export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head' | 'options' | 'trace' -export type OpenApiSpec = Record & { +export type OpenApiSpec = OpenAPI.Document + +export type DereferencedOpenApiSpec = Record & { paths?: Record servers?: OpenApiServer[] security?: SecurityRequirement[] diff --git a/packages/openapi/test/openapi.test.ts b/packages/openapi/test/openapi.test.ts index df25f11..4e33152 100644 --- a/packages/openapi/test/openapi.test.ts +++ b/packages/openapi/test/openapi.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { extractApiEntries, getApiEntryIds, loadOpenApiSpec } from '../src/openapi.js' +import { extractApiEntries, getApiEntryIds } from '../src/openapi.js' import type { OpenApiSpec } from '../src/types.js' import { fixturePath, fixtureUrl, petstore } from './fixtures.js' @@ -64,8 +64,11 @@ describe('OpenAPI extraction', () => { }) test('loads raw specs and source functions', async () => { - const spec = await loadOpenApiSpec(fixturePath) - const entries = await extractApiEntries({ slug: 'api', label: 'API', source: () => spec }) + const entries = await extractApiEntries({ + slug: 'api', + label: 'API', + source: () => petstore as unknown as OpenApiSpec, + }) expect(entries).toHaveLength(19) }) From 2295c80b3bf06c3a306bb695a4181d2a7af015fe Mon Sep 17 00:00:00 2001 From: Adrian Kahali Date: Wed, 3 Jun 2026 21:40:18 -0400 Subject: [PATCH 06/10] limit to openapi v3 and 3.1 --- packages/openapi/src/openapi.ts | 22 +++++++++++++++------- packages/openapi/src/types.ts | 4 ++-- packages/openapi/test/openapi.test.ts | 25 +++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/packages/openapi/src/openapi.ts b/packages/openapi/src/openapi.ts index 3e4f107..8dbd871 100644 --- a/packages/openapi/src/openapi.ts +++ b/packages/openapi/src/openapi.ts @@ -28,15 +28,15 @@ export interface ExtractedApiEntry { } export async function loadOpenApiSpec(source: ApiLoaderOptions['source']): Promise { - if (typeof source === 'function') return dereferenceObject(await source()) + if (typeof source === 'function') return loadOpenApiFromObject(await source()) if (source instanceof URL) { - return dereference(source.protocol === 'file:' ? fileURLToPath(source) : source.href) + return loadOpenApiFromLocation(source.protocol === 'file:' ? fileURLToPath(source) : source.href) } if (typeof source === 'string') { const absolutePath = resolve(process.cwd(), source) - return dereference(absolutePath) + return loadOpenApiFromLocation(absolutePath) } - return dereferenceObject(source) + return loadOpenApiFromObject(source) } export async function extractApiEntries(options: ApiLoaderOptions): Promise { @@ -74,7 +74,7 @@ export async function extractApiEntries(options: ApiLoaderOptions): Promise { +async function loadOpenApiFromLocation(source: string): Promise { const spec = await SwaggerParser.dereference(source) + assertOpenApi3Spec(spec) return spec as unknown as DereferencedOpenApiSpec } -async function dereferenceObject(source: OpenApiSpec): Promise { +async function loadOpenApiFromObject(source: OpenApiSpec): Promise { + assertOpenApi3Spec(source) const spec = await SwaggerParser.dereference(source) return spec as unknown as DereferencedOpenApiSpec } + +function assertOpenApi3Spec(spec: unknown): asserts spec is OpenApiSpec { + if (typeof spec !== 'object' || spec === null || !('openapi' in spec)) { + throw new Error('[Cod OpenAPI] Unsupported OpenAPI spec version; expected an OpenAPI 3.x document') + } +} diff --git a/packages/openapi/src/types.ts b/packages/openapi/src/types.ts index 8d2da18..2ea64dc 100644 --- a/packages/openapi/src/types.ts +++ b/packages/openapi/src/types.ts @@ -1,8 +1,8 @@ -import type { OpenAPI } from 'openapi-types' +import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types' export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head' | 'options' | 'trace' -export type OpenApiSpec = OpenAPI.Document +export type OpenApiSpec = OpenAPIV3.Document | OpenAPIV3_1.Document export type DereferencedOpenApiSpec = Record & { paths?: Record diff --git a/packages/openapi/test/openapi.test.ts b/packages/openapi/test/openapi.test.ts index 4e33152..36195dc 100644 --- a/packages/openapi/test/openapi.test.ts +++ b/packages/openapi/test/openapi.test.ts @@ -1,4 +1,7 @@ import { describe, expect, test } from 'bun:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' import { extractApiEntries, getApiEntryIds } from '../src/openapi.js' import type { OpenApiSpec } from '../src/types.js' import { fixturePath, fixtureUrl, petstore } from './fixtures.js' @@ -73,6 +76,28 @@ describe('OpenAPI extraction', () => { expect(entries).toHaveLength(19) }) + test('rejects Swagger 2.0 specs with a clear error', async () => { + const directory = await mkdtemp(join(tmpdir(), 'cod-openapi-')) + const specPath = join(directory, 'swagger.json') + + try { + await writeFile( + specPath, + JSON.stringify({ + swagger: '2.0', + info: { title: 'Legacy API', version: '1.0.0' }, + paths: { '/pets': { get: { responses: { '200': { description: 'OK' } } } } }, + }) + ) + + await expect(extractApiEntries({ slug: 'api', label: 'API', source: specPath })).rejects.toThrow( + 'Unsupported OpenAPI spec version' + ) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + test('dereferences source function results', async () => { const entries = await extractApiEntries({ slug: 'api', From 93385f095818444d7d1b40f89efea813d446a5e0 Mon Sep 17 00:00:00 2001 From: Adrian Kahali Date: Wed, 3 Jun 2026 21:55:51 -0400 Subject: [PATCH 07/10] tests: return instead of awaiting --- packages/openapi/test/openapi.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/openapi/test/openapi.test.ts b/packages/openapi/test/openapi.test.ts index 36195dc..95000e6 100644 --- a/packages/openapi/test/openapi.test.ts +++ b/packages/openapi/test/openapi.test.ts @@ -90,7 +90,7 @@ describe('OpenAPI extraction', () => { }) ) - await expect(extractApiEntries({ slug: 'api', label: 'API', source: specPath })).rejects.toThrow( + return expect(extractApiEntries({ slug: 'api', label: 'API', source: specPath })).rejects.toThrow( 'Unsupported OpenAPI spec version' ) } finally { @@ -158,7 +158,7 @@ describe('OpenAPI extraction', () => { }) test('throws on duplicate generated entry ids', async () => { - expect( + return expect( extractApiEntries({ slug: 'api', label: 'API', @@ -175,6 +175,6 @@ describe('OpenAPI extraction', () => { }) test('returns generated API entry ids', async () => { - expect(getApiEntryIds({ slug: 'api', label: 'API', source: fixtureUrl })).resolves.toEqual(petstoreEntryIds) + return expect(getApiEntryIds({ slug: 'api', label: 'API', source: fixtureUrl })).resolves.toEqual(petstoreEntryIds) }) }) From a452660762703f966332d099f1a99ffa458254e5 Mon Sep 17 00:00:00 2001 From: Adrian Kahali Date: Wed, 3 Jun 2026 22:20:36 -0400 Subject: [PATCH 08/10] add jsdoc --- packages/openapi/src/loader.ts | 7 +++ packages/openapi/src/openapi.ts | 26 ++++++++++ packages/openapi/src/schema.ts | 6 +++ packages/openapi/src/types.ts | 90 +++++++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+) diff --git a/packages/openapi/src/loader.ts b/packages/openapi/src/loader.ts index 3759c14..175d9c7 100644 --- a/packages/openapi/src/loader.ts +++ b/packages/openapi/src/loader.ts @@ -2,6 +2,13 @@ import type { Loader } from 'astro/loaders' import { extractApiEntries } from './openapi.js' import type { ApiLoaderOptions } from './types.js' +/** + * Creates an Astro content loader that generates one entry per OpenAPI operation. + * + * The loader clears the target store on each run, dereferences the configured + * OpenAPI 3.x source, parses each generated entry through Astro's `parseData`, + * and stores entries with ids like `${slug}/${operation}`. + */ export function apiLoader(options: ApiLoaderOptions): Loader { return { name: 'cod-openapi-loader', diff --git a/packages/openapi/src/openapi.ts b/packages/openapi/src/openapi.ts index 8dbd871..842ff9a 100644 --- a/packages/openapi/src/openapi.ts +++ b/packages/openapi/src/openapi.ts @@ -16,17 +16,34 @@ import { slugify } from './utils.js' const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'] as const +/** API entry generated from a single OpenAPI operation before Astro stores it. */ export interface ExtractedApiEntry { + /** Stable entry id, built from the configured API slug and generated operation slug. */ id: string + /** Entry title from operation summary, operation id, or method/path fallback. */ title: string + /** Long-form operation description, when provided. */ description?: string + /** Uppercase HTTP method for display and filtering. */ method: string + /** API grouping slug copied from loader options. */ apiSlug: string + /** Human-readable API grouping label copied from loader options. */ apiLabel: string + /** Zero-based order based on traversal through paths and methods. */ sortOrder: number + /** Generated endpoint details for rendering the operation. */ endpoint: Endpoint } +/** + * Loads and dereferences an OpenAPI 3.x document. + * + * String sources are resolved relative to `process.cwd()`. File URLs are loaded + * from disk, non-file URLs are passed through to the Swagger parser, object + * sources are used directly, and function sources are awaited before + * dereferencing. + */ export async function loadOpenApiSpec(source: ApiLoaderOptions['source']): Promise { if (typeof source === 'function') return loadOpenApiFromObject(await source()) if (source instanceof URL) { @@ -39,6 +56,14 @@ export async function loadOpenApiSpec(source: ApiLoaderOptions['source']): Promi return loadOpenApiFromObject(source) } +/** + * Extracts generated API reference entries from an OpenAPI 3.x document. + * + * Entry ids use the configured `slug` plus either the operation id or a + * method/path fallback. Operations with any matching `excludeTags` value are + * skipped. Operation-level parameters override path-level parameters with the + * same `in:name`, and duplicate generated ids throw an error. + */ export async function extractApiEntries(options: ApiLoaderOptions): Promise { const spec = await loadOpenApiSpec(options.source) const entries: ExtractedApiEntry[] = [] @@ -96,6 +121,7 @@ export async function extractApiEntries(options: ApiLoaderOptions): Promise { const entries = await extractApiEntries(options) return entries.map((entry) => entry.id) diff --git a/packages/openapi/src/schema.ts b/packages/openapi/src/schema.ts index ef88dd2..330df75 100644 --- a/packages/openapi/src/schema.ts +++ b/packages/openapi/src/schema.ts @@ -1,7 +1,9 @@ import { z } from 'astro/zod' +/** Loose schema for dereferenced JSON schema-like objects preserved from OpenAPI. */ export const schemaSchema = z.record(z.string(), z.unknown()) +/** Validates OpenAPI parameters copied to generated endpoint data. */ export const parameterSchema = z.looseObject({ name: z.string(), in: z.string(), @@ -10,6 +12,7 @@ export const parameterSchema = z.looseObject({ schema: schemaSchema.optional(), }) +/** Validates OpenAPI security schemes copied from `components.securitySchemes`. */ export const securitySchemeSchema = z.looseObject({ type: z.string(), scheme: z.string().optional(), @@ -19,12 +22,14 @@ export const securitySchemeSchema = z.looseObject({ in: z.string().optional(), }) +/** Validates server URL template variables copied to generated endpoint data. */ export const serverVariableSchema = z.object({ name: z.string(), default: z.string(), description: z.string().optional(), }) +/** Validates generated endpoint data for a single OpenAPI operation. */ export const endpointSchema = z.looseObject({ method: z.string(), path: z.string(), @@ -43,6 +48,7 @@ export const endpointSchema = z.looseObject({ tags: z.array(z.string()).optional(), }) +/** Validates the generated Astro content collection entry data shape. */ export const apiCollectionSchema = z.object({ title: z.string(), description: z.string().optional(), diff --git a/packages/openapi/src/types.ts b/packages/openapi/src/types.ts index 2ea64dc..a425d04 100644 --- a/packages/openapi/src/types.ts +++ b/packages/openapi/src/types.ts @@ -1,9 +1,17 @@ import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types' +/** HTTP methods that Cod extracts from OpenAPI path items. */ export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head' | 'options' | 'trace' +/** An OpenAPI 3.0 or 3.1 document accepted by the loader. */ export type OpenApiSpec = OpenAPIV3.Document | OpenAPIV3_1.Document +/** + * OpenAPI document after references have been dereferenced. + * + * Cod keeps the shape intentionally loose so extension fields and OpenAPI + * features that are not rendered directly can still pass through to consumers. + */ export type DereferencedOpenApiSpec = Record & { paths?: Record servers?: OpenApiServer[] @@ -14,12 +22,17 @@ export type DereferencedOpenApiSpec = Record & { } } +/** Server metadata from an OpenAPI document. */ export interface OpenApiServer { + /** Server base URL, copied to generated endpoints as `baseUrl`. */ url: string + /** Template variables declared by the server URL. */ variables?: Record } +/** Path item containing path-level parameters and supported operations. */ export interface OpenApiPathItem extends Record { + /** Parameters shared by operations on this path. */ parameters?: Parameter[] get?: OpenApiOperation post?: OpenApiOperation @@ -31,18 +44,29 @@ export interface OpenApiPathItem extends Record { trace?: OpenApiOperation } +/** OpenAPI operation data used to build a generated API entry. */ export interface OpenApiOperation extends Record { + /** Stable operation identifier; used to generate the entry id when present. */ operationId?: string + /** Short operation title; used as the entry title before falling back to `operationId`. */ summary?: string + /** Long-form operation description copied to the entry and endpoint. */ description?: string + /** Whether the operation is marked deprecated in the OpenAPI document. */ deprecated?: boolean + /** Operation tags; matched against `excludeTags` and copied to the endpoint. */ tags?: string[] + /** Operation-level parameters; override path-level parameters with the same `in:name`. */ parameters?: Parameter[] + /** Request body definition copied from the dereferenced operation. */ requestBody?: RequestBody + /** Response definitions keyed by status code or `default`. */ responses?: Record + /** Operation-level security requirements; override document-level security. */ security?: SecurityRequirement[] } +/** Dereferenced JSON schema-like object preserved from the OpenAPI document. */ export interface Schema extends Record { type?: string properties?: Record @@ -67,75 +91,141 @@ export interface Schema extends Record { additionalProperties?: boolean | Schema } +/** OpenAPI parameter copied to generated endpoint data. */ export interface Parameter extends Record { + /** Parameter name, such as `petId` or `limit`. */ name: string + /** Parameter location, such as `path`, `query`, `header`, or `cookie`. */ in: string + /** Whether the parameter is required. */ required?: boolean + /** Human-readable parameter description. */ description?: string + /** Dereferenced schema for the parameter value. */ schema?: Schema } +/** OpenAPI request body copied to generated endpoint data. */ export interface RequestBody extends Record { + /** Whether the request body is required. */ required?: boolean + /** Human-readable request body description. */ description?: string + /** Media type map, such as `application/json`, with dereferenced schemas. */ content?: Record } +/** OpenAPI response object copied to generated endpoint data. */ export interface ResponseObject extends Record { + /** Human-readable response description. */ description?: string + /** Media type map, such as `application/json`, with dereferenced schemas. */ content?: Record } +/** OpenAPI security scheme copied from `components.securitySchemes`. */ export interface SecurityScheme extends Record { + /** Security scheme type, such as `apiKey`, `http`, `oauth2`, or `openIdConnect`. */ type: string + /** HTTP authorization scheme, such as `bearer` or `basic`. */ scheme?: string + /** Optional bearer token format hint. */ bearerFormat?: string + /** Human-readable security scheme description. */ description?: string + /** Name of the header, query parameter, or cookie for `apiKey` schemes. */ name?: string + /** Location of the API key for `apiKey` schemes. */ in?: string } +/** Security requirement object keyed by security scheme name. */ export type SecurityRequirement = Record +/** Server URL template variable copied to generated endpoint data. */ export interface ServerVariable { + /** Variable name from the server URL template. */ name: string + /** Default value for the variable. */ default: string + /** Human-readable variable description. */ description?: string } +/** + * Endpoint data generated for a single OpenAPI operation. + * + * The object keeps dereferenced OpenAPI request, response, parameter, and + * security metadata available for rendering API reference pages. + */ export interface Endpoint { + /** Uppercase HTTP method, such as `GET` or `POST`. */ method: string + /** OpenAPI path template, such as `/pets/{petId}`. */ path: string + /** Original OpenAPI operation id, when provided. */ operationId?: string + /** Short operation summary copied from the OpenAPI document. */ summary?: string + /** Long-form operation description copied from the OpenAPI document. */ description?: string + /** First OpenAPI server URL, when one is declared. */ baseUrl?: string + /** Optional server URL suffix available to downstream renderers. */ serverUrlSuffix?: string + /** Variables declared by the first OpenAPI server URL. */ serverVariables?: ServerVariable[] + /** Merged path-level and operation-level parameters. */ parameters?: Parameter[] + /** Dereferenced request body definition. */ requestBody?: RequestBody + /** Dereferenced response definitions keyed by status code or `default`. */ responses?: Record + /** Operation security requirements, or document-level requirements if the operation does not override them. */ security?: SecurityRequirement[] + /** Security schemes copied from `components.securitySchemes`. */ securitySchemes?: Record + /** Whether the operation is marked deprecated. */ deprecated?: boolean + /** Operation tags copied from the OpenAPI document. */ tags?: string[] } +/** + * Source accepted by the OpenAPI loader. + * + * Strings are resolved relative to `process.cwd()`. File URLs are loaded from + * disk, non-file URLs are fetched by the Swagger parser, objects are used + * directly, and functions are called before dereferencing. + */ export type ApiSpecSource = string | URL | OpenApiSpec | (() => OpenApiSpec | Promise) +/** Options used to generate API reference entries from an OpenAPI document. */ export interface ApiLoaderOptions { + /** Entry id prefix and API grouping slug, such as `api` in `api/list-pets`. */ slug: string + /** Human-readable API label copied to each generated entry as `apiLabel`. */ label: string + /** OpenAPI document source to load and dereference. */ source: ApiSpecSource + /** Exclude operations that have any matching tag. */ excludeTags?: string[] } +/** Data shape stored for each generated Astro content collection entry. */ export interface ApiEntryData { + /** Entry title from operation summary, operation id, or method/path fallback. */ title: string + /** Long-form operation description, when provided. */ description?: string + /** Uppercase HTTP method for display and filtering. */ method: string + /** API grouping slug copied from loader options. */ apiSlug: string + /** Human-readable API grouping label copied from loader options. */ apiLabel: string + /** Zero-based order based on traversal through paths and methods. */ sortOrder: number + /** Generated endpoint details for rendering the operation. */ endpoint: Endpoint } From 5442099184e0a3124f202b0b01e02c6630681231 Mon Sep 17 00:00:00 2001 From: Adrian Kahali Date: Thu, 4 Jun 2026 12:44:42 -0400 Subject: [PATCH 09/10] improve api --- packages/core/src/astro-content.d.ts | 27 ++++++++++++++++-- packages/core/src/index.ts | 2 ++ packages/core/src/site.ts | 5 +++- packages/core/src/types.ts | 41 ++++++++++++++++----------- packages/core/test/core.test.ts | 2 +- packages/openapi/src/index.ts | 1 + packages/openapi/src/loader.ts | 4 +-- packages/openapi/src/openapi.ts | 17 ++++++----- packages/openapi/src/schema.ts | 11 +++++-- packages/openapi/src/types.ts | 27 +++++++++++++----- packages/openapi/test/loader.test.ts | 13 ++++++++- packages/openapi/test/openapi.test.ts | 28 +++++++++--------- 12 files changed, 121 insertions(+), 57 deletions(-) diff --git a/packages/core/src/astro-content.d.ts b/packages/core/src/astro-content.d.ts index 17dc625..f2aa2e2 100644 --- a/packages/core/src/astro-content.d.ts +++ b/packages/core/src/astro-content.d.ts @@ -1,5 +1,28 @@ declare module 'astro:content' { - import type { DynamicCollectionEntry } from './types' + export type CollectionKey = string - export function getCollection(name: string): Promise + export interface RenderedContent { + html: string + metadata?: Record + } + + export type CollectionEntry = { + id: string + body?: string + collection: C + data: { + title: string + description?: string + sidebarTitle?: string + icon?: string + method?: string + prose?: boolean + sortOrder?: number + [key: string]: unknown + } + rendered?: RenderedContent + filePath?: string + } + + export function getCollection(name: C): Promise[]> } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6e24516..aa7d7d4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,11 +4,13 @@ export { CodSite } from './site.js' export type { AdjacentPage, AdjacentPages, + BaseEntryData, BreadcrumbItem, CodConfig, CodIntegrationOptions, CollectionGroupItem, DynamicCollectionEntry, + EntryDataExtensions, GroupItem, PageContext, PageEntry, diff --git a/packages/core/src/site.ts b/packages/core/src/site.ts index 8d1c489..ef476ba 100644 --- a/packages/core/src/site.ts +++ b/packages/core/src/site.ts @@ -39,7 +39,10 @@ export class CodSite { return this.#context } - async getPageContext(pathname: string, entry: DynamicCollectionEntry): Promise { + async getPageContext( + pathname: string, + entry: TEntry + ): Promise> { const context = await this.getContext() const title = entry.data.title const description = entry.data.description diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 4a73fa2..a68d341 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,3 +1,5 @@ +import type { CollectionEntry, CollectionKey } from 'astro:content' + export interface CodConfig { defaultCollection: string navigation: { @@ -26,19 +28,24 @@ export interface CollectionGroupItem { collection: string } -export interface DynamicCollectionEntry { - id: string - body?: string - data: { - title: string - description?: string - sidebarTitle?: string - icon?: string - method?: string - prose?: boolean - sortOrder?: number - [key: string]: unknown - } +declare global { + interface CodEntryDataExtensions {} +} + +export interface EntryDataExtensions extends CodEntryDataExtensions {} + +export interface BaseEntryData extends EntryDataExtensions { + title: string + description?: string + sidebarTitle?: string + icon?: string + method?: string + prose?: boolean + sortOrder?: number +} + +export type DynamicCollectionEntry = CollectionEntry & { + data: TData } export interface TabInfo { @@ -105,8 +112,8 @@ export interface SiteContext { defaultEntriesBySlug: Map } -export interface PageContext extends SiteContext { - entry: DynamicCollectionEntry +export interface PageContext extends SiteContext { + entry: DynamicCollectionEntry title: string description: string | undefined activeTab: string | null @@ -116,10 +123,10 @@ export interface PageContext extends SiteContext { next: AdjacentPage | null } -export interface StaticPath { +export interface StaticPath { params: { slug: string | undefined } props: { - entry: DynamicCollectionEntry + entry: DynamicCollectionEntry collectionName: string } } diff --git a/packages/core/test/core.test.ts b/packages/core/test/core.test.ts index 8282c19..6b1ebb4 100644 --- a/packages/core/test/core.test.ts +++ b/packages/core/test/core.test.ts @@ -216,7 +216,7 @@ function entriesByCollection(): Map { } function entry(id: string, title: string, data: Record = {}): DynamicCollectionEntry { - return { id, body: '', data: { title, ...data } } + return { id, body: '', collection: 'test', data: { title, ...data } } } async function makeTempDir(): Promise { diff --git a/packages/openapi/src/index.ts b/packages/openapi/src/index.ts index b4d4e2b..8729a98 100644 --- a/packages/openapi/src/index.ts +++ b/packages/openapi/src/index.ts @@ -11,6 +11,7 @@ export type { OpenApiOperation, OpenApiPathItem, OpenApiServer, + OpenApiEntryData, OpenApiSpec, Parameter, RequestBody, diff --git a/packages/openapi/src/loader.ts b/packages/openapi/src/loader.ts index 175d9c7..588cf75 100644 --- a/packages/openapi/src/loader.ts +++ b/packages/openapi/src/loader.ts @@ -22,10 +22,8 @@ export function apiLoader(options: ApiLoaderOptions): Loader { title: entry.title, description: entry.description, method: entry.method, - apiSlug: entry.apiSlug, - apiLabel: entry.apiLabel, sortOrder: entry.sortOrder, - endpoint: entry.endpoint, + openapi: entry.openapi, }, }) diff --git a/packages/openapi/src/openapi.ts b/packages/openapi/src/openapi.ts index 842ff9a..38e1d72 100644 --- a/packages/openapi/src/openapi.ts +++ b/packages/openapi/src/openapi.ts @@ -9,6 +9,7 @@ import type { OpenApiOperation, OpenApiPathItem, OpenApiSpec, + OpenApiEntryData, Parameter, ServerVariable, } from './types.js' @@ -26,14 +27,10 @@ export interface ExtractedApiEntry { description?: string /** Uppercase HTTP method for display and filtering. */ method: string - /** API grouping slug copied from loader options. */ - apiSlug: string - /** Human-readable API grouping label copied from loader options. */ - apiLabel: string /** Zero-based order based on traversal through paths and methods. */ sortOrder: number - /** Generated endpoint details for rendering the operation. */ - endpoint: Endpoint + /** OpenAPI-specific payload. */ + openapi: OpenApiEntryData } /** @@ -108,10 +105,12 @@ export async function extractApiEntries(options: ApiLoaderOptions): Promise OpenApiSpec | Pr export interface ApiLoaderOptions { /** Entry id prefix and API grouping slug, such as `api` in `api/list-pets`. */ slug: string - /** Human-readable API label copied to each generated entry as `apiLabel`. */ + /** Human-readable API label copied to each generated entry's OpenAPI payload. */ label: string /** OpenAPI document source to load and dereference. */ source: ApiSpecSource @@ -212,6 +212,16 @@ export interface ApiLoaderOptions { excludeTags?: string[] } +/** OpenAPI-specific payload stored on generated API entries. */ +export interface OpenApiEntryData { + /** API grouping slug copied from loader options. */ + apiSlug: string + /** Human-readable API grouping label copied from loader options. */ + apiLabel: string + /** Generated endpoint details for rendering the operation. */ + endpoint: Endpoint +} + /** Data shape stored for each generated Astro content collection entry. */ export interface ApiEntryData { /** Entry title from operation summary, operation id, or method/path fallback. */ @@ -220,12 +230,15 @@ export interface ApiEntryData { description?: string /** Uppercase HTTP method for display and filtering. */ method: string - /** API grouping slug copied from loader options. */ - apiSlug: string - /** Human-readable API grouping label copied from loader options. */ - apiLabel: string /** Zero-based order based on traversal through paths and methods. */ sortOrder: number - /** Generated endpoint details for rendering the operation. */ - endpoint: Endpoint + /** OpenAPI-specific payload; also acts as the API page discriminator. */ + openapi: OpenApiEntryData +} + +declare global { + interface CodEntryDataExtensions { + /** OpenAPI-specific payload when the entry represents an OpenAPI operation. */ + openapi?: OpenApiEntryData + } } diff --git a/packages/openapi/test/loader.test.ts b/packages/openapi/test/loader.test.ts index 4243f91..6e8971b 100644 --- a/packages/openapi/test/loader.test.ts +++ b/packages/openapi/test/loader.test.ts @@ -23,6 +23,17 @@ describe('apiLoader', () => { expect(parsedIds).toContain('api/addpet') expect(parsedIds).toHaveLength(19) expect(stored.size).toBe(19) - expect(stored.get('api/addpet')).toMatchObject({ title: 'Add a new pet to the store.', method: 'POST' }) + expect(stored.get('api/addpet')).toMatchObject({ + title: 'Add a new pet to the store.', + method: 'POST', + openapi: { + apiSlug: 'api', + apiLabel: 'API', + endpoint: { + method: 'POST', + path: '/pet', + }, + }, + }) }) }) diff --git a/packages/openapi/test/openapi.test.ts b/packages/openapi/test/openapi.test.ts index 95000e6..370ab92 100644 --- a/packages/openapi/test/openapi.test.ts +++ b/packages/openapi/test/openapi.test.ts @@ -37,22 +37,24 @@ describe('OpenAPI extraction', () => { expect(entries[0]).toMatchObject({ title: 'Add a new pet to the store.', method: 'POST', - apiSlug: 'api', - apiLabel: 'API', sortOrder: 0, - endpoint: { - method: 'POST', - path: '/pet', - baseUrl: '/api/v3', - security: [{ petstore_auth: ['write:pets', 'read:pets'] }], - securitySchemes: { - api_key: { type: 'apiKey', name: 'api_key', in: 'header' }, - petstore_auth: { type: 'oauth2' }, + openapi: { + apiSlug: 'api', + apiLabel: 'API', + endpoint: { + method: 'POST', + path: '/pet', + baseUrl: '/api/v3', + security: [{ petstore_auth: ['write:pets', 'read:pets'] }], + securitySchemes: { + api_key: { type: 'apiKey', name: 'api_key', in: 'header' }, + petstore_auth: { type: 'oauth2' }, + }, }, }, }) expect(entries[0]?.description).toBe('Add a new pet to the store.') - expect(entries[0]?.endpoint.requestBody).toBeDefined() + expect(entries[0]?.openapi.endpoint.requestBody).toBeDefined() }) test('excludes tags explicitly', async () => { @@ -105,7 +107,7 @@ describe('OpenAPI extraction', () => { source: () => petstore as unknown as OpenApiSpec, }) - expect(entries[0]?.endpoint.requestBody?.content?.['application/json']?.schema).toMatchObject({ + expect(entries[0]?.openapi.endpoint.requestBody?.content?.['application/json']?.schema).toMatchObject({ type: 'object', required: ['name', 'photoUrls'], }) @@ -134,7 +136,7 @@ describe('OpenAPI extraction', () => { }, }) - expect(entries[0]?.endpoint.parameters).toEqual([ + expect(entries[0]?.openapi.endpoint.parameters).toEqual([ { name: 'petId', in: 'path', required: true, description: 'Operation-level ID' }, { name: 'include', in: 'query', description: 'Path-level include' }, ]) From fb92bfe2204599f2427e3fc3e2ebf600cb395e46 Mon Sep 17 00:00:00 2001 From: Adrian Kahali Date: Thu, 4 Jun 2026 13:25:54 -0400 Subject: [PATCH 10/10] improve badge api --- packages/core/src/astro-content.d.ts | 2 +- packages/core/src/site.ts | 5 +++-- packages/core/src/tree.ts | 13 +++++++++++-- packages/core/src/types.ts | 6 +++--- packages/core/src/utils.ts | 11 +++++++++++ packages/openapi/src/loader.ts | 1 - packages/openapi/src/openapi.ts | 3 --- packages/openapi/src/schema.ts | 1 - packages/openapi/src/types.ts | 2 -- packages/openapi/test/loader.test.ts | 1 - packages/openapi/test/openapi.test.ts | 1 - 11 files changed, 29 insertions(+), 17 deletions(-) diff --git a/packages/core/src/astro-content.d.ts b/packages/core/src/astro-content.d.ts index f2aa2e2..0be65d9 100644 --- a/packages/core/src/astro-content.d.ts +++ b/packages/core/src/astro-content.d.ts @@ -15,7 +15,7 @@ declare module 'astro:content' { description?: string sidebarTitle?: string icon?: string - method?: string + badge?: string prose?: boolean sortOrder?: number [key: string]: unknown diff --git a/packages/core/src/site.ts b/packages/core/src/site.ts index ef476ba..8023551 100644 --- a/packages/core/src/site.ts +++ b/packages/core/src/site.ts @@ -5,7 +5,7 @@ import { fetchCollectionEntries, getReferencedCollections, getRouteSlugForEntry, import { resolveActiveSidebarTree } from './nav.js' import { buildSidebarTree } from './tree.js' import type { CodConfig, DynamicCollectionEntry, PageContext, PageEntry, SiteContext, StaticPath } from './types.js' -import { errorToString, normalizeEntryId } from './utils.js' +import { errorToString, getEntryBadge, normalizeEntryId } from './utils.js' export class CodSite { #config: CodConfig @@ -101,7 +101,8 @@ function buildPages(config: CodConfig, entriesByCollection: Map @@ -143,7 +151,8 @@ function pageFromEntry(path: string, entry: DynamicCollectionEntry | undefined): path, } if (entry?.data.sidebarTitle) node.sidebarTitle = entry.data.sidebarTitle - if (entry?.data.method) node.method = entry.data.method + const badge = getEntryBadge(entry) + if (badge) node.badge = badge if (entry?.data.icon) node.icon = entry.data.icon return node } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a68d341..c84dce5 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -39,7 +39,7 @@ export interface BaseEntryData extends EntryDataExtensions { description?: string sidebarTitle?: string icon?: string - method?: string + badge?: string prose?: boolean sortOrder?: number } @@ -79,7 +79,7 @@ export interface SidebarPageNode { sidebarTitle?: string href: string path: string - method?: string + badge?: string icon?: string } @@ -101,7 +101,7 @@ export interface BreadcrumbItem { export interface PageEntry { slug: string title: string - method?: string + badge?: string } export interface SiteContext { diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index fb723bd..0bf6e92 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -49,3 +49,14 @@ export function pathnameToSlug(pathname: string): string { export function errorToString(error: unknown): string { return error instanceof Error ? error.message : String(error) } + +export function getEntryBadge(entry: { data: { badge?: string; openapi?: unknown } } | undefined): string | undefined { + return entry?.data.badge ?? getOpenApiEndpointMethod(entry?.data.openapi) +} + +function getOpenApiEndpointMethod(value: unknown): string | undefined { + if (typeof value !== 'object' || value === null || !('endpoint' in value)) return undefined + const endpoint = value.endpoint + if (typeof endpoint !== 'object' || endpoint === null || !('method' in endpoint)) return undefined + return typeof endpoint.method === 'string' ? endpoint.method : undefined +} diff --git a/packages/openapi/src/loader.ts b/packages/openapi/src/loader.ts index 588cf75..6de877d 100644 --- a/packages/openapi/src/loader.ts +++ b/packages/openapi/src/loader.ts @@ -21,7 +21,6 @@ export function apiLoader(options: ApiLoaderOptions): Loader { data: { title: entry.title, description: entry.description, - method: entry.method, sortOrder: entry.sortOrder, openapi: entry.openapi, }, diff --git a/packages/openapi/src/openapi.ts b/packages/openapi/src/openapi.ts index 38e1d72..7253518 100644 --- a/packages/openapi/src/openapi.ts +++ b/packages/openapi/src/openapi.ts @@ -25,8 +25,6 @@ export interface ExtractedApiEntry { title: string /** Long-form operation description, when provided. */ description?: string - /** Uppercase HTTP method for display and filtering. */ - method: string /** Zero-based order based on traversal through paths and methods. */ sortOrder: number /** OpenAPI-specific payload. */ @@ -104,7 +102,6 @@ export async function extractApiEntries(options: ApiLoaderOptions): Promise { expect(stored.size).toBe(19) expect(stored.get('api/addpet')).toMatchObject({ title: 'Add a new pet to the store.', - method: 'POST', openapi: { apiSlug: 'api', apiLabel: 'API', diff --git a/packages/openapi/test/openapi.test.ts b/packages/openapi/test/openapi.test.ts index 370ab92..aa2c62d 100644 --- a/packages/openapi/test/openapi.test.ts +++ b/packages/openapi/test/openapi.test.ts @@ -36,7 +36,6 @@ describe('OpenAPI extraction', () => { expect(entries.map((entry) => entry.id)).toEqual(petstoreEntryIds) expect(entries[0]).toMatchObject({ title: 'Add a new pet to the store.', - method: 'POST', sortOrder: 0, openapi: { apiSlug: 'api',