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
3 changes: 2 additions & 1 deletion docs/guides/environment-overrides.md
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,8 @@ workspaces:

The supported workspace child sections are: `apis`, `backends`, `diagnostics`, `groups`, `loggers`, `namedValues`, `policyFragments`, `products`, `subscriptions`, `tags`, and `versionSets`.

Workspace-scoped resources are resolved only against the sections nested under their workspace entry (including nested API sub-resources such as `diagnostics`, `operations`, `policies`, and `releases`). Top-level sections such as `apis` or `backends` apply only to service-level resources, never to resources inside a workspace.
Comment thread
Copilot marked this conversation as resolved.
Resolved

## Override rules

### Names must be consistent
Expand Down Expand Up @@ -581,4 +583,3 @@ apiops publish --overrides configuration.prod.yaml --dry-run \
- [Authentication Guide](authentication.md)
- [Scenarios and Workflows](scenarios-and-workflows.md)
- [GitHub Actions Integration](../ci-cd/github-actions.md)

2 changes: 2 additions & 0 deletions src/services/override-merger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ export function hasNamedValueOverride(
* Apply environment overrides from OverrideConfig to a resource JSON payload.
* Deep-merges matching override properties using case-insensitive key matching.
* Supports both direct overrides and nested sub-resource overrides.
* Workspace-scoped resources (descriptor.workspace set) are resolved against the
* child sections nested under the matching `workspaces` entry.
* Returns a new object (does not mutate input).
*/
export function applyOverrides(
Expand Down
293 changes: 118 additions & 175 deletions tests/unit/services/override-merger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import { describe, it, expect } from 'vitest';
import { applyOverrides } from '../../../src/services/override-merger.js';
import { applyOverrides, hasNamedValueOverride } from '../../../src/services/override-merger.js';
import { ResourceType } from '../../../src/models/resource-types.js';
import { ResourceDescriptor } from '../../../src/models/types.js';
import { OverrideConfig } from '../../../src/models/config.js';
Expand Down Expand Up @@ -655,59 +655,46 @@ describe('override-merger', () => {
expect(result.properties).toHaveProperty('isCurrent', true);
});
});

