diff --git a/package-lock.json b/package-lock.json index f558aff..752b747 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "gray-matter": "^4.0.3", "log-update": "^8.0.0", "next": "^15.0.0", + "oas": "^38.5.0", "oas-normalize": "^15.7.1", "ora": "^9.3.0", "react": "^18.3.1", @@ -1151,6 +1152,30 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@jsep-plugin/assignment": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", + "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@jsep-plugin/regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz", + "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, "node_modules/@mapbox/hast-util-table-cell-style": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@mapbox/hast-util-table-cell-style/-/hast-util-table-cell-style-0.2.1.tgz", @@ -2728,9 +2753,9 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -3519,6 +3544,27 @@ "node": ">=18" } }, + "node_modules/compute-gcd": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/compute-gcd/-/compute-gcd-1.2.1.tgz", + "integrity": "sha512-TwMbxBNz0l71+8Sc4czv13h4kEqnchV9igQZBi6QUaz09dnz13juGnnaWWJTRsP3brxOoxeB4SA2WELLw1hCtg==", + "dependencies": { + "validate.io-array": "^1.0.3", + "validate.io-function": "^1.0.2", + "validate.io-integer-array": "^1.0.0" + } + }, + "node_modules/compute-lcm": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/compute-lcm/-/compute-lcm-1.1.2.tgz", + "integrity": "sha512-OFNPdQAXnQhDSKioX8/XYT6sdUlXwpeMjfd6ApxMJfyZ4GxmLR1xvMERctlYhlHwIiz6CSpBc2+qYKjHGZw4TQ==", + "dependencies": { + "compute-gcd": "^1.2.1", + "validate.io-array": "^1.0.3", + "validate.io-function": "^1.0.2", + "validate.io-integer-array": "^1.0.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -6420,12 +6466,44 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsep": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + } + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, + "node_modules/json-schema-compare": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/json-schema-compare/-/json-schema-compare-0.2.2.tgz", + "integrity": "sha512-c4WYmDKyJXhs7WWvAWm3uIYnfyWFoIp+JEoX34rctVvEkMYCPGhXtvmFFXiffBbxfZsvQ0RNnV5H7GvDF5HCqQ==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.4" + } + }, + "node_modules/json-schema-merge-allof": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/json-schema-merge-allof/-/json-schema-merge-allof-0.8.1.tgz", + "integrity": "sha512-CTUKmIlPJbsWfzRRnOXz+0MjIqvnleIXwFTzz+t9T86HnYX/Rozria6ZVGLktAU9e+NygNljveP+yxqtQp/Q4w==", + "license": "MIT", + "dependencies": { + "compute-lcm": "^1.1.2", + "json-schema-compare": "^0.2.2", + "lodash": "^4.17.20" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/json-schema-to-ts": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", @@ -6459,6 +6537,24 @@ "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", "license": "MIT" }, + "node_modules/jsonpath-plus": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.4.0.tgz", + "integrity": "sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==", + "license": "MIT", + "dependencies": { + "@jsep-plugin/assignment": "^1.3.0", + "@jsep-plugin/regex": "^1.0.4", + "jsep": "^1.4.0" + }, + "bin": { + "jsonpath": "bin/jsonpath-cli.js", + "jsonpath-plus": "bin/jsonpath-cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/jsonpointer": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", @@ -6577,6 +6673,12 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lodash-es": { "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", @@ -8136,6 +8238,25 @@ "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", "license": "MIT" }, + "node_modules/oas": { + "version": "38.5.0", + "resolved": "https://registry.npmjs.org/oas/-/oas-38.5.0.tgz", + "integrity": "sha512-SW8ZbywqP6YN/lns9S+h9mRfOz0wuuNlo7hD/qElIFZ79ntYAbgixnXb0YoZZPiXd9VC3P7/X42C9sQw9FJ9RA==", + "license": "MIT", + "dependencies": { + "@readme/openapi-parser": "^8.0.1", + "@types/json-schema": "^7.0.11", + "json-schema-merge-allof": "^0.8.1", + "jsonpath-plus": "^10.4.0", + "jsonpointer": "^5.0.0", + "openapi-types": "^12.1.1", + "path-to-regexp": "^8.4.2", + "remove-undefined-objects": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/oas-kit-common": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/oas-kit-common/-/oas-kit-common-1.0.8.tgz", @@ -8240,6 +8361,92 @@ "url": "https://github.com/Mermade/oas-kit?sponsor=1" } }, + "node_modules/oas/node_modules/@apidevtools/json-schema-ref-parser": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-15.5.2.tgz", + "integrity": "sha512-B+C9Ok0DF/rjANIUHgwcV5/d4C72MB7f2IbKFL8jDGcGq2qn3yr893s8vAn2kbhmyaelAin0JK8EKF9P1+y7aQ==", + "license": "MIT", + "dependencies": { + "js-yaml": "^5.2.2", + "undici": "^6.28.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@types/json-schema": "^7.0.15" + } + }, + "node_modules/oas/node_modules/@readme/openapi-parser": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@readme/openapi-parser/-/openapi-parser-8.0.1.tgz", + "integrity": "sha512-dskvU052suSqh71Yp+43q2aIdJanGXDPOSbSTPCnDrRhRmLClF08Im/yqP1JXJkjh12Y3uY+RVwTGvjrOSDJUA==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^15.5.1", + "@readme/better-ajv-errors": "^2.3.2", + "@readme/openapi-schemas": "^4.0.0", + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-draft-04": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, + "node_modules/oas/node_modules/@readme/openapi-schemas": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@readme/openapi-schemas/-/openapi-schemas-4.0.0.tgz", + "integrity": "sha512-hrG//9/+RpOZTrUDirU4OzfMqaxCdY5BMpr3YuLf3Rje9rfNsydQJsH9iCPTie9ZRu9xe+0OUD4fHe0JmGwa2w==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/oas/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/oas/node_modules/js-yaml": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.1.tgz", + "integrity": "sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, + "node_modules/oas/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -9277,6 +9484,15 @@ "integrity": "sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==", "license": "ISC" }, + "node_modules/remove-undefined-objects": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/remove-undefined-objects/-/remove-undefined-objects-9.0.0.tgz", + "integrity": "sha512-oTxi32KwkldmtDNo5IshRJBrCs8Pq6mB4F3Cc+Ehqh0kMExH3lONr8SoTkAD5An7ZGlu6ljus89gz2qp9bfzXQ==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -10282,6 +10498,15 @@ "license": "MIT", "optional": true }, + "node_modules/undici": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -10497,6 +10722,39 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/validate.io-array": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/validate.io-array/-/validate.io-array-1.0.6.tgz", + "integrity": "sha512-DeOy7CnPEziggrOO5CZhVKJw6S3Yi7e9e65R1Nl/RTN1vTQKnzjfvks0/8kQ40FP/dsjRAOd4hxmJ7uLa6vxkg==", + "license": "MIT" + }, + "node_modules/validate.io-function": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/validate.io-function/-/validate.io-function-1.0.2.tgz", + "integrity": "sha512-LlFybRJEriSuBnUhQyG5bwglhh50EpTL2ul23MPIuR1odjO7XaMLFV8vHGwp7AZciFxtYOeiSCT5st+XSPONiQ==" + }, + "node_modules/validate.io-integer": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/validate.io-integer/-/validate.io-integer-1.0.5.tgz", + "integrity": "sha512-22izsYSLojN/P6bppBqhgUDjCkr5RY2jd+N2a3DCAUey8ydvrZ/OkGvFPR7qfOpwR2LC5p4Ngzxz36g5Vgr/hQ==", + "dependencies": { + "validate.io-number": "^1.0.3" + } + }, + "node_modules/validate.io-integer-array": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/validate.io-integer-array/-/validate.io-integer-array-1.0.0.tgz", + "integrity": "sha512-mTrMk/1ytQHtCY0oNO3dztafHYyGU88KL+jRxWuzfOmQb+4qqnWmI+gykvGp8usKZOM0H7keJHEbRaFiYA0VrA==", + "dependencies": { + "validate.io-array": "^1.0.3", + "validate.io-integer": "^1.0.4" + } + }, + "node_modules/validate.io-number": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/validate.io-number/-/validate.io-number-1.0.3.tgz", + "integrity": "sha512-kRAyotcbNaSYoDnXvb4MHg/0a1egJdLwS6oJ38TJY7aw9n93Fl/3blIXdyYvPOp55CNxywooG/3BcrwNrBpcSg==" + }, "node_modules/value-or-promise": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/value-or-promise/-/value-or-promise-1.0.12.tgz", diff --git a/package.json b/package.json index 3beaea5..4834125 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "gray-matter": "^4.0.3", "log-update": "^8.0.0", "next": "^15.0.0", + "oas": "^38.5.0", "oas-normalize": "^15.7.1", "ora": "^9.3.0", "react": "^18.3.1", diff --git a/src/commands/oas-sync.js b/src/commands/oas-sync.js index 9aea4b7..ba46ae9 100644 --- a/src/commands/oas-sync.js +++ b/src/commands/oas-sync.js @@ -2,6 +2,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { createRequire } from 'node:module'; import matter from 'gray-matter'; +import Oas from 'oas'; +import { Operation } from 'oas/operation'; +import { isRef } from 'oas/types'; +import { supportedMethods } from 'oas/utils'; import * as styles from '../utils/styles.js'; const require = createRequire(import.meta.url); @@ -12,8 +16,6 @@ export const order = 2; export const category = 'OAS Tooling'; export const description = 'Sync reference pages with OpenAPI specs'; -const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace']); - /** * Find OAS files at the root of reference/ (JSON or YAML). @@ -42,19 +44,6 @@ export function findOasFiles(refDir) { return oasFiles; } -/** - * Generate a synthetic operationId from the HTTP method and path. - * Matches the algorithm used by the `oas` package for specs without operationIds. - */ -function generateOperationId(method, pathStr) { - const sanitized = pathStr - .replace(/[^a-zA-Z0-9]/g, '-') - .replace(/--+/g, '-') - .replace(/^-|-$/g, '') - .toLowerCase(); - return `${method.toLowerCase()}_${sanitized}`; -} - /** * Identity key for an operation record (or an existing page's frontmatter), * used everywhere operations/pages are looked up by operationId. `paths` and @@ -68,93 +57,111 @@ export function operationKey({ operationId, isWebhook }) { return `${isWebhook ? 'webhook' : 'path'}:${operationId}`; } -/** - * Resolve a `paths`/`webhooks` entry that's a Reference Object (OAS 3.1, - * `{ $ref: '#/components/pathItems/Name' }`) against the spec's own - * `components.pathItems`, following chained refs (a pathItem that is itself - * a $ref to another) until a literal Path Item is reached. Only same-document - * refs in that exact form are supported; anything else (external files, - * other pointer shapes, an unresolvable name, or a cycle) is left unresolved - * and quietly skipped by the caller, same as before this existed. - */ -function resolveLocalPathItemRef(entry, spec) { - const seen = new Set(); - let current = entry; - // Sibling fields (e.g. an inline operation) alongside a $ref are explicitly - // allowed in an OAS 3.1 Path Item Object — accumulate them from every hop - // in the chain so they aren't discarded once $ref is followed. A field - // declared at an outer/earlier hop wins over the same field found deeper - // in the chain (OAS itself leaves this "undefined" when both define it). - let overrides = {}; - const finish = () => ({ ...current, ...overrides }); - - while (current && typeof current.$ref === 'string') { - const { $ref, ...siblings } = current; - overrides = { ...siblings, ...overrides }; - - if (seen.has(current.$ref)) return finish(); - seen.add(current.$ref); - - const match = current.$ref.match(/^#\/components\/pathItems\/(.+)$/); - if (!match) return finish(); - - let name; - try { - name = decodeURIComponent(match[1]).replace(/~1/g, '/').replace(/~0/g, '~'); - } catch { - // Malformed percent-escape — leave unresolved rather than throwing and - // aborting the whole sync/lint run over one bad $ref. - return finish(); - } - const resolved = spec.components?.pathItems?.[name]; - if (!resolved) return finish(); +function toOpRecord(operation, pathStr, isWebhook) { + const tags = operation.getTags(); + return { + operationId: operation.getOperationId(), + summary: operation.getSummary() || null, + description: operation.getDescription() || null, + tag: tags[0]?.name || null, + path: pathStr, + isWebhook, + }; +} - current = resolved; +function collectFromOas(ops, groups, isWebhook) { + let unresolved = false; + for (const [pathStr, methods] of Object.entries(groups)) { + for (const operation of Object.values(methods)) { + // oas invents get_pets for an operation `$ref` it could not inline. + // An operationId on the Reference Object is not proof it resolved — + // the pointer may still be external, cyclic, or broken. + if (isRef(operation.schema)) { + unresolved = true; + continue; + } + const rec = toOpRecord(operation, pathStr, isWebhook); + ops.set(operationKey(rec), rec); + } } - - return finish(); + return unresolved; } /** - * Extract operations from an OAS spec's `paths`, plus its OAS 3.1 `webhooks` - * (callouts the API itself makes to a client-registered URL, not endpoints the - * API exposes — a separate top-level sibling of `paths` with the same - * Operation Object shape). The platform pages a webhook the same way it pages - * a path operation: a synthetic `post_` operationId when none is given, - * grouped by its own tag or, absent one, its own category keyed by its raw - * name — never merged with `paths` operations of the same name. - * Returns a Map keyed by `operationKey()` -> { summary, description, tag, - * path, operationId, isWebhook }. For operations without an operationId, a - * synthetic one is generated from the method and path (or webhook name). + * OAS 3.1 Path Item Objects may keep sibling methods next to `$ref`. + * `oas.getPaths()` / `getWebhooks()` replace the whole entry with the + * target and drop those siblings; overlay them from the authored document. + * Local keys win. */ -export function extractOperations(spec) { - const ops = new Map(); - - function collect(entries, isWebhook) { - for (const [pathStr, rawItem] of Object.entries(entries)) { - const methods = resolveLocalPathItemRef(rawItem, spec); - - for (const [method, operation] of Object.entries(methods)) { - if (!HTTP_METHODS.has(method)) continue; - - const operationId = operation.operationId || generateOperationId(method, pathStr); - - ops.set(operationKey({ operationId, isWebhook }), { - operationId, - summary: operation.summary || null, - description: operation.description || null, - tag: (operation.tags && operation.tags[0]) || null, - path: pathStr, - isWebhook, - }); +function overlaySiblings(ops, resolved, entries, api, isWebhook) { + let unresolved = false; + for (const [pathStr, raw] of Object.entries(entries || {})) { + if (!raw || typeof raw !== 'object') continue; + if (isRef(raw) && Object.keys(resolved[pathStr] || {}).length === 0) { + unresolved = true; + } + if (!isRef(raw)) continue; + for (const method of supportedMethods) { + const sibling = raw[method]; + if (!sibling || typeof sibling !== 'object') continue; + // A sibling `$ref` is still a Reference Object. operationId next to + // it (OAS 3.1 sibling keywords) must not replace a resolved method + // or keep the delete pass on — that wipes the real page and invents + // a stub for the unresolved pointer. + if (isRef(sibling)) { + unresolved = true; + continue; } + const operation = new Operation(api, pathStr, method, sibling); + const rec = toOpRecord(operation, pathStr, isWebhook); + const previous = resolved[pathStr]?.[method]; + if (previous) { + const previousId = previous.getOperationId(); + if (previousId !== rec.operationId) { + ops.delete(operationKey({ operationId: previousId, isWebhook })); + } + } + ops.set(operationKey(rec), rec); } } + return unresolved; +} + +/** + * Walk the spec through `oas`: `$ref` resolution, operationId generation, + * and summary/description/tag accessors. Covers `paths` and OAS 3.1 + * `webhooks`. Unresolved `$ref`s (external files, cycles, broken pointers) + * are reported so the delete pass can stay off. + */ +function inspectSpec(spec) { + const api = new Oas(structuredClone(spec)); + const paths = api.getPaths(); + const webhooks = api.getWebhooks(); + const ops = new Map(); + let unresolved = collectFromOas(ops, paths, false); + unresolved = collectFromOas(ops, webhooks, true) || unresolved; + unresolved = overlaySiblings(ops, paths, spec.paths, api, false) || unresolved; + unresolved = overlaySiblings(ops, webhooks, spec.webhooks, api, true) || unresolved; + return { ops, unresolved }; +} - collect(spec.paths || {}, false); - collect(spec.webhooks || {}, true); +/** + * True when `paths` / `webhooks` still has a `$ref` we could not inline. Used + * to skip the delete pass — missing operations after a failed resolve are + * "we couldn't see the spec", not "the operation was removed". + */ +export function hasUnresolvedOperationRefs(spec) { + return inspectSpec(spec).unresolved; +} - return ops; +/** + * Extract operations from an OAS spec via `oas`. + * Returns a Map keyed by `operationKey()` -> { summary, description, tag, + * path, operationId, isWebhook }. Operations without an operationId get the + * synthetic id `oas` would generate. + */ +export function extractOperations(spec) { + return inspectSpec(spec).ops; } /** @@ -404,7 +411,7 @@ function reserveSlug(takenSlugs, base) { * mutated so slugs stay unique across every spec processed in one sync run. */ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { - const specOps = extractOperations(spec); + const { ops: specOps, unresolved: skipDeletes } = inspectSpec(spec); const infoTitle = safeSegment( spec.info?.title || path.basename(oasFilename, path.extname(oasFilename)), 'api', @@ -423,6 +430,8 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { } const changes = { added: [], deleted: [], skipped: [] }; + // File $refs (and broken internal pointers) mean we cannot see the real + // operation set. Deleting "missing" pages would wipe valid reference docs. // Tag descriptions from the spec's top-level `tags` array, used for the // per-tag category landing page (index.md). @@ -435,6 +444,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { // Deletes: pages referencing operations that no longer exist. for (const [opId, page] of pagesByOpId) { if (!specOps.has(opId)) { + if (skipDeletes) continue; fs.unlinkSync(page.filePath); const pageDir = path.dirname(page.filePath); diff --git a/test/oas-reference.test.js b/test/oas-reference.test.js index 704b24e..a65c20b 100644 --- a/test/oas-reference.test.js +++ b/test/oas-reference.test.js @@ -28,6 +28,33 @@ test('mismatched title/excerpt no longer reported as out of sync', () => { } }); +test('path-item $ref is not reported as a missing operation', () => { + const spec = JSON.stringify({ + openapi: '3.1.0', + info: { title: 'Pets', version: '1.0.0' }, + paths: { + '/pets': { $ref: '#/components/pathItems/Pets' }, + }, + components: { + pathItems: { + Pets: { get: { operationId: 'listPets' } }, + }, + }, + }); + const root = makeRepo({ + 'reference/pets.json': spec, + 'reference/Pets/Other/listPets.md': + '---\napi:\n file: pets.json\n operationId: listPets\n---\n', + }); + try { + const res = validateAll(collectFiles(root), root, {}); + assert.ok(!res.some((r) => r.message.includes('Operation not found'))); + assert.ok(!res.some((r) => r.message.includes('Missing page'))); + } finally { + rmRepo(root); + } +}); + test('operation not found is still reported', () => { const root = makeRepo({ 'reference/pets.json': SPEC, diff --git a/test/oas-sync.test.js b/test/oas-sync.test.js index 7d94144..fa41975 100644 --- a/test/oas-sync.test.js +++ b/test/oas-sync.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import matter from 'gray-matter'; -import { syncOas } from '../src/commands/oas-sync.js'; +import { extractOperations, hasUnresolvedOperationRefs, syncOas } from '../src/commands/oas-sync.js'; import { makeRepo, rmRepo } from './helpers.js'; const SPEC = JSON.stringify({ @@ -312,6 +312,434 @@ test('a slug taken by a category folder (folder/index.md) is not reused by an op } }); +const PATH_ITEMS_SPEC = { + openapi: '3.1.0', + info: { title: 'Pets', version: '1.0.0' }, + paths: { + '/pets': { $ref: '#/components/pathItems/Pets' }, + }, + components: { + pathItems: { + Pets: { + get: { operationId: 'listPets', summary: 'List pets', tags: ['pets'] }, + post: { operationId: 'createPet', summary: 'Create a pet', tags: ['pets'] }, + }, + }, + }, +}; + +test('extractOperations resolves OAS 3.1 components.pathItems $refs', () => { + const ops = extractOperations(PATH_ITEMS_SPEC); + assert.deepEqual([...ops.keys()].sort(), ['path:createPet', 'path:listPets']); + assert.equal(ops.get('path:listPets').tag, 'pets'); + assert.equal(hasUnresolvedOperationRefs(PATH_ITEMS_SPEC), false); +}); + +test('extractOperations resolves a path $ref to another path item', () => { + const spec = { + openapi: '3.0.0', + info: { title: 'Pets' }, + paths: { + '/pets': { + get: { operationId: 'listPets', summary: 'List pets' }, + }, + '/animals': { $ref: '#/paths/~1pets' }, + }, + }; + const ops = extractOperations(spec); + // Same operationId is reused (Map last-write); the point is the $ref is visible. + assert.equal(ops.has('path:listPets'), true); + assert.equal(ops.size, 1); +}); + +test('extractOperations uses the resolved operationId on an operation $ref', () => { + const spec = { + openapi: '3.0.0', + info: { title: 'Pets' }, + paths: { + '/pets': { + get: { $ref: '#/components/x-operations/ListPets' }, + }, + }, + components: { + 'x-operations': { + ListPets: { operationId: 'listPets', summary: 'List pets', tags: ['pets'] }, + }, + }, + }; + const ops = extractOperations(spec); + assert.equal(ops.has('path:listPets'), true); + assert.equal(ops.has('path:get_pets'), false); + assert.equal(ops.get('path:listPets').summary, 'List pets'); +}); + +test('sync does not delete pages when the spec uses path-item $refs', () => { + const root = makeRepo({ + 'reference/pets.json': JSON.stringify(PATH_ITEMS_SPEC), + 'reference/Pets/pets/listPets.md': + '---\ntitle: Custom docs\napi:\n file: pets.json\n operationId: listPets\n---\n\nCUSTOM BODY\n', + 'reference/Pets/pets/_order.yaml': '- listPets\n', + 'reference/Pets/_order.yaml': '- pets\n', + }); + try { + const [result] = syncOas(root); + assert.deepEqual(result.changes.deleted, []); + const page = path.join(root, 'reference/Pets/pets/listPets.md'); + assert.ok(fs.existsSync(page), 'existing $ref-backed page must survive sync'); + assert.match(fs.readFileSync(page, 'utf-8'), /CUSTOM BODY/); + assert.ok( + result.changes.added.some((p) => p.endsWith('createpet.md')), + `expected createpet.md in ${JSON.stringify(result.changes.added)}`, + ); + } finally { + rmRepo(root); + } +}); + +test('JSON Pointer does not follow inherited prototype keys', () => { + const spec = { + openapi: '3.0.0', + info: { title: 'Pets' }, + paths: { + '/pets': { $ref: '#/__proto__' }, + }, + }; + assert.equal(extractOperations(spec).size, 0); + assert.equal(hasUnresolvedOperationRefs(spec), true); + + const root = makeRepo({ + 'reference/pets.json': JSON.stringify(spec), + 'reference/Pets/Other/listPets.md': + '---\napi:\n file: pets.json\n operationId: listPets\n---\n\nCUSTOM BODY\n', + }); + try { + const [result] = syncOas(root); + assert.deepEqual(result.changes.deleted, []); + assert.match( + fs.readFileSync(path.join(root, 'reference/Pets/Other/listPets.md'), 'utf-8'), + /CUSTOM BODY/, + ); + } finally { + rmRepo(root); + } +}); + +test('chained $ref to an external file is unresolved and does not delete pages', () => { + const spec = { + openapi: '3.0.0', + info: { title: 'Pets' }, + paths: { + '/pets': { $ref: '#/components/pathItems/Pets' }, + }, + components: { + pathItems: { + Pets: { $ref: './paths/pets.yaml' }, + }, + }, + }; + assert.equal(hasUnresolvedOperationRefs(spec), true); + assert.equal(extractOperations(spec).size, 0); + + const root = makeRepo({ + 'reference/pets.json': JSON.stringify(spec), + 'reference/Pets/Other/listPets.md': + '---\napi:\n file: pets.json\n operationId: listPets\n---\n\nCUSTOM BODY\n', + }); + try { + const [result] = syncOas(root); + assert.deepEqual(result.changes.deleted, []); + assert.match( + fs.readFileSync(path.join(root, 'reference/Pets/Other/listPets.md'), 'utf-8'), + /CUSTOM BODY/, + ); + } finally { + rmRepo(root); + } +}); + +test('cyclic path-item $ref is unresolved and does not delete pages', () => { + const spec = { + openapi: '3.0.0', + info: { title: 'Pets' }, + paths: { + '/pets': { $ref: '#/components/pathItems/A' }, + }, + components: { + pathItems: { + A: { $ref: '#/components/pathItems/B' }, + B: { $ref: '#/components/pathItems/A' }, + }, + }, + }; + assert.equal(hasUnresolvedOperationRefs(spec), true); + assert.equal(extractOperations(spec).size, 0); + + const root = makeRepo({ + 'reference/pets.json': JSON.stringify(spec), + 'reference/Pets/Other/listPets.md': + '---\napi:\n file: pets.json\n operationId: listPets\n---\n\nCUSTOM BODY\n', + }); + try { + const [result] = syncOas(root); + assert.deepEqual(result.changes.deleted, []); + assert.ok(fs.existsSync(path.join(root, 'reference/Pets/Other/listPets.md'))); + } finally { + rmRepo(root); + } +}); + +test('OAS 3.1 path-item $ref keeps sibling operations', () => { + const spec = { + openapi: '3.1.0', + info: { title: 'Pets', version: '1.0.0' }, + paths: { + '/pets': { + $ref: '#/components/pathItems/Pets', + post: { operationId: 'createPet', tags: ['pets'] }, + }, + }, + components: { + pathItems: { + Pets: { + get: { operationId: 'listPets', tags: ['pets'] }, + }, + }, + }, + }; + const ops = extractOperations(spec); + assert.deepEqual([...ops.keys()].sort(), ['path:createPet', 'path:listPets']); + assert.equal(hasUnresolvedOperationRefs(spec), false); + + const root = makeRepo({ + 'reference/pets.json': JSON.stringify(spec), + 'reference/Pets/pets/createPet.md': + '---\napi:\n file: pets.json\n operationId: createPet\n---\n\nSIBLING BODY\n', + 'reference/Pets/pets/_order.yaml': '- createPet\n', + 'reference/Pets/_order.yaml': '- pets\n', + }); + try { + const [result] = syncOas(root); + assert.deepEqual(result.changes.deleted, []); + assert.match( + fs.readFileSync(path.join(root, 'reference/Pets/pets/createPet.md'), 'utf-8'), + /SIBLING BODY/, + ); + assert.ok( + result.changes.added.some((p) => p.endsWith('listpets.md')), + `expected listpets.md in ${JSON.stringify(result.changes.added)}`, + ); + } finally { + rmRepo(root); + } +}); + +test('path-item $ref to a non-object keeps siblings and does not delete pages', () => { + const spec = { + openapi: '3.1.0', + info: { title: 'Pets', version: '1.0.0' }, + paths: { + '/pets': { + $ref: '#/components/examples/notAPathItem', + post: { operationId: 'createPet', tags: ['pets'] }, + }, + }, + components: { + examples: { + notAPathItem: ['not', 'an', 'object'], + }, + }, + }; + const ops = extractOperations(spec); + assert.deepEqual([...ops.keys()], ['path:createPet']); + assert.equal(hasUnresolvedOperationRefs(spec), true); + + const root = makeRepo({ + 'reference/pets.json': JSON.stringify(spec), + 'reference/Pets/pets/createPet.md': + '---\napi:\n file: pets.json\n operationId: createPet\n---\n\nSIBLING BODY\n', + }); + try { + const [result] = syncOas(root); + assert.deepEqual(result.changes.deleted, []); + assert.match( + fs.readFileSync(path.join(root, 'reference/Pets/pets/createPet.md'), 'utf-8'), + /SIBLING BODY/, + ); + } finally { + rmRepo(root); + } +}); + +test('unresolved operation $ref with operationId does not invent a stub page', () => { + const spec = { + openapi: '3.0.0', + info: { title: 'Pets' }, + paths: { + '/pets': { + get: { $ref: './ops/list.yaml', operationId: 'listPets' }, + }, + }, + }; + const ops = extractOperations(spec); + assert.equal(ops.size, 0); + assert.equal(ops.has('path:listPets'), false); + assert.equal(hasUnresolvedOperationRefs(spec), true); + + const root = makeRepo({ + 'reference/pets.json': JSON.stringify(spec), + 'reference/Pets/Other/listPets.md': + '---\napi:\n file: pets.json\n operationId: listPets\n---\n\nCUSTOM BODY\n', + }); + try { + const [result] = syncOas(root); + assert.deepEqual(result.changes.deleted, []); + assert.deepEqual( + result.changes.added.filter((p) => !p.endsWith('index.md')), + [], + ); + assert.match( + fs.readFileSync(path.join(root, 'reference/Pets/Other/listPets.md'), 'utf-8'), + /CUSTOM BODY/, + ); + } finally { + rmRepo(root); + } +}); + +test('unresolved operation $ref does not invent a synthetic operationId page', () => { + const spec = { + openapi: '3.0.0', + info: { title: 'Pets' }, + paths: { + '/pets': { + get: { $ref: './ops/list.yaml' }, + }, + }, + }; + const ops = extractOperations(spec); + assert.equal(ops.size, 0); + assert.equal(ops.has('path:get_pets'), false); + assert.equal(hasUnresolvedOperationRefs(spec), true); + + const root = makeRepo({ + 'reference/pets.json': JSON.stringify(spec), + 'reference/Pets/Other/listPets.md': + '---\napi:\n file: pets.json\n operationId: listPets\n---\n\nCUSTOM BODY\n', + }); + try { + const [result] = syncOas(root); + assert.deepEqual(result.changes.deleted, []); + assert.deepEqual(result.changes.added, []); + assert.match( + fs.readFileSync(path.join(root, 'reference/Pets/Other/listPets.md'), 'utf-8'), + /CUSTOM BODY/, + ); + } finally { + rmRepo(root); + } +}); + +test('OAS 3.1 path-item sibling overrides the referenced method', () => { + const spec = { + openapi: '3.1.0', + info: { title: 'Pets', version: '1.0.0' }, + paths: { + '/pets': { + $ref: '#/components/pathItems/Pets', + get: { operationId: 'listPetsV2', tags: ['pets'] }, + }, + }, + components: { + pathItems: { + Pets: { + get: { operationId: 'listPets', tags: ['pets'] }, + }, + }, + }, + }; + const ops = extractOperations(spec); + assert.equal(ops.has('path:listPetsV2'), true); + assert.equal(ops.has('path:listPets'), false); +}); + +test('unresolved sibling operation $ref with operationId does not replace the resolved method or delete its page', () => { + // OAS 3.1 allows sibling keywords on a Reference Object. The sibling GET + // is still an external $ref — operationId is not proof it resolved. + const spec = { + openapi: '3.1.0', + info: { title: 'Pets', version: '1.0.0' }, + paths: { + '/pets': { + $ref: '#/components/pathItems/Pets', + get: { $ref: './ops/get.yaml', operationId: 'localId', tags: ['pets'] }, + }, + }, + components: { + pathItems: { + Pets: { + get: { operationId: 'listPets', summary: 'List pets', tags: ['pets'] }, + }, + }, + }, + }; + const ops = extractOperations(spec); + assert.equal(ops.has('path:listPets'), true, 'resolved method from the path-item $ref must stay'); + assert.equal(ops.has('path:localId'), false, 'must not invent a stub for the unresolved sibling $ref'); + assert.equal(hasUnresolvedOperationRefs(spec), true); + + const root = makeRepo({ + 'reference/pets.json': JSON.stringify(spec), + 'reference/Pets/pets/listPets.md': + '---\napi:\n file: pets.json\n operationId: listPets\n---\n\nCUSTOM BODY\n', + 'reference/Pets/pets/localId.md': + '---\napi:\n file: pets.json\n operationId: localId\n---\n\nSIBLING BODY\n', + }); + try { + const [result] = syncOas(root); + assert.deepEqual(result.changes.deleted, []); + assert.equal( + result.changes.added.some((p) => p.toLowerCase().includes('localid')), + false, + `must not create a stub page: ${JSON.stringify(result.changes.added)}`, + ); + assert.match( + fs.readFileSync(path.join(root, 'reference/Pets/pets/listPets.md'), 'utf-8'), + /CUSTOM BODY/, + ); + assert.match( + fs.readFileSync(path.join(root, 'reference/Pets/pets/localId.md'), 'utf-8'), + /SIBLING BODY/, + ); + } finally { + rmRepo(root); + } +}); + +test('sync does not delete pages when a path $ref points at an external file', () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Pets' }, + paths: { + '/pets': { $ref: './paths/pets.yaml' }, + }, + }); + const root = makeRepo({ + 'reference/pets.json': spec, + 'reference/Pets/Other/listPets.md': + '---\napi:\n file: pets.json\n operationId: listPets\n---\n\nCUSTOM BODY\n', + }); + try { + assert.equal(hasUnresolvedOperationRefs(JSON.parse(spec)), true); + const [result] = syncOas(root); + assert.deepEqual(result.changes.deleted, []); + const page = path.join(root, 'reference/Pets/Other/listPets.md'); + assert.ok(fs.existsSync(page), 'unresolved $ref must not wipe existing pages'); + assert.match(fs.readFileSync(page, 'utf-8'), /CUSTOM BODY/); + } finally { + rmRepo(root); + } +}); + test('existing reference page title is not overwritten by sync', () => { const root = makeRepo({ 'reference/pets.json': SPEC,