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
8 changes: 6 additions & 2 deletions .github/workflows/sync-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ jobs:
- name: Check for changes
id: changes
run: |
# Only count actual spec yaml changes, not summary.json or formatting
# Count substantive changes only: spec yaml, generated API/type files,
# and the operation-identity manifest (scripts/api-surface.yaml), not
# summary.json or pure formatting.
SPEC_CHANGES=$(git diff --name-only -- 'specs/raw/*.yaml' 'specs/fixed/*.yaml' | wc -l)
API_CHANGES=$(git diff --name-only -- 'src/api/*.ts' | wc -l)
TYPE_CHANGES=$(git diff --name-only -- 'src/types/generated/*.ts' | grep -v 'index.ts' | wc -l)
Expand Down Expand Up @@ -287,6 +289,8 @@ jobs:
echo "### ✅ Forced Release Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Released version: **v${{ steps.version.outputs.new_version }}**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Classification: **$CLASSIFICATION**" >> $GITHUB_STEP_SUMMARY
else
echo "### ℹ️ No Changes Detected" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
Expand All @@ -304,7 +308,7 @@ jobs:
if [[ "$CLASSIFICATION" == "breaking" ]]; then
MISSING_OPS=$(jq -r '.missingOperations[] | "- `" + (.spec | gsub("\\r|\\n"; " ")) + "`: " + .method + " " + (.path | gsub("\\r|\\n"; " ")) + " (as `" + (.name | gsub("\\r|\\n"; " ")) + "`)"' sync-report.json 2>/dev/null || echo "")

echo "### Breaking API changes detected" >> $GITHUB_STEP_SUMMARY || true
echo "### ⚠️ Breaking API changes detected" >> $GITHUB_STEP_SUMMARY || true
echo "" >> $GITHUB_STEP_SUMMARY || true
echo "Manifest operations missing upstream:" >> $GITHUB_STEP_SUMMARY || true
echo "" >> $GITHUB_STEP_SUMMARY || true
Expand Down
5 changes: 1 addition & 4 deletions scripts/fetch-specs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import * as yaml from 'yaml';
import { type ValidatedFetchedDoc, validateFetchedSpecDocs } from './lib/fetched-spec-utils.js';
import { canonicalizeSpec, stringifySpec } from './lib/spec-canonicalize.js';
import { type FetchedDoc, mergeSpecDocs } from './lib/spec-merge.js';
import { isRecord } from './lib/spec-utils.js';

const BASE_URL = 'https://developer.deere.com/devDoc/apiDetails';
const OUTPUT_DIR = join(process.cwd(), 'specs', 'raw');
Expand Down Expand Up @@ -116,10 +117,6 @@ function parseSlugDocs(slug: string, docs: ValidatedFetchedDoc[]): FetchedDoc[]
}
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

