{"title":"Review an AI-Generated OpenAPI Description Before You Publish It","excerpt":"Structural validity and truthfulness about a service are unrelated properties: a generated description parses, resolves and lints while inventing a DELETE endpoint and quietly making the refund route public. This guide starts with the security override that removes authentication in two characters of YAML, reconciles operations against a route inventory taken from the booted router, and shows why the specification's SHOULD on examples means nothing is obliged to reject an invented field.","canonicalUrl":"https://automationtester.in/blog/ai-in-testing/review-ai-generated-openapi-specifications","category":{"name":"AI in Testing","slug":"ai-in-testing"},"tags":["api-contracts","openapi","ai-generated-code","api-security","code-review","json-schema"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-09-17T13:30:00.000Z","dateModified":"2026-09-07T11:52:05.894Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v3/review-ai-generated-openapi-specifications.webp","alt":"Dark technical illustration reading left to right across six rows. A green panel on the left holds five stacked pills, four green and one orange, with the bottom row left empty. A matching panel on the right also holds five pills, four green and one orange, but its empty row is the fifth rather than the sixth. Three rows connect the two panels with green arrows passing through small dashed rectangular gates, each marked with a green dot. A fourth row crosses through a gate drawn only as two dashed orange horizontal lines with no sides, and its connecting arrows are orange rather than green. From the orange pill on the left a dashed orange curve falls away and ends at a crossed-out circle in the lower middle of the frame. From the orange pill on the right a second dashed orange curve reaches back toward the left panel and stops at a small orange dot in open space, connected to nothing."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> Validate the document, then stop trusting it. Diff the operation list against a route inventory taken from the running router, because a generated description invents endpoints and omits real ones with equal confidence. Read every operation-level <code>security</code> field before anything else: an empty array removes the global requirement entirely, while an array containing an empty object only makes it optional, and the two look almost identical in a diff. Validate every example against its own schema yourself — the specification holds examples to SHOULD, so a conforming validator is not obliged to reject one full of invented fields.</div>\n\n<p>A generated OpenAPI file arrives clean. The linter passes. The document parses, the dialect resolves, every <code>$ref</code> points somewhere real, and the rendered documentation looks like the API. It is also describing a <code>DELETE /orders/{orderId}</code> that does not exist, and it has quietly made the refund endpoint public.</p>\n\n<p>Neither of those is a syntax error. Both survive every structural check that runs in CI, because structural validity and truthfulness about a service are unrelated properties. The document is checked against the specification; nothing checks it against the code.</p>\n\n<h2 id=\"empty-array-versus-empty-object\">An empty array and an empty object are opposite security decisions</h2>\n\n<p>Start here, before route reconciliation and before schemas, because this is the single highest-consequence thing a generated description gets wrong and the one least likely to be caught by reading.</p>\n\n<p>A description usually declares a global requirement, and the specification is explicit about what an operation can do to it. Of the root <code>security</code> field: \"Individual operations can override this definition. The list can be incomplete, up to being empty or absent.\" Of the operation-level field: \"This definition overrides any declared top-level security. To remove a top-level security declaration, an empty array can be used.\"</p>\n\n<p>So an operation carrying <code>security: []</code> is not inheriting the global requirement, and it is not tightening it. It is unauthenticated.</p>\n\n<pre class=\"language-yaml\"><code class=\"language-yaml\">security:\n  - oauth2: [orders.read]\n\npaths:\n  /orders/{orderId}/refund:\n    post:\n      operationId: refundOrder\n      # Removes the global requirement. This endpoint is public.\n      security: []\n\n  /orders/{orderId}/receipt:\n    get:\n      operationId: getOrderReceipt\n      # Authentication is OPTIONAL: satisfied by oauth2, and also by nothing.\n      security:\n        - oauth2: [orders.read]\n        - {}\n\n  /orders/{orderId}:\n    delete:\n      operationId: deleteOrder\n      # Genuinely tightened: a different scope is now required.\n      security:\n        - oauth2: [orders.admin]\n</code></pre>\n\n<p>The specification describes the middle case separately: \"To make security optional, an empty security requirement (<code>{}</code>) can be included in the array.\" Three operations, three different authorization postures, distinguished by two characters of YAML. In a pull-request diff they occupy the same amount of space and read as the same kind of change.</p>\n\n<p>Models produce all three. A model asked to describe a public health endpoint learns <code>security: []</code> as a shape, and will reuse that shape on an operation it has decided is \"read-only\" or \"internal\". It has no access to the authorization policy and no way to know that the refund endpoint is the one that moves money.</p>\n\n<p>The review step is mechanical and worth automating, because reading for it does not work — the eye slides over <code>[]</code>.</p>\n\n<pre class=\"language-javascript\"><code class=\"language-javascript\">import { readFileSync } from \"node:fs\";\nimport { parse } from \"yaml\";\n\nconst doc = parse(readFileSync(\"openapi.yaml\", \"utf8\"));\nconst globalRequirement = doc.security ?? [];\nconst METHODS = [\"get\", \"put\", \"post\", \"delete\", \"options\", \"head\", \"patch\", \"trace\"];\n\nfor (const [path, item] of Object.entries(doc.paths ?? {})) {\n  for (const method of METHODS) {\n    const op = item[method];\n    if (!op || !(\"security\" in op)) continue; // inherits the global requirement\n\n    const declared = op.security;\n    const posture =\n      declared.length === 0\n        ? \"UNAUTHENTICATED (global requirement removed)\"\n        : declared.some((r) =&gt; Object.keys(r).length === 0)\n          ? \"OPTIONAL (an empty requirement is present)\"\n          : \"overridden\";\n\n    console.log(`${method.toUpperCase()} ${path} -&gt; ${posture}`);\n  }\n}\n\nif (globalRequirement.length === 0) {\n  console.log(\"NOTE: no global security; every operation is open by default.\");\n}\n</code></pre>\n\n<p>Fail the build on the first two postures unless the operation is on a written allowlist. The allowlist is the point: it forces someone to name each public endpoint deliberately, which is a different act from a model emitting two characters.</p>\n\n<h2 id=\"reconcile-against-the-router\">Reconcile against the router, not against the description</h2>\n\n<p>The second question is whether the operations correspond to code. A generated description drifts in both directions at once, and the two drifts have different consequences.</p>\n\n<p>An invented operation is the visible failure. A consumer generates a client from it, calls the endpoint, and gets a 404 from a route that was never implemented. It is embarrassing but it surfaces immediately.</p>\n\n<p>A missing operation is worse and quieter. An endpoint that exists and is absent from the description is outside every contract test, every mock server and every compatibility gate that reads the file. It is a live, unversioned, untested surface, and nothing in the pipeline reports it, because every tool downstream treats the description as the definition of the API.</p>\n\n<p>So take the inventory from the router at runtime rather than from a source scan, which misses dynamically mounted routes:</p>\n\n<pre class=\"language-javascript\"><code class=\"language-javascript\">// Express 4: walk the router stack of the app you actually boot.\nfunction routeInventory(app) {\n  const found = new Set();\n  for (const layer of app._router.stack) {\n    if (!layer.route) continue;\n    // Express \":param\" -&gt; OpenAPI \"{param}\"\n    const path = layer.route.path.replace(/:([A-Za-z0-9_]+)/g, \"{$1}\");\n    for (const [method, enabled] of Object.entries(layer.route.methods)) {\n      if (enabled) found.add(`${method.toUpperCase()} ${path}`);\n    }\n  }\n  return found;\n}\n\nconst described = new Set();\nfor (const [path, item] of Object.entries(doc.paths ?? {})) {\n  for (const method of METHODS) {\n    if (item[method]) described.add(`${method.toUpperCase()} ${path}`);\n  }\n}\n\nconst implemented = routeInventory(app);\nconst invented = [...described].filter((r) =&gt; !implemented.has(r));\nconst undocumented = [...implemented].filter((r) =&gt; !described.has(r));\n</code></pre>\n\n<p>Report both lists and fail on both. The reconciliation is also where parameter names get checked: matching <code>GET /orders/{id}</code> against <code>GET /orders/{orderId}</code> is a mismatch worth surfacing rather than normalising away, because a generated client will use the name from the description in its method signatures.</p>\n\n<p>Two spellings of the same path are a specification error rather than a style question. Of path templating, the specification states that given <code>/pets/{petId}</code> and <code>/pets/{name}</code>, \"the following paths are considered identical and invalid\". It also warns that <code>/{entity}/me</code> alongside <code>/books/{id}</code> \"may lead to ambiguous resolution\", and that \"in case of ambiguous matching, it's up to the tooling to decide which one to use\" — meaning two generators can resolve the same document differently and both be conforming. A model producing a large paths object from separate prompts is exactly the process that emits both spellings.</p>\n\n<p>While walking operations, check <code>operationId</code> too. It \"MUST be unique among all operations described in the API\", and \"the operationId value is case-sensitive\", so <code>getOrder</code> and <code>getorder</code> are two valid identifiers that will collide in most generated clients regardless.</p>\n\n<h2 id=\"examples-are-only-a-should\">Examples are held to SHOULD, so nothing is obliged to reject an invented field</h2>\n\n<p>This is the trap that survives a strict pipeline, and it is worth stating precisely because the usual folklore is wrong.</p>\n\n<p>Of the mutually exclusive <code>example</code> and <code>examples</code> fields in the Parameter and Media Type Objects, the specification says they \"SHOULD both match the schema and be formatted as they would appear as a serialized parameter or within a media type representation\". SHOULD, not MUST. An example carrying a field the schema does not permit is not a specification violation, so a conforming validator may accept the document in full — and most do.</p>\n\n<p>That matters because examples are the part of the description humans actually read. They are rendered at the top of the documentation, they become the fixture a frontend developer codes against, and they are frequently the seed for a mock server. An invented <code>loyaltyTier</code> in an example propagates into a client, a mock and a test before anyone consults the schema.</p>\n\n<p>There are also four places an example can live, with different meanings, and a generated document scatters them:</p>\n\n<ul>\n<li><code>example</code> and <code>examples</code> on the Parameter or Media Type Object, which are mutually exclusive and show the serialized form.</li>\n<li><code>examples</code> on the Schema Object, which is the JSON Schema array and, in the specification's words, \"the preferred way to include examples in the Schema Object\".</li>\n<li><code>example</code> on the Schema Object, \"retained purely for compatibility with older versions of the OpenAPI Specification\".</li>\n</ul>\n\n<p>The precedence is stated: because the Parameter and Media Type fields represent the final serialized form, \"they SHALL override any example in the corresponding Schema Object\". So a correct schema-level example and a wrong media-type example do not average out. The wrong one wins in the rendered docs.</p>\n\n<p>Validate them yourself, against the schema in the same document:</p>\n\n<pre class=\"language-javascript\"><code class=\"language-javascript\">import Ajv2020 from \"ajv/dist/2020.js\";\n\nconst ajv = new Ajv2020({ strict: false, allErrors: true });\n\nfunction checkExamples(mediaType, location) {\n  if (!mediaType?.schema) return;\n  const validate = ajv.compile(mediaType.schema);\n\n  const candidates =\n    \"example\" in mediaType\n      ? [[\"example\", mediaType.example]]\n      : Object.entries(mediaType.examples ?? {}).map(([k, v]) =&gt; [k, v.value]);\n\n  for (const [name, value] of candidates) {\n    if (validate(value)) continue;\n    for (const err of validate.errors ?? []) {\n      console.log(`${location} example \"${name}\": ${err.instancePath || \"/\"} ${err.message}`);\n    }\n  }\n}\n</code></pre>\n\n<p>Run it with <code>additionalProperties: false</code> asserted on the object schemas you own. Without that, an invented field is not an error to Ajv either — JSON Schema permits unknown properties unless the schema forbids them, so the invented <code>loyaltyTier</code> validates cleanly against a schema that simply never mentions it. That default is why \"we validate our examples\" is often true and worth nothing.</p>\n\n<h2 id=\"what-a-clean-review-still-misses\">What a clean review still misses</h2>\n\n<p>Three failures survive everything above, and each needs a different check rather than more of the same one.</p>\n\n<h3 id=\"plausible-domain-rules\">The schema is structurally right and semantically invented</h3>\n\n<p>A generated <code>status</code> enum lists <code>PENDING</code>, <code>ACTIVE</code>, <code>CANCELLED</code>, <code>REFUNDED</code>. The service emits <code>PARTIALLY_REFUNDED</code> as well. The enum is well-formed, and every example the model wrote validates against it, because the model wrote both. Nothing internal to the document can catch this: it is consistent with itself and wrong about the world. The only detection is replaying recorded production responses against the schema, which turns the missing value into a validation failure with a real payload attached.</p>\n\n<h3 id=\"compatibility-direction\">The diff is real but its direction is unlabelled</h3>\n\n<p>A regenerated description differs from the published one in ninety places. Most are cosmetic — reordered properties, reworded descriptions. A few are a narrowed enum or a newly required request field, which are breaking for existing consumers. Reviewing the raw diff reliably produces fatigue and a rubber stamp by the fortieth hunk. Classify mechanically instead: compare the previous published artefact against the candidate and label each change additive, breaking, or cosmetic, then require a human decision only on the breaking set.</p>\n\n<h3 id=\"required-and-nullable\">Optionality is asserted rather than observed</h3>\n\n<p>Models are consistently over-confident about <code>required</code> and about nullability, in both directions: fields that are always present get omitted from <code>required</code>, and fields that are frequently null get marked required and non-nullable. Both pass validation against examples the same model produced. Derive this from data rather than from the description — over a sample of real responses, a field present in every one is a <code>required</code> candidate, and a field null in any is not non-nullable, whatever the document claims.</p>\n\n<p>One rule covers all three: no property of a generated description should be accepted because the description asserts it. Path parameters are the exception worth knowing, because there the specification does the work for you — for a parameter whose location is <code>path</code>, <code>required</code> \"is REQUIRED and its value MUST be true\", so a path parameter marked optional is a hard error rather than a judgement call.</p>\n\n<h2 id=\"questions-after-a-generated-description-fails-review\">Questions that come up the first time a generated description fails review</h2>\n\n<h3 id=\"faq-regenerate-or-edit\">The description is generated on every build. Should reviewers edit it?</h3>\n<p>No — edits are lost on the next generation. Put the corrections in the source the generator reads: route annotations, schema definitions, or a small overlay document merged after generation. Treat the generated file as build output and review it as you would review a lockfile diff, mechanically and by class of change.</p>\n\n<h3 id=\"faq-single-source\">Can the implementation be generated from the description instead, so they cannot drift?</h3>\n<p>It removes the reconciliation problem and replaces it with a different one: the description becomes the thing nobody reads critically, because it is now upstream and therefore assumed correct. The security and domain-rule checks above still apply, and they matter more, since a mistake now propagates into the server rather than only into the documentation.</p>\n\n<h3 id=\"faq-which-checks-block\">Which of these checks should block a merge rather than warn?</h3>\n<p>Block on the mechanical ones with no judgement in them: an operation that removes or optionalises global security without an allowlist entry, an invented or undocumented route, a duplicate <code>operationId</code>, two path templates that differ only by parameter name, and a path parameter not marked required. Warn on the ones needing a human read — enum changes, <code>required</code> changes, and example mismatches — because those have legitimate cases and a blocking check with legitimate exceptions gets disabled within a month.</p>\n\n<h3 id=\"faq-old-versions\">Does any of this change for OpenAPI 3.0 documents?</h3>\n<p>The security semantics are the same, and the reconciliation and example checks apply unchanged. The dialect handling differs: 3.1 aligns the Schema Object with JSON Schema 2020-12, so validate 3.1 schemas with a 2020-12 validator rather than the 3.0-flavoured subset. Ajv needs its 2020 build for that, which is the <code>ajv/dist/2020.js</code> entry point used above.</p>\n\n<h2 id=\"primary-references\">Primary references</h2>\n<ul>\n<li><a href=\"https://spec.openapis.org/oas/v3.1.1.html\">OpenAPI Specification 3.1.1</a> — the normative source for the security override semantics, path templating validity, <code>operationId</code> uniqueness, the path-parameter <code>required</code> rule, and the SHOULD applied to examples.</li>\n<li><a href=\"https://spec.openapis.org/oas/3.1/dialect/2024-11-10.html\">OpenAPI 3.1 JSON Schema dialect</a> — the vocabulary set a 3.1 Schema Object is validated under, which is what a 2020-12 validator has to be configured for.</li>\n<li><a href=\"https://json-schema.org/specification\">JSON Schema specification</a> — establishes that unknown properties are permitted unless a schema forbids them, which is why example validation is weak without <code>additionalProperties: false</code>.</li>\n<li><a href=\"https://owasp.org/API-Security/editions/2023/en/0x11-t10/\">OWASP API Security Top 10 (2023)</a> — Broken Object Level Authorization and Broken Function Level Authorization are the categories an accidental <code>security: []</code> lands in.</li>\n</ul>\n\n<h2 id=\"apply-this-now\">Apply this now</h2>\n\n<p>Run the security posture script over your current published description before changing anything else. It takes a few minutes and it answers a question you probably cannot answer from memory: which operations have overridden the global requirement, and which of those are unauthenticated rather than merely different. If that list contains anything you did not expect, you have found the highest-value bug in this article without writing a test.</p>\n\n<p>Then add two blocking checks to the pipeline — the security posture check with an explicit allowlist, and route reconciliation against the booted router — and keep the example and enum checks as warnings until you have seen a month of their output. The evidence worth retaining from each run is the reconciliation report itself: the two lists of invented and undocumented routes, dated, so drift between the description and the service is a trend you can look at rather than a surprise you discover during an incident.</p>\n\n<p>Our <a href=\"/tools/json-schema\">JSON Schema Generator</a> is useful for turning a handful of recorded production responses into the schema you compare the generated one against, and <a href=\"/tools/json-diff\">JSON Diff</a> for separating the additive changes from the breaking ones in a regenerated description.</p>\n"}