Skip to content

Commit f5d2cd2

Browse files
committed
Adding support for specification updates when in incremental mode.
1 parent c8ae8e0 commit f5d2cd2

6 files changed

Lines changed: 284 additions & 35 deletions

File tree

src/lib/resource-path.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ const ASSOCIATION_TYPES = new Set<ResourceType>([
2020
ResourceType.GatewayApi,
2121
]);
2222

23+
const SUPPORTED_SPECIFICATION_EXTENSIONS = new Set([
24+
'yaml',
25+
'yml',
26+
'json',
27+
'graphql',
28+
'wsdl',
29+
'wadl',
30+
]);
31+
2332
/**
2433
* Fills all positional `{i}` tokens in a template string with `nameParts[i]`.
2534
* Throws if a placeholder index has no corresponding entry in `nameParts`.
@@ -385,6 +394,79 @@ export function parseArtifactPath(
385394
return undefined;
386395
}
387396

397+
/**
398+
* Parse a changed artifact file path into a ResourceDescriptor.
399+
*
400+
* This extends `parseArtifactPath` with support for supplemental artifact
401+
* files that belong to a resource but are not the primary info file.
402+
*
403+
* Currently supports:
404+
* - API specification files (`apis/{api}/specification.{ext}`)
405+
* - Workspace-scoped API specification files
406+
* (`workspaces/{workspace}/apis/{api}/specification.{ext}`)
407+
*/
408+
export function parseArtifactChangePath(
409+
baseDir: string,
410+
filePath: string
411+
): ResourceDescriptor | undefined {
412+
const directDescriptor = parseArtifactPath(baseDir, filePath);
413+
if (directDescriptor) {
414+
return directDescriptor;
415+
}
416+
417+
const relativePath = toTemplatePath(path.relative(baseDir, filePath));
418+
419+
const rootApiSpec = parseTemplatePath('apis/{0}/specification.{1}', relativePath);
420+
if (rootApiSpec && rootApiSpec.length === 2) {
421+
const apiName = getCapture(rootApiSpec, 0);
422+
const extension = getCapture(rootApiSpec, 1);
423+
if (apiName && isSupportedSpecificationExtension(extension)) {
424+
return {
425+
type: ResourceType.Api,
426+
nameParts: [apiName],
427+
};
428+
}
429+
}
430+
431+
const workspaceApiSpec = parseTemplatePath(
432+
'workspaces/{0}/apis/{1}/specification.{2}',
433+
relativePath
434+
);
435+
if (workspaceApiSpec && workspaceApiSpec.length === 3) {
436+
const workspace = getCapture(workspaceApiSpec, 0);
437+
const apiName = getCapture(workspaceApiSpec, 1);
438+
const extension = getCapture(workspaceApiSpec, 2);
439+
if (workspace && apiName && isSupportedSpecificationExtension(extension)) {
440+
return {
441+
type: ResourceType.Api,
442+
nameParts: [apiName],
443+
workspace,
444+
};
445+
}
446+
}
447+
448+
return undefined;
449+
}
450+
451+
function toTemplatePath(relativePath: string): string {
452+
return relativePath.split(path.sep).join('/');
453+
}
454+
455+
function getCapture(captures: string[], index: number): string | undefined {
456+
if (captures.length <= index) {
457+
return undefined;
458+
}
459+
return captures[index];
460+
}
461+
462+
function isSupportedSpecificationExtension(extension: string | undefined): boolean {
463+
if (!extension) {
464+
return false;
465+
}
466+
467+
return SUPPORTED_SPECIFICATION_EXTENSIONS.has(extension.toLowerCase());
468+
}
469+
388470
/**
389471
* Check if a resource type is a singleton (no list, only get).
390472
* Singletons have armPathSuffix ending with a fixed segment (no `{n}` placeholder).

src/services/api-publisher.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -255,23 +255,34 @@ async function publishApiChildren(
255255
// schemaId/typeName references in representations. Re-include operations that
256256
// carry such references so those links are explicitly restored via PUT.
257257
if (specImported) {
258+
// In incremental mode, avoid re-publishing operation artifacts after spec
259+
// import. This prevents stale operation JSON from overwriting newly
260+
// imported OpenAPI operation metadata (for example descriptions).
261+
const shouldRepublishSchemaRefOps = !config.commitId;
262+
258263
const operationDescriptors = allDescriptors.filter(
259264
(d) =>
260265
d.type === ResourceType.ApiOperation &&
261266
getNamePart(d.nameParts, 0) === getNamePart(apiDescriptor.nameParts, 0)
262267
);
263268

264-
const schemaRefOps = await filterOperationsWithSchemaRefs(
265-
store,
266-
config.sourceDir,
267-
operationDescriptors
268-
);
269+
if (shouldRepublishSchemaRefOps) {
270+
const schemaRefOps = await filterOperationsWithSchemaRefs(
271+
store,
272+
config.sourceDir,
273+
operationDescriptors
274+
);
269275

270-
if (schemaRefOps.length > 0) {
276+
if (schemaRefOps.length > 0) {
277+
logger.debug(
278+
`Re-publishing ${schemaRefOps.length} operation(s) with schema references after spec import for "${getNamePart(apiDescriptor.nameParts, 0)}"`
279+
);
280+
childDescriptors = [...childDescriptors, ...schemaRefOps];
281+
}
282+
} else {
271283
logger.debug(
272-
`Re-publishing ${schemaRefOps.length} operation(s) with schema references after spec import for "${getNamePart(apiDescriptor.nameParts, 0)}"`
284+
`Skipping schema-reference operation re-publish for "${getNamePart(apiDescriptor.nameParts, 0)}" in incremental mode`
273285
);
274-
childDescriptors = [...childDescriptors, ...schemaRefOps];
275286
}
276287

277288
// Re-include explicitly named schemas (non-auto-generated IDs).

src/services/git-diff-service.ts

Lines changed: 33 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import { simpleGit, SimpleGit } from 'simple-git';
88
import * as path from 'node:path';
99
import { ResourceDescriptor } from '../models/types.js';
10-
import { parseArtifactPath } from '../lib/resource-path.js';
10+
import { parseArtifactChangePath } from '../lib/resource-path.js';
1111
import { logger } from '../lib/logger.js';
1212

1313
export interface GitDiffResult {
@@ -61,7 +61,7 @@ export async function computeGitDiff(
6161
// Get diff between parent and current commit
6262
// If no parent, diff against empty tree (shows all files as added)
6363
const diffTarget = hasParent ? parentCommit : '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; // Git empty tree SHA
64-
const diffOutput = await git.diff(['--name-status', diffTarget, commitId]);
64+
const diffOutput = await git.diff(['--name-status', '--relative', diffTarget, commitId]);
6565

6666
return parseDiffOutput(diffOutput, sourceDir);
6767
} catch (error) {
@@ -105,12 +105,7 @@ function parseDiffOutput(diffOutput: string, sourceDir: string): GitDiffResult {
105105
continue;
106106
}
107107

108-
// Convert relative path to absolute for parsing
109-
const absolutePath = path.isAbsolute(filePath)
110-
? filePath
111-
: path.join(sourceDir, filePath);
112-
113-
const descriptor = parseArtifactPath(sourceDir, absolutePath);
108+
const descriptor = parseDescriptorFromDiffPath(sourceDir, filePath);
114109
if (!descriptor) {
115110
continue;
116111
}
@@ -119,30 +114,16 @@ function parseDiffOutput(diffOutput: string, sourceDir: string): GitDiffResult {
119114
const key = descriptorKey(descriptor);
120115

121116
if (status === 'D') {
122-
// Deleted file
123-
if (!seenDeleted.has(key)) {
124-
deletedDescriptors.push(descriptor);
125-
seenDeleted.add(key);
126-
}
117+
addUniqueDescriptor(deletedDescriptors, seenDeleted, descriptor, key);
127118
} else if (status === 'M' || status === 'A' || status === 'R' || status === 'C') {
128-
// Modified, added, renamed, or copied file
129-
if (!seenChanged.has(key)) {
130-
changedDescriptors.push(descriptor);
131-
seenChanged.add(key);
132-
}
119+
addUniqueDescriptor(changedDescriptors, seenChanged, descriptor, key);
133120

134121
// For renames, also parse the new path (in parts[2])
135-
if (status === 'R' && parts[2]) {
136-
const newPath = path.isAbsolute(parts[2])
137-
? parts[2]
138-
: path.join(sourceDir, parts[2]);
139-
const newDescriptor = parseArtifactPath(sourceDir, newPath);
122+
if (status === 'R' && parts.length > 2 && parts[2]) {
123+
const newDescriptor = parseDescriptorFromDiffPath(sourceDir, parts[2]);
140124
if (newDescriptor) {
141125
const newKey = descriptorKey(newDescriptor);
142-
if (!seenChanged.has(newKey)) {
143-
changedDescriptors.push(newDescriptor);
144-
seenChanged.add(newKey);
145-
}
126+
addUniqueDescriptor(changedDescriptors, seenChanged, newDescriptor, newKey);
146127
}
147128
}
148129
}
@@ -161,3 +142,28 @@ function parseDiffOutput(diffOutput: string, sourceDir: string): GitDiffResult {
161142
function descriptorKey(descriptor: ResourceDescriptor): string {
162143
return [descriptor.type, ...descriptor.nameParts, descriptor.workspace ?? ''].join('::');
163144
}
145+
146+
function addUniqueDescriptor(
147+
target: ResourceDescriptor[],
148+
seen: Set<string>,
149+
descriptor: ResourceDescriptor,
150+
key: string
151+
): void {
152+
if (seen.has(key)) {
153+
return;
154+
}
155+
156+
target.push(descriptor);
157+
seen.add(key);
158+
}
159+
160+
function parseDescriptorFromDiffPath(
161+
sourceDir: string,
162+
diffPath: string
163+
): ResourceDescriptor | undefined {
164+
const absolutePath = path.isAbsolute(diffPath)
165+
? diffPath
166+
: path.join(sourceDir, diffPath);
167+
168+
return parseArtifactChangePath(sourceDir, absolutePath);
169+
}

tests/unit/lib/resource-path.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
buildSpecificationFilePath,
1414
buildAssociationFilePath,
1515
parseArtifactPath,
16+
parseArtifactChangePath,
1617
deriveListPaths,
1718
hasNestedParent,
1819
getPublishTier,
@@ -323,6 +324,51 @@ describe('parseArtifactPath', () => {
323324
});
324325
});
325326

327+
describe('parseArtifactChangePath', () => {
328+
it('should parse regular info files via parseArtifactPath fallback', () => {
329+
const filePath = path.join(baseDir, 'apis', 'my-api', 'apiInformation.json');
330+
const result = parseArtifactChangePath(baseDir, filePath);
331+
332+
expect(result).toBeDefined();
333+
expect(result!.type).toBe(ResourceType.Api);
334+
expect(result!.nameParts).toEqual(['my-api']);
335+
});
336+
337+
it('should parse API specification file to Api descriptor', () => {
338+
const filePath = path.join(baseDir, 'apis', 'my-api', 'specification.yaml');
339+
const result = parseArtifactChangePath(baseDir, filePath);
340+
341+
expect(result).toBeDefined();
342+
expect(result!.type).toBe(ResourceType.Api);
343+
expect(result!.nameParts).toEqual(['my-api']);
344+
expect(result!.workspace).toBeUndefined();
345+
});
346+
347+
it('should parse workspace-scoped API specification file', () => {
348+
const filePath = path.join(
349+
baseDir,
350+
'workspaces',
351+
'dev',
352+
'apis',
353+
'my-api',
354+
'specification.json'
355+
);
356+
const result = parseArtifactChangePath(baseDir, filePath);
357+
358+
expect(result).toBeDefined();
359+
expect(result!.type).toBe(ResourceType.Api);
360+
expect(result!.nameParts).toEqual(['my-api']);
361+
expect(result!.workspace).toBe('dev');
362+
});
363+
364+
it('should ignore unsupported specification extensions', () => {
365+
const filePath = path.join(baseDir, 'apis', 'my-api', 'specification.txt');
366+
const result = parseArtifactChangePath(baseDir, filePath);
367+
368+
expect(result).toBeUndefined();
369+
});
370+
});
371+
326372
describe('buildArtifactFilePath + parseArtifactPath roundtrip', () => {
327373
const topLevelTypes: { type: ResourceType; nameParts: string[] }[] = [
328374
{ type: ResourceType.NamedValue, nameParts: ['nv1'] },

tests/unit/services/api-publisher.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,6 +766,59 @@ describe('api-publisher', () => {
766766
expect(totalTasks).toBe(2);
767767
});
768768

769+
it('should not re-publish schema-reference operations in incremental mode after spec import', async () => {
770+
const client = createMockClient();
771+
const children = [
772+
{ type: ResourceType.ApiPolicy, nameParts: ['petstore', 'policy-1'] },
773+
{ type: ResourceType.ApiOperation, nameParts: ['petstore', 'create-item'] },
774+
];
775+
const store = createMockStore(children);
776+
777+
store.readResource.mockImplementation(async (_dir: string, descriptor: ResourceDescriptor) => {
778+
if (descriptor.type === ResourceType.Api) {
779+
return { name: 'petstore', properties: { path: 'petstore' } };
780+
}
781+
if (
782+
descriptor.type === ResourceType.ApiOperation &&
783+
(descriptor.nameParts[1] ?? '') === 'create-item'
784+
) {
785+
return {
786+
name: 'create-item',
787+
properties: {
788+
request: {
789+
representations: [{ contentType: 'application/json', schemaId: 'my-schema' }],
790+
},
791+
},
792+
};
793+
}
794+
return null;
795+
});
796+
store.readContent.mockResolvedValue({
797+
content: 'openapi: "3.0.0"',
798+
format: 'yaml',
799+
});
800+
801+
const apiDescriptor: ResourceDescriptor = {
802+
type: ResourceType.Api,
803+
nameParts: ['petstore'],
804+
};
805+
806+
const incrementalConfig: PublishConfig = {
807+
...testConfig,
808+
commitId: 'abc123',
809+
};
810+
811+
await publishApi(client, store, testContext, apiDescriptor, incrementalConfig);
812+
813+
// Only ApiPolicy should be published as child. The schema-ref operation
814+
// must be skipped in incremental mode to preserve imported spec metadata.
815+
const totalTasks = mockRunParallel.mock.calls.reduce((sum, call) => {
816+
const tasks = call[0] as unknown[];
817+
return sum + tasks.length;
818+
}, 0);
819+
expect(totalTasks).toBe(1);
820+
});
821+
769822
it('should re-publish operations with schema references in response representations', async () => {
770823
const client = createMockClient();
771824
const children = [

0 commit comments

Comments
 (0)