diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b1bc003..6c80e19 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,32 +3,152 @@ name: Deploy to GitHub Pages on: push: branches: [main] + pull_request: workflow_dispatch: permissions: contents: read pages: write id-token: write + pull-requests: write concurrency: group: "pages" cancel-in-progress: false jobs: - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - name: Install dependencies + run: npm ci + - name: Validate openapi.yaml is valid YAML + run: npm run lint:spec + - name: Run tests + run: npm test + + build: + needs: test runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - name: Install dependencies + run: npm ci + - name: Build public and full specs + run: npm run build + # dist-internal/openapi.json has every field, with x-internal markers stripped. Uploaded as a + # private Actions artifact only — never published to Pages. Requires repo access + # to download (this repo is private); hand it to Aplifisa manually. + - name: Upload full spec (private, for Aplifisa) + uses: actions/upload-artifact@v4 + with: + name: openapi-full + path: dist-internal/openapi.json + retention-days: 90 + # Single self-contained HTML file per spec: Scalar UI + spec embedded inline, no + # fetch, no other files. Open directly in a browser — nothing to serve, nothing to + # convert. (Previously rendered to PDF via Puppeteer, but printing an SPA hits page + # breaks that cut content mid-element; HTML has no pages, so nothing to cut.) + - name: Build public and full standalone HTML docs + run: | + npm run build-html -- openapi.yaml openapi-public.html + npm run build-html -- dist-internal/openapi.json openapi-full.html + - name: Upload public HTML doc + id: upload-public-html + uses: actions/upload-artifact@v4 + with: + name: openapi-public-html + path: openapi-public.html + # Same access restriction as the openapi-full JSON artifact above — internal-only + # fields included (x-internal markers stripped), never published to Pages. + - name: Upload full HTML doc (private, for Aplifisa) + id: upload-full-html + uses: actions/upload-artifact@v4 + with: + name: openapi-full-html + path: openapi-full.html + retention-days: 90 + # Deep-links straight to each artifact (.../artifacts/{id}) instead of just the run — + # still requires repo access to download (private repo), same as before. Upserts via + # the marker so repeated pushes update one comment instead of piling up a new one. + - name: Comment PR with doc links + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + env: + PUBLIC_ARTIFACT_ID: ${{ steps.upload-public-html.outputs.artifact-id }} + FULL_ARTIFACT_ID: ${{ steps.upload-full-html.outputs.artifact-id }} + with: + script: | + const marker = '' + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` + const publicUrl = `${runUrl}/artifacts/${process.env.PUBLIC_ARTIFACT_ID}` + const fullUrl = `${runUrl}/artifacts/${process.env.FULL_ARTIFACT_ID}` + const body = [ + marker, + '**API docs for this run are ready** (single self-contained HTML file each — download and open in a browser):', + `- [openapi-public-html](${publicUrl}) — public spec, what ships to the docs site`, + `- [openapi-full-html](${fullUrl}) — full spec including \`x-internal\` (Aplifisa) fields` + ].join('\n') + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + }) + const existing = comments.find((c) => c.body.includes(marker)) + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body + }) + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body + }) + } + # Everything below here is what actually goes public — remove build tooling and + # the full spec/PDFs from disk first so the Pages artifact can only ever contain + # the filtered openapi.yaml, regardless of what upload-pages-artifact does or + # doesn't exclude on its own. Only bundled on push to main — no point producing a + # Pages artifact that nothing will deploy from a PR run. + - name: Strip build tooling and internal spec before publishing + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: rm -rf node_modules dist-internal openapi-public.html openapi-full.html - name: Setup Pages + if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: actions/configure-pages@v5 - - name: Upload artifact + - name: Upload Pages artifact + if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: actions/upload-pages-artifact@v3 with: path: '.' + + deploy: + needs: build + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ebdb746 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist-internal/ diff --git a/README.md b/README.md index 7a6d6a3..e1173a1 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,59 @@ npx serve . Edit `openapi.yaml` and push to `main`. The docs will be deployed automatically. +## Internal-only fields (`x-internal`) + +Some API v1 fields only exist for a specific integration partner (e.g. Aplifisa) and are gated +server-side in the Rails app — they only appear in a response when the owner has that integration +activated (`owner.aplifisa?` in the serializers, passed down from the API v1 controllers as +`with_aplifisa_data`). This repo is public documentation, so those partner-gated fields must never +ship in the published spec (CI removes every `x-internal: true` field before deploying). Any field +that *does* ship publicly (i.e. not `x-internal`) must never name the partner integration by name in +its description — that would leak the association even though the field itself stays public. + +**Rule of thumb:** if a field is only serialized `if: -> { instance_options[:with_aplifisa_data] }` +(or the equivalent gate) in `app/serializers/api/v1/**` in the main app, tag it here: + +```yaml +some_field: + x-internal: true + type: string + description: > + Whatever it does. (Aplifisa integration only.) +``` + +Fields that are *always* returned regardless of any integration flag (even if they were added +alongside integration work) are NOT `x-internal` — document them normally, without naming any +partner. + +### How it's built + +`openapi.yaml` (committed, with `x-internal` tags visible) is the single source of truth. CI +(`.github/workflows/deploy.yml`) runs `scripts/filter-internal.js` before publishing, which produces +two artifacts from it: + +- **Public spec** — every `x-internal`-tagged property removed entirely (not hidden, not present). + This overwrites `openapi.yaml` in the ephemeral CI checkout right before the GitHub Pages upload — + the committed source file is never touched. Formatting of this generated file will differ from the + committed source (it's a machine-dumped copy); don't diff the two for style, only for content. +- **Full spec** (`dist-internal/openapi.json`) — every field present, `x-internal` markers stripped. + Uploaded as a private GitHub Actions artifact (`openapi-full`, 90-day retention) — never published + to Pages. This repo is private, so downloading it requires repo access. Whoever manages the Aplifisa + integration downloads it from the workflow run and sends it to Aplifisa directly. + +Both a CI job (`test`, runs on every push and PR) and local `npm test` run +`scripts/filter-internal.test.js`, which includes a check against the real `openapi.yaml` — so a +field with a bad tag, a missing tag, or a stray partner-name leak into a public description fails CI +before it can reach `main`. + +```bash +npm install +npm test # unit tests for the filter script, incl. a check against the real spec +npm run build # writes the filtered openapi.yaml + dist-internal/openapi.json (do this in CI, not locally — + # it overwrites your local openapi.yaml with the machine-formatted public version) +npm run lint:spec # quick YAML-validity check +``` + ## Future improvements - Auto-generate `openapi.yaml` from request specs using `rspec-openapi` diff --git a/openapi.yaml b/openapi.yaml index ac23647..7dd4834 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1868,6 +1868,70 @@ components: deductible_expense_percent: type: number description: Deductible expense percentage + counterpart_account_code: + x-internal: true + type: string + nullable: true + description: > + Accounting ledger code for this line item's category/subcategory (Aplifisa integration only — + present only when the owner has Aplifisa activated). + isp: + x-internal: true + type: boolean + description: > + Whether reverse charge (Inversión del Sujeto Pasivo) applies to this line item — always `false` + for outbound documents. (Aplifisa integration only.) + retention_key: + x-internal: true + type: string + nullable: true + description: > + IRPF retention key code configured in the owner's preferences for this item's retention percentage + (e.g. "01" for professional activities). Null when the item has no retention. + (Aplifisa integration only.) + model_190_key: + x-internal: true + type: string + nullable: true + description: > + Modelo 190 key for this item's retention, looked up by year and retention percentage. Null when the + item has no retention. (Aplifisa integration only.) + model_190_subkey: + x-internal: true + type: string + nullable: true + description: > + Modelo 190 subkey paired with `model_190_key`. Null when the item has no retention. + (Aplifisa integration only.) + tax_form: + x-internal: true + type: string + nullable: true + enum: ["00", "01", "02", "03", "05", "09", "11"] + description: > + IRPF/VAT reporting form code for this line item: `00` (non-EU import), `01` (standard), + `02` (EU goods), `03` (transport), `05` (other services), `09` (1% retention professional), + `11` (EU services). Null for tickets and disbursement items. (Aplifisa integration only.) + equalization_tax_rate: + x-internal: true + type: number + nullable: true + description: > + Equivalence surcharge (recargo de equivalencia) rate matching this item's VAT rate + (e.g. 5.2 for 21% VAT, 1.4 for 10% VAT). Null when the item has no surcharge. + (Aplifisa integration only.) + equalization_tax_amount: + x-internal: true + type: string + description: > + Equivalence surcharge amount for this line item. "0.0" when none applies. + (Aplifisa integration only.) + retention_rate: + x-internal: true + type: number + description: > + Retention percentage for this line item (e.g. 21, 15, 7). 0 when no retention applies. + (Aplifisa integration only.) ItemWritableAttributes: type: object @@ -1979,6 +2043,12 @@ components: Computed per-request from the caller's abilities (not a persistent flag); typically `true` when the contact has no associated book entries and the caller can destroy it. + account_code: + type: string + nullable: true + description: > + Accounting ledger code assigned to this contact (client or supplier account, + depending on context). ContactResource: type: object @@ -2108,6 +2178,158 @@ components: recipient_country_code: type: string nullable: true + accounting_account_code: + type: string + nullable: true + description: > + Full accounting ledger code for this document's main accounting category/subcategory. + economic_activity_id: + x-internal: true + type: integer + nullable: true + description: > + ID of the economic activity (epígrafe) associated with this document. + (Aplifisa integration only.) + document_type: + x-internal: true + type: string + nullable: true + description: | + Verifactu document type code. + + - `F1` — Invoices and simplified invoices with name, country, type of tax id type and tax id number. + - `F2` — Invoices and simplified invoices without NIF in the recipient (client) and missing the tax id type. Also, Additional Income entries always, regardless of whether the contact has a complete NIF or not. If the Additional Income is itself a credit note, the credit-note criteria below take precedence. + - `R1` — Credit notes due to an error founded in law, misapplied VAT or other errors. Also the default for expense (purchase invoice) credit notes when no motive is selected. + - `R2` — Credit notes due to a bad debt (customer does not pay). Customer does not pay after 6 months/year, company recovers VAT. + - `R3` — Credit note due to customer's bankruptcy or insolvency. + - `R4` — Rest of credit notes. + - `R5` — Simplified invoices credit notes. + contact_account_code: + x-internal: true + type: string + nullable: true + description: > + Accounting ledger code for the counterpart contact on this document (client account for + outbound documents, supplier account for inbound). Null when the document has no contact. + (Aplifisa integration only.) + account_breakdown: + x-internal: true + type: object + nullable: true + description: > + Document total broken down by accounting account code, mirroring the account selection used + for A3/Sage export (asset reclassification, non-deductible expense routing, disbursement + accounts included). Always `null` for paysheets — breakdowns are only computed for invoices, + tickets, simplified invoices and additional incomes. (Aplifisa integration only.) + properties: + base: + type: object + description: Map of accounting account code (string) to the amount booked to it (number). + additionalProperties: + type: number + vat_breakdown: + x-internal: true + type: object + nullable: true + description: > + Document's VAT groups keyed by VAT rate (e.g. "21", "10", "0"), each carrying its Verifactu + fiscal classification. Always `null` for paysheets — breakdowns are only computed for + invoices, tickets, simplified invoices and additional incomes. (Aplifisa integration only.) + additionalProperties: + type: object + properties: + tax: + type: number + description: Total VAT amount for this rate group. + base: + type: number + description: Total taxable base for this rate group. + operation_classification: + type: string + nullable: true + enum: [S1, S2, N1, N2] + description: | + Classification code + + - `S1` — Subject and not exempt — no reverse charge (inversión del sujeto pasivo). + - `S2` — Subject and not exempt — with reverse charge. + - `N1` — Not subject — Articles 7, 14, or other. + - `N2` — Not subject — localization rules. + - `null` — Exempt — see `exempt_operation_type` for the specific exemption. + exempt_operation_type: + type: string + nullable: true + enum: [E1, E2, E3, E5, E6] + description: | + Exemption code. + + - `E1` — VAT-exempt owner operating with a Spanish contact, at 0% rate. + - `E2` — 0%-rated operation with a non-EU contact. + - `E3` — Intracommunity supply of goods (not services) to an EU contact with a valid VAT-IVA number. + - `E5` — Explicit exemption reason set by the user in the "Imported by" field (e.g. diplomats, universities — see exempt_reason r1/r2/r3). + - `E6` — Exemption with no more specific reason determined. + - `null` — A classification code is present for this rate (mutually exclusive with `operation_classification`). + regime_key: + type: string + nullable: true + description: | + Regime key code. + + - `01` — General VAT regime. + - `02` — Export. + - `08` — IPSI / IGIC. + - `10` — Collections by third parties. + - `11` — Business leases — VAT subject and not exempt. + - `17` — OSS and IOSS. + - `18` — Equivalence surcharge regime. + - `19` — Agricultural regime. + - `20` — Simplified regime. + - `null` — Not determined (group has no current items). + equalization_rate: + type: number + nullable: true + description: Equivalence surcharge rate for this VAT group. Null when no item in the group has a surcharge. + equalization_amount: + type: number + description: Total equivalence surcharge amount for this VAT group. 0 when none. + rectification_type: + x-internal: true + type: string + nullable: true + enum: [S, I] + description: > + For credit notes: `S` (full substitution — same absolute total as the countered document) or + `I` (by differences). Null for non-credit-note documents. (Aplifisa integration only.) + rectification_kind: + x-internal: true + type: string + nullable: true + enum: [R1, R2, R3, R4, R5] + description: > + For credit notes: `R1`–`R5`, matching `document_type`. Null for non-credit-note documents. + (Aplifisa integration only.) + operation_description: + x-internal: true + type: string + description: > + Human-readable summary of the document's current-kind line items, formatted as + "account - concept - amount€" per item and joined with " | ", truncated to 500 characters. + Falls back to "Operación" when there are no current-kind items. (Aplifisa integration only.) + retention_rates: + x-internal: true + type: array + description: > + Retention breakdown across this document's line items, grouped by retention rate. + (Aplifisa integration only.) + items: + type: object + properties: + rate: + type: number + description: Retention percentage (e.g. 15.0). + amount: + type: number + description: Total amount withheld at this rate across the document's items. BookEntryResource: type: object @@ -2546,6 +2768,8 @@ components: type: string company_ss_amount: type: string + retention_amount: + type: string PaysheetResource: type: object @@ -2709,6 +2933,11 @@ components: type: string kind: type: string + accounting_digits_number: + type: integer + nullable: true + description: > + Number of digits used in this owner's chart-of-accounts codes (e.g. 8). AccountingCategoryResource: type: object diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8aaaeea --- /dev/null +++ b/package-lock.json @@ -0,0 +1,45 @@ +{ + "name": "api-v1-docs", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "api-v1-docs", + "version": "1.0.0", + "devDependencies": { + "js-yaml": "^4.1.0" + } + }, + "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==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "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.js" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..42223fa --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "api-v1-docs", + "private": true, + "version": "1.0.0", + "description": "Build tooling for the Quipu API v1 public documentation (Scalar)", + "type": "module", + "scripts": { + "test": "node --test scripts/", + "build": "node scripts/filter-internal.js", + "lint:spec": "node -e \"require('js-yaml').load(require('fs').readFileSync('openapi.yaml', 'utf8')); console.log('openapi.yaml: valid YAML')\"", + "build-html": "node scripts/build-standalone-html.js" + }, + "devDependencies": { + "js-yaml": "^4.1.0" + } +} diff --git a/scripts/build-standalone-html.js b/scripts/build-standalone-html.js new file mode 100644 index 0000000..e004f10 --- /dev/null +++ b/scripts/build-standalone-html.js @@ -0,0 +1,79 @@ +// Bundles a single self-contained HTML file: Scalar UI + the OpenAPI spec +// embedded inline (no fetch, no other files). Open it directly in any +// browser — nothing to serve, nothing to convert. +// +// Replaces the earlier Puppeteer/PDF pipeline: printing an SPA to PDF hits +// page-break pagination that cuts content mid-element. HTML has no pages, so +// there's nothing to cut. +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import yaml from 'js-yaml' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const ROOT = path.resolve(__dirname, '..') + +function loadSpecAsObject(specPath) { + const raw = fs.readFileSync(specPath, 'utf8') + return specPath.endsWith('.json') ? JSON.parse(raw) : yaml.load(raw) +} + +function main() { + const [, , specPath, outputPath] = process.argv + if (!specPath || !outputPath) { + console.error('Usage: node scripts/build-standalone-html.js ') + process.exit(1) + } + + const spec = loadSpecAsObject(path.resolve(specPath)) + const specJson = JSON.stringify(spec).replace(/" inside the embedded JSON + const logoBase64 = fs.readFileSync(path.join(ROOT, 'logo.png')).toString('base64') + + const html = ` + + + ${spec.info?.title ?? 'API Reference'} + + + + + + + + + + +` + fs.writeFileSync(path.resolve(outputPath), html) + console.log(`Wrote ${outputPath}`) +} + +main() diff --git a/scripts/filter-internal.js b/scripts/filter-internal.js new file mode 100644 index 0000000..d87d354 --- /dev/null +++ b/scripts/filter-internal.js @@ -0,0 +1,107 @@ +// Splits openapi.yaml (the source of truth, which may contain fields tagged +// `x-internal: true`) into two artifacts: +// +// - a PUBLIC spec with every `x-internal` property removed entirely +// (published to GitHub Pages) +// - a FULL spec with every field intact, only the `x-internal` marker +// stripped (uploaded as a private CI artifact, e.g. for Aplifisa) +// +// See ../README.md for the full pipeline and the reasoning behind it. + +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import yaml from 'js-yaml' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const ROOT = path.resolve(__dirname, '..') + +// Recursively removes any `properties` entry whose schema is tagged +// `x-internal: true`, and drops its name from a sibling `required` array +// if present. Mutates and returns `node`. +export function removeInternalProperties(node) { + if (Array.isArray(node)) { + node.forEach(removeInternalProperties) + return node + } + + if (node === null || typeof node !== 'object') { + return node + } + + if (node.properties && typeof node.properties === 'object') { + const removedKeys = [] + + for (const [key, schema] of Object.entries(node.properties)) { + if (schema && typeof schema === 'object' && schema['x-internal'] === true) { + delete node.properties[key] + removedKeys.push(key) + } + } + + if (Array.isArray(node.required) && removedKeys.length > 0) { + node.required = node.required.filter((key) => !removedKeys.includes(key)) + } + } + + for (const value of Object.values(node)) { + removeInternalProperties(value) + } + + return node +} + +// Recursively deletes the `x-internal` marker key itself, leaving the +// field it was attached to untouched. Mutates and returns `node`. +export function stripInternalMarkers(node) { + if (Array.isArray(node)) { + node.forEach(stripInternalMarkers) + return node + } + + if (node === null || typeof node !== 'object') { + return node + } + + delete node['x-internal'] + + for (const value of Object.values(node)) { + stripInternalMarkers(value) + } + + return node +} + +export function buildSpecs(sourceYaml) { + const source = yaml.load(sourceYaml) + + const full = stripInternalMarkers(structuredClone(source)) + const filtered = removeInternalProperties(structuredClone(source)) + stripInternalMarkers(filtered) // belt-and-braces: no stray markers should survive on kept fields + + return { full, filtered } +} + +function main() { + const sourcePath = path.join(ROOT, 'openapi.yaml') + const sourceYaml = fs.readFileSync(sourcePath, 'utf8') + + const { full, filtered } = buildSpecs(sourceYaml) + + // Public artifact: overwrites openapi.yaml in place. This only ever runs in the + // CI checkout (ephemeral) right before the Pages upload — the committed source + // file with the x-internal tags is never touched. + fs.writeFileSync(sourcePath, yaml.dump(filtered, { lineWidth: -1 })) + + // Full artifact: JSON, for the private "openapi-full" CI artifact. + const fullOutDir = path.join(ROOT, 'dist-internal') + fs.mkdirSync(fullOutDir, { recursive: true }) + fs.writeFileSync(path.join(fullOutDir, 'openapi.json'), JSON.stringify(full, null, 2)) + + console.log(`Wrote filtered ${sourcePath}`) + console.log(`Wrote ${path.join(fullOutDir, 'openapi.json')}`) +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main() +} diff --git a/scripts/filter-internal.test.js b/scripts/filter-internal.test.js new file mode 100644 index 0000000..0a771dc --- /dev/null +++ b/scripts/filter-internal.test.js @@ -0,0 +1,195 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import yaml from 'js-yaml' +import { removeInternalProperties, stripInternalMarkers, buildSpecs } from './filter-internal.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +test('removeInternalProperties drops only x-internal properties', () => { + const doc = { + type: 'object', + properties: { + public_field: { type: 'string' }, + secret_field: { type: 'string', 'x-internal': true } + } + } + + removeInternalProperties(doc) + + assert.deepEqual(Object.keys(doc.properties), ['public_field']) +}) + +test('removeInternalProperties cleans up the required array', () => { + const doc = { + type: 'object', + required: ['public_field', 'secret_field'], + properties: { + public_field: { type: 'string' }, + secret_field: { type: 'string', 'x-internal': true } + } + } + + removeInternalProperties(doc) + + assert.deepEqual(doc.required, ['public_field']) +}) + +test('removeInternalProperties recurses into nested schemas (allOf, items, additionalProperties)', () => { + const doc = { + components: { + schemas: { + Widget: { + allOf: [ + { + type: 'object', + properties: { + internal_nested: { type: 'string', 'x-internal': true }, + kept_nested: { type: 'string' } + } + } + ] + }, + WidgetList: { + type: 'array', + items: { + type: 'object', + properties: { + internal_in_item: { type: 'string', 'x-internal': true } + } + } + }, + WidgetMap: { + type: 'object', + additionalProperties: { + type: 'object', + properties: { + internal_in_map: { type: 'string', 'x-internal': true }, + kept_in_map: { type: 'number' } + } + } + } + } + } + } + + removeInternalProperties(doc) + + const { schemas } = doc.components + assert.deepEqual(Object.keys(schemas.Widget.allOf[0].properties), ['kept_nested']) + assert.deepEqual(Object.keys(schemas.WidgetList.items.properties), []) + assert.deepEqual(Object.keys(schemas.WidgetMap.additionalProperties.properties), ['kept_in_map']) +}) + +test('removeInternalProperties leaves a doc with no internal fields unchanged', () => { + const doc = { + properties: { + a: { type: 'string' }, + b: { type: 'number' } + } + } + const before = structuredClone(doc) + + removeInternalProperties(doc) + + assert.deepEqual(doc, before) +}) + +test('stripInternalMarkers removes the marker but keeps the field', () => { + const doc = { + properties: { + secret_field: { type: 'string', 'x-internal': true, description: 'shh' } + } + } + + stripInternalMarkers(doc) + + assert.deepEqual(doc.properties.secret_field, { type: 'string', description: 'shh' }) +}) + +test('buildSpecs: filtered spec has no x-internal fields and no "x-internal" strings left anywhere', () => { + const source = yaml.dump({ + components: { + schemas: { + Thing: { + properties: { + open: { type: 'string', description: 'visible to everyone' }, + hidden: { type: 'string', 'x-internal': true, description: 'Vendor integration only.' } + } + } + } + } + }) + + const { filtered, full } = buildSpecs(source) + + const filteredProps = filtered.components.schemas.Thing.properties + assert.deepEqual(Object.keys(filteredProps), ['open']) + assert.equal(JSON.stringify(filtered).includes('x-internal'), false) + + // full keeps both fields, marker stripped + const fullProps = full.components.schemas.Thing.properties + assert.deepEqual(Object.keys(fullProps).sort(), ['hidden', 'open']) + assert.equal(JSON.stringify(full).includes('x-internal'), false) +}) + +// Finds every (schemaName, propertyName) pair tagged x-internal directly under +// components.schemas.*.properties in the ORIGINAL doc (field names can repeat +// across unrelated schemas, e.g. "document_type" exists on both ContactAttributes +// (public, unrelated) and BookEntryAttributes (internal) — so checks must be +// scoped per schema, never a whole-document substring search). +function findTaggedFields(sourceDoc) { + const pairs = [] + for (const [schemaName, schema] of Object.entries(sourceDoc.components.schemas)) { + if (!schema || typeof schema !== 'object' || !schema.properties) continue + for (const [propName, propSchema] of Object.entries(schema.properties)) { + if (propSchema && propSchema['x-internal'] === true) { + pairs.push({ schemaName, propName }) + } + } + } + return pairs +} + +test('the real openapi.yaml: every x-internal field disappears from the filtered spec, survives in the full spec', () => { + const sourcePath = path.join(__dirname, '..', 'openapi.yaml') + const sourceYaml = fs.readFileSync(sourcePath, 'utf8') + const sourceDoc = yaml.load(sourceYaml) + + const tagged = findTaggedFields(sourceDoc) + assert.ok(tagged.length > 0, 'expected at least one x-internal field in openapi.yaml') + + const { filtered, full } = buildSpecs(sourceYaml) + + for (const { schemaName, propName } of tagged) { + assert.equal( + Object.prototype.hasOwnProperty.call(filtered.components.schemas[schemaName].properties, propName), + false, + `${schemaName}.${propName} leaked into the public spec` + ) + assert.ok( + Object.prototype.hasOwnProperty.call(full.components.schemas[schemaName].properties, propName), + `${schemaName}.${propName} missing from the full spec` + ) + } + + // No non-internal field should have been caught in the crossfire. + assert.equal(Object.keys(filtered.components.schemas.ItemAttributes.properties).includes('concept'), true) + assert.equal(Object.keys(filtered.components.schemas.ContactAttributes.properties).includes('account_code'), true) + assert.equal(Object.keys(filtered.components.schemas.ContactAttributes.properties).includes('document_type'), true) + assert.equal( + Object.keys(filtered.components.schemas.AccountingCategoryAttributes.properties).includes('accounting_digits_number'), + true + ) +}) + +test('the real openapi.yaml: no "Aplifisa" mention survives in the public spec', () => { + const sourcePath = path.join(__dirname, '..', 'openapi.yaml') + const sourceYaml = fs.readFileSync(sourcePath, 'utf8') + + const { filtered } = buildSpecs(sourceYaml) + + assert.equal(JSON.stringify(filtered).toLowerCase().includes('aplifisa'), false) +})