describe('workspace-scoped overrides', () => {
it('should apply a direct override for a workspace-scoped API', () => {
const descriptor: ResourceDescriptor = {
type: ResourceType.Api,
nameParts: ['my-api'],
workspace: 'ws1',
};
const json = { name: 'my-api', properties: { serviceUrl: 'https://dev.example.com' } };
const overrideConfig: OverrideConfig = {
workspaces: {
ws1: {
properties: {},
children: {
apis: {
'my-api': {
properties: { serviceUrl: 'https://prod.example.com' },
const overrideConfig: OverrideConfig = {
apis: {
'my-api': { properties: { serviceUrl: 'https://service-level.example.com' } },
},
workspaces: {
'my-workspace': {
properties: { description: 'Production workspace' },
children: {
apis: {
'my-api': {
properties: { serviceUrl: 'https://prod.example.com' },
children: {
diagnostics: { applicationinsights: { properties: { verbosity: 'error' } } },
policies: { policy: { properties: { format: 'xml' } } },
operations: {
'get-op': {
properties: { displayName: 'Prod Op' },
children: { policies: { policy: { properties: { format: 'xml' } } } },
},
},
},
},
},
},
},
};

const result = applyOverrides(descriptor, json, overrideConfig);
expect(result.properties).toHaveProperty('serviceUrl', 'https://prod.example.com');
});

it('should apply a direct override for a workspace-scoped backend', () => {
const descriptor: ResourceDescriptor = {
type: ResourceType.Backend,
nameParts: ['orders-backend'],
workspace: 'partner-workspace',
};
const json = { name: 'orders-backend', properties: { url: 'https://orders-dev.example.com' } };
const overrideConfig: OverrideConfig = {
workspaces: {
'partner-workspace': {
properties: {},
children: {
backends: {
'orders-backend': {
properties: { url: 'https://orders-prod.example.com' },
},
backends: {
'my-backend': { properties: { url: 'https://prod-backend.example.com' } },
},
namedValues: {
'My-NV': { properties: { value: 'prod-value' } },
},
products: {
'my-product': {
properties: { state: 'published' },
children: { policies: { policy: { properties: { format: 'xml' } } } },
},
},
},
},
};

const result = applyOverrides(descriptor, json, overrideConfig);
expect(result.properties).toHaveProperty('url', 'https://orders-prod.example.com');
});
},
};

it('should use the override from the matching workspace when multiple workspaces define the same resource name', () => {
const descriptor: ResourceDescriptor = {
Expand All @@ -716,168 +703,124 @@ describe('override-merger', () => {
workspace: 'ws2',
};
const json = { name: 'my-api', properties: { serviceUrl: 'https://dev.example.com' } };
const overrideConfig: OverrideConfig = {
const overrides: OverrideConfig = {
workspaces: {
ws1: {
properties: {},
children: {
apis: {
'my-api': {
properties: { serviceUrl: 'https://ws1.example.com' },
},
'my-api': { properties: { serviceUrl: 'https://ws1.example.com' } },
},
},
},
ws2: {
properties: {},
children: {
apis: {
'my-api': {
properties: { serviceUrl: 'https://ws2.example.com' },
},
'my-api': { properties: { serviceUrl: 'https://ws2.example.com' } },
},
},
},
},
};

const result = applyOverrides(descriptor, json, overrideConfig);
const result = applyOverrides(descriptor, json, overrides);
expect(result.properties).toHaveProperty('serviceUrl', 'https://ws2.example.com');
});

it('should apply a nested override for a workspace-scoped API diagnostic', () => {
const descriptor: ResourceDescriptor = {
type: ResourceType.ApiDiagnostic,
nameParts: ['my-api', 'applicationinsights'],
workspace: 'ws1',
};
const json = { name: 'applicationinsights', properties: { loggerId: '/old-logger' } };
const overrideConfig: OverrideConfig = {
workspaces: {
ws1: {
properties: {},
children: {
apis: {
'my-api': {
properties: {},
children: {
diagnostics: {
applicationinsights: {
properties: { loggerId: '/new-logger' },
},
},
},
},
},
},
},
},
};
it('should apply override to the workspace container itself', () => {
const result = applyOverrides(
{ type: ResourceType.Workspace, nameParts: ['my-workspace'] },
{ properties: { description: 'Dev workspace' } },
overrideConfig
);
expect(result.properties).toHaveProperty('description', 'Production workspace');
});

const result = applyOverrides(descriptor, json, overrideConfig);
expect(result.properties).toHaveProperty('loggerId', '/new-logger');
it('should apply nested override to a workspace API (case-insensitive workspace name)', () => {
const result = applyOverrides(
{ type: ResourceType.Api, nameParts: ['my-api'], workspace: 'MY-WORKSPACE' },
{ properties: { serviceUrl: 'https://dev.example.com', path: 'my' } },
overrideConfig
);
expect(result.properties).toEqual({ serviceUrl: 'https://prod.example.com', path: 'my' });
});

it('should apply a grandchild override for a workspace-scoped API operation policy', () => {
const descriptor: ResourceDescriptor = {
type: ResourceType.ApiOperationPolicy,
nameParts: ['my-api', 'get-pets'],
workspace: 'ws1',
};
const json = { name: 'policy', properties: { format: 'xml' } };
const overrideConfig: OverrideConfig = {
workspaces: {
ws1: {
properties: {},
children: {
apis: {
'my-api': {
properties: {},
children: {
operations: {
'get-pets': {
properties: {},
children: {
policies: {
policy: { properties: { format: 'rawxml' } },
},
},
},
},
},
},
},
},
},
},
};
it('should apply nested override to a workspace backend and named value', () => {
const backend = applyOverrides(
{ type: ResourceType.Backend, nameParts: ['my-backend'], workspace: 'my-workspace' },
{ properties: { url: 'https://dev-backend.example.com' } },
overrideConfig
);
expect(backend.properties).toHaveProperty('url', 'https://prod-backend.example.com');

const result = applyOverrides(descriptor, json, overrideConfig);
expect(result.properties).toHaveProperty('format', 'rawxml');
const nv = applyOverrides(
{ type: ResourceType.NamedValue, nameParts: ['my-nv'], workspace: 'my-workspace' },
{ properties: { value: 'dev-value' } },
overrideConfig
);
expect(nv.properties).toHaveProperty('value', 'prod-value');
});

it('should not leak a top-level override into a workspace-scoped resource', () => {
const descriptor: ResourceDescriptor = {
type: ResourceType.Api,
nameParts: ['my-api'],
workspace: 'ws1',
};
const json = { name: 'my-api', properties: { serviceUrl: 'https://dev.example.com' } };
// Same resource name is overridden at the top level, but no workspace-scoped override exists.
const overrideConfig: OverrideConfig = {
apis: {
'my-api': {
properties: { serviceUrl: 'https://should-not-apply.example.com' },
},
},
};
it('should apply overrides to workspace API sub-resources', () => {
Comment thread
azaslonov marked this conversation as resolved.
Resolved
const diag = applyOverrides(
{ type: ResourceType.ApiDiagnostic, nameParts: ['my-api', 'applicationinsights'], workspace: 'my-workspace' },
{ properties: { verbosity: 'information' } },
overrideConfig
);
expect(diag.properties).toHaveProperty('verbosity', 'error');

const result = applyOverrides(descriptor, json, overrideConfig);
expect(result).toEqual(json);
});
const apiPolicy = applyOverrides(
{ type: ResourceType.ApiPolicy, nameParts: ['my-api'], workspace: 'my-workspace' },
{ properties: { format: 'rawxml', value: '<policies/>' } },
overrideConfig
);
expect(apiPolicy.properties).toEqual({ format: 'xml', value: '<policies/>' });

it('should still apply workspace container property overrides (no workspace field on descriptor)', () => {
const descriptor: ResourceDescriptor = {
type: ResourceType.Workspace,
nameParts: ['ws1'],
};
const json = { name: 'ws1', properties: { displayName: 'Dev Workspace' } };
const overrideConfig: OverrideConfig = {
workspaces: {
ws1: {
properties: { displayName: 'Prod Workspace' },
},
},
};
const op = applyOverrides(
{ type: ResourceType.ApiOperation, nameParts: ['my-api', 'get-op'], workspace: 'my-workspace' },
{ properties: { displayName: 'Dev Op' } },
overrideConfig
);
expect(op.properties).toHaveProperty('displayName', 'Prod Op');

const result = applyOverrides(descriptor, json, overrideConfig);
expect(result.properties).toHaveProperty('displayName', 'Prod Workspace');
const opPolicy = applyOverrides(
{ type: ResourceType.ApiOperationPolicy, nameParts: ['my-api', 'get-op'], workspace: 'my-workspace' },
{ properties: { format: 'rawxml' } },
overrideConfig
);
expect(opPolicy.properties).toHaveProperty('format', 'xml');

const productPolicy = applyOverrides(
{ type: ResourceType.ProductPolicy, nameParts: ['my-product'], workspace: 'my-workspace' },
{ properties: { format: 'rawxml' } },
overrideConfig
);
expect(productPolicy.properties).toHaveProperty('format', 'xml');
});

it('should match workspace names case-insensitively', () => {
const descriptor: ResourceDescriptor = {
type: ResourceType.Api,
nameParts: ['my-api'],
workspace: 'ws1',
};
const json = { name: 'my-api', properties: { serviceUrl: 'https://dev.example.com' } };
const overrideConfig: OverrideConfig = {
workspaces: {
WS1: {
properties: {},
children: {
apis: {
'my-api': {
properties: { serviceUrl: 'https://prod.example.com' },
},
},
},
},
},
};
it('should not apply service-level overrides to workspace-scoped resources', () => {
const result = applyOverrides(
{ type: ResourceType.Api, nameParts: ['my-api'], workspace: 'other-workspace' },
{ properties: { serviceUrl: 'https://dev.example.com' } },
overrideConfig
);
expect(result.properties).toHaveProperty('serviceUrl', 'https://dev.example.com');
});

const result = applyOverrides(descriptor, json, overrideConfig);
expect(result.properties).toHaveProperty('serviceUrl', 'https://prod.example.com');
it('should not apply workspace overrides to service-level resources', () => {
const result = applyOverrides(
{ type: ResourceType.Backend, nameParts: ['my-backend'] },
{ properties: { url: 'https://dev-backend.example.com' } },
overrideConfig
);
expect(result.properties).toHaveProperty('url', 'https://dev-backend.example.com');
});

it('should detect named value overrides within a workspace scope', () => {
expect(hasNamedValueOverride('my-nv', overrideConfig, 'my-workspace')).toBe(true);
Comment thread
azaslonov marked this conversation as resolved.
Resolved
expect(hasNamedValueOverride('my-nv', overrideConfig)).toBe(false);
expect(hasNamedValueOverride('my-nv', overrideConfig, 'other-workspace')).toBe(false);
Comment thread
azaslonov marked this conversation as resolved.
Resolved
});
});
});
Loading