/**
* Read back the merge order mergeSpecDocs stamped onto the merged document's
* `x-source-documents` extension field (primary document first, then the
Expand Down
4 changes: 3 additions & 1 deletion scripts/generate-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { basename, join } from 'node:path';
import * as yaml from 'yaml';
import {
buildSyncReport,
displayOp,
loadApiSurface,
normalizePathPattern,
resolveMethodNames,
Expand Down Expand Up @@ -540,7 +541,8 @@ function generateApiClass(api: GeneratedApi, names: Map<string, string>): string
.map((op) => {
// resolveMethodNames keys its map by each op's raw display string
// (`METHOD /path` with real param names), NOT the normalized opKey.
const key = `${op.method.toUpperCase()} ${op.path}`;
// displayOp is the shared builder of that exact key.
const key = displayOp(op);
const methodName = names.get(key);
if (!methodName) {
throw new Error(
Expand Down
71 changes: 23 additions & 48 deletions scripts/lib/api-surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import * as yaml from 'yaml';
import { isRecord } from './spec-utils.js';

// ============================================================================
// Types
Expand All @@ -37,7 +38,7 @@ export interface ApiSurface {

/** What naming needs to know about a single spec operation. */
export interface SurfaceOp {
/** Synthesized upstream when the spec omits one; see extractOps. */
/** Synthesized when the spec omits one; see parseSpec in generate-sdk.ts. */
operationId: string;
method: 'get' | 'post' | 'put' | 'patch' | 'delete';
path: string;
Expand Down Expand Up @@ -78,16 +79,11 @@ const OP_PATTERN = /^(GET|POST|PUT|PATCH|DELETE) (\/\S*)$/;
const NAME_PATTERN = /^[a-z][a-zA-Z0-9]*$/;
/** Generated-class field names a method name must never shadow. */
const RESERVED_NAMES = new Set(['constructor', 'spec', 'client']);
const METHODS: readonly SurfaceOp['method'][] = ['get', 'post', 'put', 'patch', 'delete'];

function fail(detail: string): never {
throw new Error(`api-surface: ${detail}`);
}

function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function describeType(value: unknown): string {
if (value === null) return 'null';
if (Array.isArray(value)) return 'array';
Expand Down Expand Up @@ -139,7 +135,7 @@ export function loadApiSurface(filePath: string = DEFAULT_SURFACE_PATH): ApiSurf
}

function validateSurface(parsed: unknown, filePath: string): ApiSurface {
if (!isObject(parsed)) {
if (!isRecord(parsed)) {
fail(
`top-level value in ${filePath} must be a mapping with "version" and "specs", got ${describeType(parsed)}`
);
Expand All @@ -149,7 +145,7 @@ function validateSurface(parsed: unknown, filePath: string): ApiSurface {
`unsupported version ${JSON.stringify(parsed.version)} in ${filePath}; expected version: 1`
);
}
if (!isObject(parsed.specs)) {
if (!isRecord(parsed.specs)) {
fail(
`"specs" in ${filePath} must be a mapping of specName to entries, got ${describeType(parsed.specs)}`
);
Expand All @@ -172,7 +168,7 @@ function validateSpecEntries(specName: string, entries: unknown): SurfaceEntry[]
for (let i = 0; i < entries.length; i++) {
const entry: unknown = entries[i];
const where = `spec "${specName}" entry[${i}]`;
if (!isObject(entry)) {
if (!isRecord(entry)) {
fail(`${where} must be a mapping with "op" and "name", got ${describeType(entry)}`);
}
if (typeof entry.op !== 'string' || entry.op.length === 0) {
Expand Down Expand Up @@ -311,42 +307,6 @@ function compareEntries(a: SurfaceEntry, b: SurfaceEntry): number {
return compareStrings(a.name, b.name);
}

// ============================================================================
// Op extraction (shared by the seed script and the generator)
// ============================================================================

/**
* Walk a parsed spec's `paths` and return one SurfaceOp per operation. The
* operationId synthesis and isCollection rule replicate parseSpec in
* generate-sdk.ts exactly, so the seed script and the generator see identical
* operations. Missing / empty / malformed `paths` yield [].
*/
export function extractOps(spec: unknown): SurfaceOp[] {
const ops: SurfaceOp[] = [];
if (!isObject(spec) || !isObject(spec.paths)) return ops;

for (const [path, pathItem] of Object.entries(spec.paths)) {
if (!isObject(pathItem)) continue;
for (const method of METHODS) {
const operation = pathItem[method];
if (!operation) continue;
const rawId = isObject(operation) ? operation.operationId : undefined;
const operationId =
typeof rawId === 'string' && rawId.length > 0
? rawId
: `${method}${path.replace(/[^a-zA-Z]/g, '')}`;
ops.push({ operationId, method, path, isCollection: isCollectionEndpoint(path, method) });
}
}
return ops;
}

function isCollectionEndpoint(path: string, method: string): boolean {
if (method !== 'get') return false;
const lastSegment = path.split('/').pop() || '';
return !lastSegment.startsWith('{');
}

// ============================================================================
// Name proposal for NEW operations (deterministic, no positional counters)
// ============================================================================
Expand Down Expand Up @@ -386,7 +346,9 @@ function paramNames(path: string): string[] {

/**
* Deterministic name for a NEW operation. Verb by method (collection GETs use
* `list`), then capHump of the last non-param segment; never a bare verb.
* `list`), then capHump of the last non-param segment; never a bare verb (a
* segment that is all punctuation strips to empty under capHump and falls back
* to the `Item` noun, so the result is never just the verb).
* Tiebreak chain, first free wins: prepend preceding non-param segments
* nearest-first up to the whole path, then append `By<LastParam>` to the
* full-path candidate. If every candidate is taken, throw (a human picks a
Expand All @@ -404,7 +366,11 @@ export function proposeName(op: SurfaceOp, takenNames: ReadonlySet<string>): str
candidates.push(`${verb}Item`);
} else {
for (let i = nonParam.length - 1; i >= 0; i--) {
candidates.push(`${verb}${nonParam.slice(i).map(capHump).join('')}`);
// capHump strips a segment with no alphanumerics to '', which would leave
// a bare verb like "get". Fall back to the same "Item" noun the
// no-segment branch uses so a proposed name is never just a verb.
const suffix = nonParam.slice(i).map(capHump).join('');
candidates.push(suffix.length > 0 ? `${verb}${suffix}` : `${verb}Item`);
}
}
if (params.length > 0) {
Expand Down Expand Up @@ -432,7 +398,16 @@ function addImpliedTwin(taken: Set<string>, op: SurfaceOp, name: string): void {
}
}

function displayOp(op: SurfaceOp): string {
/**
* The raw display string for an operation: "METHOD /path" with real param
* names (e.g. "GET /organizations/{orgId}/fields") and an uppercased method.
* This is the exact key `resolveMethodNames` stores its returned `names` map
* under, so generate-sdk must build the identical string to look a name up.
* Sharing this one function keeps the resolver and the generator in lockstep
* instead of reconstructing the format inline in two places. Distinct from
* `opKey`, which normalizes param names to `{_}` for identity.
*/
export function displayOp(op: SurfaceOp): string {
return `${op.method.toUpperCase()} ${op.path}`;
}

Expand Down
5 changes: 1 addition & 4 deletions scripts/lib/fetched-spec-utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as yaml from 'yaml';
import { redactSpecContent } from './spec-redactor.js';
import { isRecord } from './spec-utils.js';

export interface ValidatedFetchedDoc {
slug: string;
Expand All @@ -9,10 +10,6 @@ export interface ValidatedFetchedDoc {
ymlContent: string;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function isOpenApiDocument(content: string): boolean {
try {
const document = yaml.parseDocument(content);
Expand Down
13 changes: 5 additions & 8 deletions scripts/lib/spec-canonicalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,7 @@
*/

import * as yaml from 'yaml';

function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
import { isRecord } from './spec-utils.js';

function compareStrings(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
Expand Down Expand Up @@ -43,7 +40,7 @@ function canonicalizeComponents(components: Record<string, unknown>): Record<str
const result: Record<string, unknown> = {};
for (const category of Object.keys(components).sort(compareStrings)) {
const value = components[category];
result[category] = isPlainObject(value) ? sortKeys(value) : value;
result[category] = isRecord(value) ? sortKeys(value) : value;
}
return result;
}
Expand All @@ -66,15 +63,15 @@ function canonicalizeComponents(components: Record<string, unknown>): Record<str
* reference with the input, unchanged.
*/
export function canonicalizeSpec(doc: unknown): unknown {
if (!isPlainObject(doc)) return doc;
if (!isRecord(doc)) return doc;

const result: Record<string, unknown> = { ...doc };

if (isPlainObject(doc.paths)) {
if (isRecord(doc.paths)) {
result.paths = sortKeys(doc.paths);
}

if (isPlainObject(doc.components)) {
if (isRecord(doc.components)) {
result.components = canonicalizeComponents(doc.components);
}

Expand Down
Loading