Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/api-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 23 additions & 28 deletions .github/workflows/bump-postman.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }})

Expand All @@ -112,7 +108,6 @@ jobs:
base: main
add-paths: |
vendor/postman
scripts/php-sdk-examples.generated.json
labels: |
automated
api-docs
16 changes: 16 additions & 0 deletions scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
16 changes: 11 additions & 5 deletions scripts/generate-api-docs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -93,6 +94,7 @@ const PHP_SDK_EXAMPLES_PATH = path.join(
);
let phpSdkExamples = {};
const usedPhpSdkExampleIds = new Set();
const missingPhpSdkExampleIds = new Set();

// ---------------------------------------------------------------------------
// Entry
Expand All @@ -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())) {
Expand Down Expand Up @@ -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.');
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion scripts/sdk-emitters.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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' &&
Expand Down
21 changes: 20 additions & 1 deletion scripts/sdk-emitters.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand All @@ -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',
Expand Down
14 changes: 8 additions & 6 deletions scripts/sync-php-sdk-examples.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand All @@ -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);
Expand All @@ -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;
Expand Down
25 changes: 20 additions & 5 deletions scripts/sync-php-sdk-examples.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<?php' };
const catalog = (examples) => ({ 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']) {
Expand All @@ -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);
Expand Down
Loading