diff --git a/.github/workflows/api-docs.yml b/.github/workflows/api-docs.yml index 2605e582..21ef5f9d 100644 --- a/.github/workflows/api-docs.yml +++ b/.github/workflows/api-docs.yml @@ -28,7 +28,7 @@ jobs: with: node-version: 20 cache: pnpm - - name: Verify complete PHP catalog before postinstall + - name: Validate supplied SDK examples before postinstall run: node scripts/sync-php-sdk-examples.mjs - run: pnpm install --frozen-lockfile - run: node --test scripts/sdk-emitters.test.mjs scripts/sync-php-sdk-examples.test.mjs diff --git a/.github/workflows/bump-postman.yml b/.github/workflows/bump-postman.yml index ddd79395..36015bb4 100644 --- a/.github/workflows/bump-postman.yml +++ b/.github/workflows/bump-postman.yml @@ -12,9 +12,6 @@ on: repository_dispatch: types: [postman-updated] workflow_dispatch: - schedule: - # Retry after an SDK contract update is merged, even without a Postman push. - - cron: '17 5 * * *' concurrency: group: bump-postman @@ -47,57 +44,56 @@ jobs: new_sha=$(git rev-parse HEAD) cd "$GITHUB_WORKSPACE" - echo "old_sha=$old_sha" >> "$GITHUB_OUTPUT" - echo "new_sha=$new_sha" >> "$GITHUB_OUTPUT" - echo "old_short=${old_sha:0:7}" >> "$GITHUB_OUTPUT" - echo "new_short=${new_sha:0:7}" >> "$GITHUB_OUTPUT" - - echo "Postman: $old_sha → $new_sha; checking the PHP catalog as well." + { + echo "old_sha=$old_sha" + echo "new_sha=$new_sha" + echo "old_short=${old_sha:0:7}" + echo "new_short=${new_sha:0:7}" + if [ "$old_sha" = "$new_sha" ]; then + echo "no_change=true" + else + echo "no_change=false" + fi + } >> "$GITHUB_OUTPUT" + echo "Postman: $old_sha → $new_sha" + + - name: Stop early if nothing changed + if: steps.bump.outputs.no_change == 'true' + run: echo "Postman is already current; skipping installation, generation, and PR creation." - name: Setup pnpm + if: steps.bump.outputs.no_change != 'true' uses: pnpm/action-setup@v3 with: version: 10 - name: Setup Node.js + if: steps.bump.outputs.no_change != 'true' uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - - name: Checkout the reviewed PHP SDK catalog - uses: actions/checkout@v4 - with: - repository: fleetbase/fleetbase-php - ref: main - path: .php-sdk-source - persist-credentials: false - sparse-checkout: contracts - - - name: Synchronize PHP examples before postinstall generates docs - id: sdk - run: | - echo "sha=$(git -C .php-sdk-source rev-parse HEAD)" >> "$GITHUB_OUTPUT" - node scripts/sync-php-sdk-examples.mjs --source .php-sdk-source/contracts/php-sdk-examples.json - - name: Install dependencies + if: steps.bump.outputs.no_change != 'true' run: pnpm install --frozen-lockfile - name: Regenerate API docs + if: steps.bump.outputs.no_change != 'true' run: | node --test scripts/sdk-emitters.test.mjs scripts/sync-php-sdk-examples.test.mjs pnpm generate:api-docs - name: Create Pull Request + if: steps.bump.outputs.no_change != 'true' uses: peter-evans/create-pull-request@v6 with: token: ${{ secrets.GITHUB_TOKEN }} commit-message: "chore: bump postman submodule (${{ steps.bump.outputs.old_short }} → ${{ steps.bump.outputs.new_short }})" title: "chore: bump postman submodule" body: | - Automated bump of the `vendor/postman` submodule and matching PHP SDK examples. - - PHP SDK catalog source: `fleetbase/fleetbase-php@${{ steps.sdk.outputs.sha }}`. + Automated bump of the `vendor/postman` submodule for the API reference. + SDK example coverage is independent and does not block new API documentation. **Diff:** [`${{ steps.bump.outputs.old_short }}...${{ steps.bump.outputs.new_short }}`](https://github.com/fleetbase/postman/compare/${{ steps.bump.outputs.old_sha }}...${{ steps.bump.outputs.new_sha }}) @@ -112,7 +108,6 @@ jobs: base: main add-paths: | vendor/postman - scripts/php-sdk-examples.generated.json labels: | automated api-docs diff --git a/scripts/README.md b/scripts/README.md index 18e92a39..cdd3a749 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -200,3 +200,19 @@ Only for **new kinds** of behavior, not new instances: - Postman invents a new YAML shape → adapt the parser in `loadRequests` / `loadExamples` Day-to-day collection changes (resources added, examples updated, descriptions reworded) need zero code changes — just bump the submodule. + +### SDK examples are independent of the API reference + +`Bump Postman Submodule` only updates Postman and verifies documentation generation. When its SHA is unchanged, installation, generation, and PR creation are skipped. It does not check out an SDK or wait for an SDK release. + +Missing PHP catalog entries omit the PHP sample for that endpoint; stale entries are not rendered. Supplied malformed examples still fail validation. Unknown JavaScript SDK stores use raw `fetch` examples rather than guessed SDK methods. Other languages and the HTTP reference remain available regardless of one SDK's coverage. + +Refresh examples separately after the corresponding SDK version is published: + +```sh +node scripts/sync-php-sdk-examples.mjs --source /path/to/released-sdk/contracts/php-sdk-examples.json +pnpm test:sdk-emitters +pnpm generate:api-docs +``` + +Review and commit that catalog change independently. The inspections documentation can ship with the existing published PHP catalog; its PHP samples become available when the released inspection-capable SDK catalog is synchronized. diff --git a/scripts/generate-api-docs.mjs b/scripts/generate-api-docs.mjs index 79ae0317..dd7cde83 100644 --- a/scripts/generate-api-docs.mjs +++ b/scripts/generate-api-docs.mjs @@ -30,6 +30,7 @@ import { createHighlighter } from 'shiki'; import YAML from 'yaml'; import { apis as apisConfig, defaultConfig } from './api-docs.config.mjs'; +import { validateCatalog } from './sync-php-sdk-examples.mjs'; import { classifyEndpoint, emitCurl, @@ -93,6 +94,7 @@ const PHP_SDK_EXAMPLES_PATH = path.join( ); let phpSdkExamples = {}; const usedPhpSdkExampleIds = new Set(); +const missingPhpSdkExampleIds = new Set(); // --------------------------------------------------------------------------- // Entry @@ -102,6 +104,7 @@ async function main() { const phpSdkCatalog = JSON.parse( await fs.readFile(PHP_SDK_EXAMPLES_PATH, 'utf8'), ); + validateCatalog(phpSdkCatalog, []); phpSdkExamples = phpSdkCatalog.examples ?? {}; if (!(await collectionsAvailable())) { @@ -152,9 +155,10 @@ async function main() { const catalogIds = Object.keys(phpSdkExamples); if (usedPhpSdkExampleIds.size !== catalogIds.length) { const unused = catalogIds.filter((id) => !usedPhpSdkExampleIds.has(id)); - throw new Error( - `PHP SDK catalog coverage is ${usedPhpSdkExampleIds.size}/${catalogIds.length}; unused IDs: ${unused.join(', ')}`, - ); + console.warn(`PHP SDK catalog has ${unused.length} unused entries; these are not rendered.`); + } + if (missingPhpSdkExampleIds.size) { + console.warn(`PHP SDK examples unavailable for ${missingPhpSdkExampleIds.size} endpoints; API reference generated without those SDK samples: ${[...missingPhpSdkExampleIds].join(', ')}`); } console.log('\n✅ API docs generated.'); @@ -295,9 +299,10 @@ async function buildEndpointSection( const sdkExample = phpSdkExamples[sdkExampleId]; if (sdkConfig?.php) { if (!sdkExample) { - throw new Error(`No PHP SDK example is mapped for ${sdkExampleId}.`); + missingPhpSdkExampleIds.add(sdkExampleId); + } else { + usedPhpSdkExampleIds.add(sdkExampleId); } - usedPhpSdkExampleIds.add(sdkExampleId); } const rawSamples = { @@ -413,6 +418,7 @@ async function buildEndpointSection( async function highlightSampleSet(samples) { const out = {}; for (const [lang, code] of Object.entries(samples)) { + if (!code) continue; out[lang] = { code, html: await highlightCode(code, lang) }; } return out; diff --git a/scripts/sdk-emitters.mjs b/scripts/sdk-emitters.mjs index 3b2f3a2a..66c0f505 100644 --- a/scripts/sdk-emitters.mjs +++ b/scripts/sdk-emitters.mjs @@ -104,7 +104,8 @@ export function emitJs({ const js = sdkConfig?.js; if (!js) return emitJsRaw({ method, fullUrl, body }); - const store = js.stores?.[resourceFolder] ?? camelCase(resourceFolder); + const store = js.stores?.[resourceFolder]; + if (!store) return emitJsRaw({ method, fullUrl, body }); const isOrderAction = endpointKind === 'custom-action' && resourceFolder === 'Orders'; const sdkMethod = orderActionMethod(js, endpointAction); @@ -237,6 +238,8 @@ export function emitPhp({ }) { const php = sdkConfig?.php; if (!php) return emitPhpRaw({ method, fullUrl, body }); + // Do not invent SDK methods for APIs added ahead of this SDK's catalog. + if (!sdkExample) return null; if ( typeof sdkExample?.code === 'string' && diff --git a/scripts/sdk-emitters.test.mjs b/scripts/sdk-emitters.test.mjs index 6aaa171e..97fc3550 100644 --- a/scripts/sdk-emitters.test.mjs +++ b/scripts/sdk-emitters.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; -import { emitPhp } from './sdk-emitters.mjs'; +import { emitJs, emitPhp } from './sdk-emitters.mjs'; const catalog = JSON.parse( await readFile(new URL('./php-sdk-examples.generated.json', import.meta.url)), @@ -18,6 +18,25 @@ const sdkConfig = { }, }; +test('new API endpoints do not invent PHP SDK methods when no example exists', () => { + for (const endpointKind of ['create', 'find', 'query', 'custom-action']) { + assert.equal(emitPhp({ + method: 'GET', fullUrl: 'https://api.fleetbase.io/v1/inspection-forms', + endpointKind, resourceFolder: 'Inspections', sdkConfig, + }), null); + } +}); + +test('unknown JavaScript SDK resources use raw HTTP rather than guessed stores', () => { + const code = emitJs({ + method: 'GET', fullUrl: 'https://api.fleetbase.io/v1/inspection-forms', + endpointKind: 'query', resourceFolder: 'Inspections', + sdkConfig: { js: { pkg: '@fleetbase/sdk', client: 'fleetbase', stores: {} } }, + }); + assert.match(code, /fetch\(/); + assert.doesNotMatch(code, /fleetbase\.inspections|import Fleetbase/); +}); + test('uses concise PHP SDK calls for canonical CRUD endpoints', () => { const code = emitPhp({ method: 'POST', diff --git a/scripts/sync-php-sdk-examples.mjs b/scripts/sync-php-sdk-examples.mjs index 7bf0baa9..de873830 100644 --- a/scripts/sync-php-sdk-examples.mjs +++ b/scripts/sync-php-sdk-examples.mjs @@ -25,7 +25,7 @@ export async function requestIds(collectionsDirectory) { return ids.sort(); } -export function validateCatalog(catalog, expectedIds) { +export function validateCatalog(catalog, expectedIds, { requireComplete = false } = {}) { if (catalog?.schema_version !== 1 || catalog?.package !== 'fleetbase/fleetbase-php' || !catalog.examples || typeof catalog.examples !== 'object' || Array.isArray(catalog.examples)) { throw new Error('Invalid Fleetbase PHP SDK example catalog.'); @@ -38,20 +38,21 @@ export function validateCatalog(catalog, expectedIds) { const entry = catalog.examples[id]; return !entry || !['implementation', 'call', 'code'].every((key) => typeof entry[key] === 'string' && entry[key].trim()); }); - if (missing.length || stale.length || invalid.length) { + if (invalid.length || (requireComplete && (missing.length || stale.length))) { throw new Error( `PHP SDK catalog does not match Postman. Missing: ${missing.join(', ') || 'none'}. ` + `Stale: ${stale.join(', ') || 'none'}. Invalid: ${invalid.join(', ') || 'none'}. ` + - 'Merge the matching fleetbase/fleetbase-php contract update, then rerun Bump Postman Submodule.', + 'Correct invalid examples; SDK coverage gaps must not block API reference updates.', ); } - return actual.length; + return actual.filter((id) => expected.has(id)).length; } export async function syncCatalog({ source, destination, collectionsDirectory }) { const contents = await readFile(source, 'utf8'); const count = validateCatalog(JSON.parse(contents), await requestIds(collectionsDirectory)); - // Validate the complete candidate before replacing the checked-in catalog. + // Validate supplied examples before replacing the checked-in catalog. + // SDKs evolve independently of Postman; partial coverage is allowed. if (path.resolve(source) !== path.resolve(destination)) { const temporary = `${destination}.${process.pid}.tmp`; await writeFile(temporary, contents); @@ -72,7 +73,8 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me destination, collectionsDirectory: values.collections ?? path.join(root, 'vendor/postman/postman/collections'), }); - console.log(`PHP SDK catalog verified: ${count}/${count} Postman request IDs.`); + const ids = await requestIds(values.collections ?? path.join(root, 'vendor/postman/postman/collections')); + console.log(`PHP SDK catalog verified: ${count}/${ids.length} current Postman requests have examples. Missing examples do not block API docs.`); } catch (error) { console.error(error.message); process.exitCode = 1; diff --git a/scripts/sync-php-sdk-examples.test.mjs b/scripts/sync-php-sdk-examples.test.mjs index 54b114b5..d1b3b71b 100644 --- a/scripts/sync-php-sdk-examples.test.mjs +++ b/scripts/sync-php-sdk-examples.test.mjs @@ -3,19 +3,32 @@ import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; +import YAML from 'yaml'; import { requestIds, syncCatalog, validateCatalog } from './sync-php-sdk-examples.mjs'; const example = { implementation: 'Fleetbase\\Sdk\\Services\\TrailerService::createTrailer', call: '$fleetbase->trailers->createTrailer([]);', code: ' ({ schema_version: 1, package: 'fleetbase/fleetbase-php', examples }); -test('requires complete, non-stale, executable SDK mappings', () => { +test('Postman bump remains SDK-independent and skips expensive work when unchanged', async () => { + const workflow = YAML.parse(await readFile(new URL('../.github/workflows/bump-postman.yml', import.meta.url), 'utf8')); + assert.equal(workflow.on.schedule, undefined); + const steps = workflow.jobs.bump.steps; + assert.ok(steps.some((step) => step.name === 'Stop early if nothing changed')); + assert.equal(steps.some((step) => step.with?.repository === 'fleetbase/fleetbase-php'), false); + for (const name of ['Setup pnpm', 'Setup Node.js', 'Install dependencies', 'Regenerate API docs', 'Create Pull Request']) { + assert.equal(steps.find((step) => step.name === name)?.if, "steps.bump.outputs.no_change != 'true'"); + } +}); + +test('accepts independent SDK coverage but rejects malformed supplied mappings', () => { assert.equal(validateCatalog(catalog({ trailer: example }), ['trailer']), 1); - assert.throws(() => validateCatalog(catalog({ old: example }), ['trailer']), /Missing: trailer.*Stale: old/); + assert.equal(validateCatalog(catalog({ old: example }), ['trailer']), 0); + assert.throws(() => validateCatalog(catalog({ old: example }), ['trailer'], { requireComplete: true }), /Missing: trailer.*Stale: old/); assert.throws(() => validateCatalog(catalog({ trailer: {} }), ['trailer']), /Invalid: trailer/); assert.throws(() => validateCatalog({}, []), /Invalid Fleetbase/); }); -test('sync uses stable IDs from both PHP collections and never overwrites with an incomplete catalog', async () => { +test('sync uses stable IDs and never overwrites with malformed examples', async () => { const directory = await mkdtemp(path.join(os.tmpdir(), 'php-catalog-test-')); try { for (const group of ['Fleetbase API/Trailers', 'Fleetbase Core API/Organizations', 'Fleetbase API/Trailers/.resources']) { @@ -30,9 +43,11 @@ test('sync uses stable IDs from both PHP collections and never overwrites with a const source = path.join(directory, 'source.json'); const destination = path.join(directory, 'destination.json'); await writeFile(destination, 'existing catalog'); - await writeFile(source, JSON.stringify(catalog({}))); - await assert.rejects(syncCatalog({ source, destination, collectionsDirectory: directory }), /Missing:/); + await writeFile(source, JSON.stringify(catalog({ [ids[0]]: {} }))); + await assert.rejects(syncCatalog({ source, destination, collectionsDirectory: directory }), /Invalid:/); assert.equal(await readFile(destination, 'utf8'), 'existing catalog'); + await writeFile(source, JSON.stringify(catalog({ [ids[0]]: example }))); + assert.equal(await syncCatalog({ source, destination, collectionsDirectory: directory }), 1); const contents = JSON.stringify(catalog(Object.fromEntries(ids.map((id) => [id, example])))); await writeFile(source, contents); assert.equal(await syncCatalog({ source, destination, collectionsDirectory: directory }), 2); diff --git a/vendor/postman b/vendor/postman index 7fe06b7c..d6a7cc53 160000 --- a/vendor/postman +++ b/vendor/postman @@ -1 +1 @@ -Subproject commit 7fe06b7c5e415a9aa292869e9037910896b049ad +Subproject commit d6a7cc530fa27d7cf19a0d3ef8594cf4e51a76bb