diff --git a/bun.lock b/bun.lock index e01adbe..da08849 100644 --- a/bun.lock +++ b/bun.lock @@ -23,8 +23,30 @@ "astro": "^6.0.0", }, }, + "packages/openapi": { + "name": "cod-openapi", + "version": "0.0.0", + "dependencies": { + "@apidevtools/swagger-parser": "^12.1.0", + "openapi-types": "^12.1.3", + }, + "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 +331,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 +345,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 +367,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 +385,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 +449,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 +513,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 +661,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 +719,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/core/src/astro-content.d.ts b/packages/core/src/astro-content.d.ts index 17dc625..0be65d9 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 + badge?: 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..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 @@ -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 @@ -98,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 4a73fa2..c84dce5 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 + badge?: string + prose?: boolean + sortOrder?: number +} + +export type DynamicCollectionEntry = CollectionEntry & { + data: TData } export interface TabInfo { @@ -72,7 +79,7 @@ export interface SidebarPageNode { sidebarTitle?: string href: string path: string - method?: string + badge?: string icon?: string } @@ -94,7 +101,7 @@ export interface BreadcrumbItem { export interface PageEntry { slug: string title: string - method?: string + badge?: string } export interface SiteContext { @@ -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/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/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/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..c2deb79 --- /dev/null +++ b/packages/openapi/package.json @@ -0,0 +1,52 @@ +{ + "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", + "openapi-types": "^12.1.3" + }, + "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..8729a98 --- /dev/null +++ b/packages/openapi/src/index.ts @@ -0,0 +1,23 @@ +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, + DereferencedOpenApiSpec, + Endpoint, + HttpMethod, + OpenApiOperation, + OpenApiPathItem, + OpenApiServer, + OpenApiEntryData, + 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..6de877d --- /dev/null +++ b/packages/openapi/src/loader.ts @@ -0,0 +1,37 @@ +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', + async load({ store, logger, parseData }) { + store.clear() + const entries = await extractApiEntries(options) + for (const entry of entries) { + const data = await parseData({ + id: entry.id, + data: { + title: entry.title, + description: entry.description, + sortOrder: entry.sortOrder, + openapi: entry.openapi, + }, + }) + + 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/src/openapi.ts b/packages/openapi/src/openapi.ts new file mode 100644 index 0000000..7253518 --- /dev/null +++ b/packages/openapi/src/openapi.ts @@ -0,0 +1,206 @@ +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import SwaggerParser from '@apidevtools/swagger-parser' +import type { + ApiLoaderOptions, + DereferencedOpenApiSpec, + Endpoint, + HttpMethod, + OpenApiOperation, + OpenApiPathItem, + OpenApiSpec, + OpenApiEntryData, + Parameter, + ServerVariable, +} from './types.js' +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 + /** Zero-based order based on traversal through paths and methods. */ + sortOrder: number + /** OpenAPI-specific payload. */ + openapi: OpenApiEntryData +} + +/** + * 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) { + return loadOpenApiFromLocation(source.protocol === 'file:' ? fileURLToPath(source) : source.href) + } + if (typeof source === 'string') { + const absolutePath = resolve(process.cwd(), source) + return loadOpenApiFromLocation(absolutePath) + } + 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[] = [] + const entrySources = new Map() + 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 = getOperationSlug(method, path, operation) + const id = `${options.slug}/${operationSlug}` + const sourceLabel = `${method.toUpperCase()} ${path}` + const existingSource = entrySources.get(id) + if (existingSource !== undefined) { + throw new Error( + `[Cod OpenAPI] Duplicate OpenAPI entry id "${id}" generated for ${sourceLabel}; already used by ${existingSource}` + ) + } + entrySources.set(id, sourceLabel) + + const entry: ExtractedApiEntry = { + id, + title, + sortOrder: entries.length, + openapi: { + apiSlug: options.slug, + apiLabel: options.label, + endpoint, + }, + } + if (operation.description !== undefined) entry.description = operation.description + entries.push(entry) + } + } + + return entries +} + +/** Returns the generated entry ids for an OpenAPI document without exposing entry data. */ +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 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 + pathItem: OpenApiPathItem + operation: OpenApiOperation + baseUrl?: string + security?: Endpoint['security'] + securitySchemes?: Endpoint['securitySchemes'] + serverVariables?: ServerVariable[] +}): Endpoint { + const parameters = mergeParameters(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 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]) => { + const serverVariable: ServerVariable = { name, default: variable.default ?? '' } + if (variable.description !== undefined) serverVariable.description = variable.description + return serverVariable + }) +} + +async function loadOpenApiFromLocation(source: string): Promise { + const spec = await SwaggerParser.dereference(source) + assertOpenApi3Spec(spec) + return spec as unknown as DereferencedOpenApiSpec +} + +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/schema.ts b/packages/openapi/src/schema.ts new file mode 100644 index 0000000..37636f2 --- /dev/null +++ b/packages/openapi/src/schema.ts @@ -0,0 +1,64 @@ +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(), + required: z.boolean().optional(), + description: z.string().optional(), + schema: schemaSchema.optional(), +}) + +/** Validates OpenAPI security schemes copied from `components.securitySchemes`. */ +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(), +}) + +/** 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(), + 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(), +}) + +/** Validates OpenAPI-specific payload stored on generated API entries. */ +export const openApiEntryDataSchema = z.object({ + apiSlug: z.string(), + apiLabel: z.string(), + endpoint: endpointSchema, +}) + +/** Validates the generated Astro content collection entry data shape. */ +export const apiCollectionSchema = z.object({ + title: z.string(), + description: z.string().optional(), + sortOrder: z.number(), + openapi: openApiEntryDataSchema, +}) diff --git a/packages/openapi/src/types.ts b/packages/openapi/src/types.ts new file mode 100644 index 0000000..225121e --- /dev/null +++ b/packages/openapi/src/types.ts @@ -0,0 +1,242 @@ +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[] + security?: SecurityRequirement[] + components?: { + securitySchemes?: Record + [key: string]: unknown + } +} + +/** 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 + put?: OpenApiOperation + patch?: OpenApiOperation + delete?: OpenApiOperation + head?: OpenApiOperation + options?: OpenApiOperation + 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 + 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 +} + +/** 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's OpenAPI payload. */ + label: string + /** OpenAPI document source to load and dereference. */ + source: ApiSpecSource + /** Exclude operations that have any matching tag. */ + 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. */ + title: string + /** Long-form operation description, when provided. */ + description?: string + /** Zero-based order based on traversal through paths and methods. */ + sortOrder: number + /** 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/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..57af81f --- /dev/null +++ b/packages/openapi/test/fixtures.ts @@ -0,0 +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/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..575ab78 --- /dev/null +++ b/packages/openapi/test/loader.test.ts @@ -0,0 +1,38 @@ +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 parsedIds: string[] = [] + 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), + }, + 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.', + 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 new file mode 100644 index 0000000..aa2c62d --- /dev/null +++ b/packages/openapi/test/openapi.test.ts @@ -0,0 +1,181 @@ +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' + +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.', + sortOrder: 0, + 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]?.openapi.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 entries = await extractApiEntries({ + slug: 'api', + label: 'API', + source: () => petstore as unknown as OpenApiSpec, + }) + + 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' } } } } }, + }) + ) + + return 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', + label: 'API', + source: () => petstore as unknown as OpenApiSpec, + }) + + expect(entries[0]?.openapi.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]?.openapi.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 () => { + return 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 () => { + return 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"] +}