Skip to content

Commit 6b731ff

Browse files
fix: env-mapped op reconcile, revision-only env suffix, auth override key, revision-collision guard, tests
1 parent cb8049e commit 6b731ff

6 files changed

Lines changed: 156 additions & 10 deletions

File tree

‎src/services/api-publisher.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,9 +345,27 @@ async function publishApiRevisions(
345345
return revA - revB;
346346
});
347347

348+
// The current revision is already published as the root API, so skip a
349+
// revision artifact that carries the same number — otherwise we re-create /
350+
// collide with it (e.g. the root PUT created at ;rev=N for a fresh
351+
// multi-revision API, or a stale ;rev=N folder from an older extract).
352+
const rootJson = await store.readResource(config.sourceDir, apiDescriptor);
353+
const rootRevision = (rootJson?.properties as Record<string, unknown> | undefined)?.apiRevision;
354+
const rootRevisionNumber =
355+
typeof rootRevision === 'string' && rootRevision !== '' ? Number(rootRevision) : undefined;
356+
348357
// Publish each revision in order; a failed revision must fail the API —
349358
// otherwise errors are silently swallowed and the exit code stays 0.
350359
for (const revDescriptor of sortedRevisions) {
360+
if (
361+
rootRevisionNumber !== undefined &&
362+
extractRevisionNumber(getNamePart(revDescriptor.nameParts, 0)) === rootRevisionNumber
363+
) {
364+
logger.debug(
365+
`Skipping revision ${getNamePart(revDescriptor.nameParts, 0)} — already published as the root API`
366+
);
367+
continue;
368+
}
351369
const result = await publishResource(client, store, context, revDescriptor, config);
352370
if (result.status === 'failed') {
353371
throw new Error(
@@ -525,8 +543,15 @@ async function reconcileOperationsAfterSpecImport(
525543

526544
const patchBody: Record<string, unknown> = { properties: patchProps };
527545

546+
// Artifact lookup above uses the canonical descriptor; the PATCH must target
547+
// the deployed (env-mapped) name so reconciliation hits the API that the
548+
// root create actually produced under environment mapping.
549+
const patchDescriptor = config.envMapping
550+
? mapDescriptor(descriptor, config.envMapping)
551+
: descriptor;
552+
528553
try {
529-
await client.patchResource(context, descriptor, patchBody);
554+
await client.patchResource(context, patchDescriptor, patchBody);
530555
logger.debug(`Reconciled operation "${getNamePart(descriptor.nameParts, 1)}" after spec import`);
531556
} catch (error) {
532557
logger.warn(

‎src/services/env-mapper.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -169,9 +169,11 @@ export function toCanonicalDescriptor(d: ResourceDescriptor, m: EnvMapping): Res
169169

170170
/**
171171
* Split an API name into base and ";rev=N" suffix so env affixes apply to the
172-
* base name only (deployed form is "{prefix}{base}{suffix};rev=N").
172+
* base name only (deployed form is "{prefix}{base}{suffix};rev=N"). The ;rev=N
173+
* suffix is only meaningful for API revisions, so non-API types are never split.
173174
*/
174-
function splitRevisionSuffix(name: string): { base: string; revSuffix: string } {
175+
function splitRevisionSuffix(name: string, type: ResourceType): { base: string; revSuffix: string } {
176+
if (type !== ResourceType.Api) return { base: name, revSuffix: '' };
175177
const idx = name.indexOf(';rev=');
176178
return idx === -1
177179
? { base: name, revSuffix: '' }
@@ -180,7 +182,7 @@ function splitRevisionSuffix(name: string): { base: string; revSuffix: string }
180182

181183
export function toDeployedName(name: string, type: ResourceType, m: EnvMapping): string {
182184
if (!m.appliesTo.has(type)) return name;
183-
const { base, revSuffix } = splitRevisionSuffix(name);
185+
const { base, revSuffix } = splitRevisionSuffix(name, type);
184186
return `${m.prefix}${base}${m.suffix}${revSuffix}`;
185187
}
186188

@@ -193,7 +195,7 @@ export function toCanonicalName(deployedName: string, type: ResourceType, m: Env
193195
if (!m.appliesTo.has(type)) return deployedName;
194196
if (!isInEnvNamespace(deployedName, type, m)) return undefined;
195197

196-
const { base, revSuffix } = splitRevisionSuffix(deployedName);
198+
const { base, revSuffix } = splitRevisionSuffix(deployedName, type);
197199
let name = base;
198200
if (m.prefix) name = name.slice(m.prefix.length);
199201
if (m.suffix) name = name.slice(0, name.length - m.suffix.length);
@@ -206,7 +208,7 @@ export function toCanonicalName(deployedName: string, type: ResourceType, m: Env
206208
*/
207209
export function isInEnvNamespace(deployedName: string, type: ResourceType, m: EnvMapping): boolean {
208210
if (!m.appliesTo.has(type)) return true;
209-
const { base } = splitRevisionSuffix(deployedName);
211+
const { base } = splitRevisionSuffix(deployedName, type);
210212
if (base.length < m.prefix.length + m.suffix.length) return false;
211213
return base.startsWith(m.prefix) && base.endsWith(m.suffix);
212214
}

‎src/services/resource-publisher.ts‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -394,10 +394,7 @@ export async function publishResource(
394394
if (descriptor.type === ResourceType.Api) {
395395
const apiName = getNamePart(descriptor.nameParts, 0);
396396
json = normalizeApiAuthenticationSettings(json, {
397-
preferLegacyFields: prefersLegacyAuthOverride(
398-
apiName.split(';rev=')[0] ?? apiName,
399-
config.overrides?.apis
400-
),
397+
preferLegacyFields: prefersLegacyAuthOverride(apiName, config.overrides?.apis),
401398
});
402399
if (apiName.includes(';rev=')) {
403400
const baseApiName = apiName.split(';rev=')[0];

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

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { ApimServiceContext, ResourceDescriptor } from '../../../src/models/type
1111
import { PublishConfig } from '../../../src/models/config.js';
1212
import { LogLevel } from '../../../src/lib/logger.js';
1313
import { applyOverrides } from '../../../src/services/override-merger.js';
14+
import { EnvMapping } from '../../../src/services/env-mapper.js';
1415

1516
// Mock resource-publisher so we can verify call sequence
1617
const mockPublishResource = vi.fn();
@@ -479,6 +480,31 @@ describe('api-publisher', () => {
479480
expect(client.putResource).toHaveBeenCalledTimes(1);
480481
});
481482

483+
it('skips the revision artifact whose number equals the current root revision', async () => {
484+
const client = createMockClient();
485+
const store = createMockStore([
486+
{ type: ResourceType.Api, nameParts: ['orders-api;rev=2'] },
487+
{ type: ResourceType.Api, nameParts: ['orders-api;rev=3'] },
488+
]);
489+
// Root is the current revision 3; the ;rev=3 artifact duplicates it.
490+
store.readResource.mockImplementation(async (_dir: string, d: ResourceDescriptor) => {
491+
if (d.type === ResourceType.Api && !(d.nameParts[0] ?? '').includes(';rev=')) {
492+
return { name: 'orders-api', properties: { apiRevision: '3', isCurrent: true } };
493+
}
494+
return null;
495+
});
496+
497+
const apiDescriptor: ResourceDescriptor = { type: ResourceType.Api, nameParts: ['orders-api'] };
498+
499+
await publishApi(client, store, testContext, apiDescriptor, testConfig);
500+
501+
const publishedRevs = mockPublishResource.mock.calls.map(
502+
(c: unknown[]) => (c[3] as ResourceDescriptor).nameParts[0]
503+
);
504+
expect(publishedRevs).toContain('orders-api;rev=2');
505+
expect(publishedRevs).not.toContain('orders-api;rev=3');
506+
});
507+
482508
it('should replay root API without re-importing specification after revisions', async () => {
483509
const client = createMockClient();
484510
const revisions = [{ type: ResourceType.Api, nameParts: ['orders-api;rev=2'] }];
@@ -596,6 +622,72 @@ describe('api-publisher', () => {
596622
expect(mockPublishResource.mock.calls[0][3].nameParts[0]).toBe('orders-api;rev=2');
597623
});
598624

625+
it('fails the API publish when a revision publish fails (no silent success)', async () => {
626+
const client = createMockClient();
627+
const store = createMockStore([
628+
{ type: ResourceType.Api, nameParts: ['orders-api;rev=2'] },
629+
]);
630+
// Root API publishes fine; the revision publish returns failed.
631+
mockPublishResource.mockResolvedValue({
632+
descriptor: { type: ResourceType.Api, nameParts: ['orders-api;rev=2'] },
633+
status: 'failed',
634+
action: 'noop',
635+
error: new Error('revision boom'),
636+
});
637+
638+
const apiDescriptor: ResourceDescriptor = {
639+
type: ResourceType.Api,
640+
nameParts: ['orders-api'],
641+
};
642+
643+
const result = await publishApi(client, store, testContext, apiDescriptor, testConfig);
644+
645+
expect(result.status).toBe('failed');
646+
expect(result.error?.message).toContain('orders-api;rev=2');
647+
});
648+
649+
it('reconciles operations against the env-mapped API name after spec import', async () => {
650+
const client = createMockClient();
651+
const store = createMockStore([
652+
{ type: ResourceType.ApiOperation, nameParts: ['orders-api', 'get-orders'] },
653+
]);
654+
store.readResource.mockImplementation(async (_dir: string, d: ResourceDescriptor) => {
655+
if (d.type === ResourceType.Api && !(d.nameParts[0] ?? '').includes(';rev=')) {
656+
return { name: 'orders-api', properties: {} };
657+
}
658+
if (d.type === ResourceType.ApiOperation) {
659+
return { name: 'get-orders', properties: { displayName: 'Get Orders', method: 'GET', urlTemplate: '/orders' } };
660+
}
661+
return null;
662+
});
663+
store.readContent.mockImplementation(async (_dir: string, _d: ResourceDescriptor, kind: string) => {
664+
return kind === 'specification'
665+
? { content: 'openapi: 3.0.0\ninfo:\n title: t\n version: "1"\npaths: {}\n', format: 'yaml' }
666+
: undefined;
667+
});
668+
// Execute the reconcile tasks so the PATCH target can be asserted.
669+
mockRunParallel.mockImplementation(async (tasks: Array<() => Promise<unknown>>) => {
670+
for (const t of tasks) await t();
671+
});
672+
673+
const envMapping: EnvMapping = { prefix: 'dev-', suffix: '-eu', appliesTo: new Set([ResourceType.Api]) };
674+
const config: PublishConfig = { ...testConfig, envMapping };
675+
676+
await publishApi(
677+
client, store, testContext,
678+
{ type: ResourceType.Api, nameParts: ['orders-api'] },
679+
config
680+
);
681+
682+
const opPatch = client.patchResource.mock.calls.find(
683+
(c: unknown[]) => (c[1] as ResourceDescriptor).type === ResourceType.ApiOperation
684+
);
685+
expect(opPatch).toBeDefined();
686+
// PATCH must target the affixed API name, not the canonical one.
687+
expect((opPatch![1] as ResourceDescriptor).nameParts[0]).toBe('dev-orders-api-eu');
688+
expect((opPatch![1] as ResourceDescriptor).nameParts[1]).toBe('get-orders');
689+
});
690+
599691
it('should publish API child resources in parallel', async () => {
600692
const client = createMockClient();
601693
const children = [

‎tests/unit/services/env-mapper.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,13 @@ describe('env-mapper', () => {
297297
expect(isInEnvNamespace('dev-orders-api-eu;rev=2', ResourceType.Api, mb)).toBe(true);
298298
expect(isInEnvNamespace('orders-api;rev=2', ResourceType.Api, mb)).toBe(false);
299299
});
300+
301+
it('does NOT split ;rev= for non-API types (affix applies to the whole name)', () => {
302+
const mb = bothMapping('dev-', '-eu');
303+
// ;rev= is only meaningful for API revisions; other types treat it literally.
304+
expect(toDeployedName('token;rev=2', ResourceType.NamedValue, mb)).toBe('dev-token;rev=2-eu');
305+
expect(toCanonicalName('dev-token;rev=2-eu', ResourceType.NamedValue, mb)).toBe('token;rev=2');
306+
});
300307
});
301308

302309
// ─── isInEnvNamespace ────────────────────────────────────────────────────

‎tests/unit/services/resource-publisher.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1442,5 +1442,28 @@ describe('resource-publisher', () => {
14421442
expect(prefersLegacyAuthOverride('my-api', undefined)).toBe(false);
14431443
expect(prefersLegacyAuthOverride('my-api', { 'my-api': { properties: { path: '/x' } } })).toBe(false);
14441444
});
1445+
1446+
it('matches by the exact resource key (revision names are not base-inherited)', () => {
1447+
// A revision-specific legacy override is honored only under its full key —
1448+
// this is why the caller looks up the full API name, not the stripped base.
1449+
const revisionSection = {
1450+
'my-api;rev=2': {
1451+
properties: {
1452+
authenticationSettings: { oAuth2: { authorizationServerId: 'rev-server' } },
1453+
},
1454+
},
1455+
};
1456+
expect(prefersLegacyAuthOverride('my-api;rev=2', revisionSection)).toBe(true);
1457+
1458+
// A base override must NOT leak into a revision publish (keys differ).
1459+
const baseSection = {
1460+
'my-api': {
1461+
properties: {
1462+
authenticationSettings: { oAuth2: { authorizationServerId: 'base-server' } },
1463+
},
1464+
},
1465+
};
1466+
expect(prefersLegacyAuthOverride('my-api;rev=2', baseSection)).toBe(false);
1467+
});
14451468
});
14461469
});

0 commit comments

Comments
 (0)