Skip to content

Commit 601f36a

Browse files
authored
Add A2A API round-trip support in extract/publish
1 parent 785f750 commit 601f36a

10 files changed

Lines changed: 122 additions & 4 deletions

File tree

docs/reference/artifact-format.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ Example policy XML:
241241
- **Info file names** follow the pattern `{resourceType}Information.json`
242242
- **Policy files** are always named `policy.xml`
243243
- **API specifications** are named `specification.{ext}` (e.g., `specification.yaml`, `specification.json`, `specification.wsdl`)
244+
- Specification files are not exported for API types that don't use OpenAPI/WSDL artifacts (for example: WebSocket, MCP, A2A).
244245
- **Wiki files** are named `wiki.md`
245246

246247
---

src/clients/apim-client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -586,7 +586,7 @@ export class ApimClient implements IApimClient {
586586
* graphql-link – GraphQL SDL blob (type=graphql)
587587
* wsdl-link – WSDL blob (type=soap)
588588
* openapi-link – OpenAPI 3.0 YAML (type=http, default)
589-
* undefined – (type=websocket — no spec; callers should skip)
589+
* undefined – (type=websocket|a2a — no spec; callers should skip)
590590
*
591591
* SOAP APIs use wsdl-link so the exported specification can be re-imported
592592
* faithfully on publish (matches the Azure/apiops reference tool). APIM's
@@ -612,6 +612,7 @@ export class ApimClient implements IApimClient {
612612
case 'graphql': return 'graphql-link';
613613
case 'soap': return 'wsdl-link';
614614
case 'websocket': return undefined;
615+
case 'a2a': return undefined;
615616
default: return 'openapi-link';
616617
}
617618
}
@@ -743,4 +744,3 @@ export class ApimClient implements IApimClient {
743744
}
744745

745746
}
746-

src/services/api-extractor.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,11 @@ async function extractApiSpecification(
358358
return false;
359359
}
360360

361+
if (apiType?.toLowerCase() === 'a2a') {
362+
logger.debug(`Skipping spec export for A2A API "${getNamePart(apiDescriptor.nameParts, 0)}" — A2A APIs use JSON-RPC + agent card endpoints, not OpenAPI`);
363+
return false;
364+
}
365+
361366
if (apiType?.toLowerCase() === 'graphql' && hasGraphQLSchema(extractedSchemas)) {
362367
logger.debug(
363368
`Skipping spec export for synthetic GraphQL API "${getNamePart(apiDescriptor.nameParts, 0)}" — schema is captured via ApiSchema`

src/services/api-publisher.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@ export async function publishApi(
9797
* Maps spec file format to APIM ContentFormat for inline import.
9898
*/
9999
function getImportFormat(specFormat: string, _apiType?: string): string | undefined {
100+
const apiType = _apiType?.toLowerCase();
101+
if (apiType === 'a2a') {
102+
return undefined;
103+
}
104+
100105
switch (specFormat) {
101106
case 'yaml':
102107
return 'openapi';
@@ -178,7 +183,7 @@ async function publishRootApi(
178183

179184
// The `type` property from GET is read-only and ignored on PUT.
180185
// APIM uses `apiType` to determine the API kind during spec import.
181-
// Always set it explicitly (valid values: http, soap, websocket, graphql, odata, grpc, mcp).
186+
// Always set it explicitly (valid values include http, soap, websocket, graphql, odata, grpc, mcp, a2a).
182187
if (apiType) {
183188
cleanProps.apiType = apiType;
184189
}

tests/integration/all-resource-types/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ The kitchen sink APIM instance includes **every resource type and API protocol v
1818
| `src-rest-revisioned` | REST (revisioned) | OpenAPI |
1919
| `src-mcp-from-api` | MCP (from existing API) | None |
2020
| `src-mcp-from-external` | MCP (from external MCP server) | None |
21+
| `src-a2a-weather-agent` | A2A (JSON-RPC + agent card) | None |
2122

2223
### Backend Variations
2324
| Backend | Type |

tests/integration/all-resource-types/expected-structure.json

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,7 @@
355355
]
356356
},
357357
"apis": {
358-
"minCount": 9,
358+
"minCount": 10,
359359
"expected": [
360360
{
361361
"name": "src-rest-openapi",
@@ -596,6 +596,24 @@
596596
}
597597
},
598598
"notes": "MCP API repackaging an external MCP server: backendId points to the backend that holds the upstream URL (https://api.githubcopilot.com/mcp), and mcpProperties.endpoints.mcp.uriTemplate addresses the MCP endpoint exposed by that backend"
599+
},
600+
{
601+
"name": "src-a2a-weather-agent",
602+
"files": ["apiInformation.json"],
603+
"spotChecks": {
604+
"apiInformation.json": {
605+
"properties.displayName": "KS A2A Weather Agent",
606+
"properties.path": "ks/a2a-weather",
607+
"properties.type": "a2a",
608+
"properties.agent.id": "src-a2a-weather-agent",
609+
"properties.a2aProperties.agentCardPath": "/.well-known/agent.json",
610+
"properties.jsonRpcProperties.path": "/",
611+
"properties.subscriptionRequired": true,
612+
"properties.subscriptionKeyParameterNames.header": "Ocp-Apim-Subscription-Key",
613+
"properties.subscriptionKeyParameterNames.query": "subscription-key"
614+
}
615+
},
616+
"notes": "A2A API with JSON-RPC runtime mediation and agent card settings."
599617
}
600618
]
601619
},

tests/integration/all-resource-types/source-apim.bicep

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,38 @@ resource apiMcpFromExternal 'Microsoft.ApiManagement/service/apis@2025-09-01-pre
901901
})
902902
}
903903

904+
// 10. A2A API with JSON-RPC runtime and agent card settings
905+
// any() used because A2A properties are runtime-supported but may not be present
906+
// in this API version's Bicep type definitions.
907+
resource apiA2a 'Microsoft.ApiManagement/service/apis@2025-09-01-preview' = {
908+
parent: apim
909+
name: 'src-a2a-weather-agent'
910+
properties: any({
911+
displayName: 'KS A2A Weather Agent'
912+
description: 'A2A API exposing JSON-RPC runtime and an APIM-mediated agent card'
913+
path: 'ks/a2a-weather'
914+
protocols: ['https']
915+
type: 'a2a'
916+
isAgent: true
917+
agent: {
918+
id: 'src-a2a-weather-agent'
919+
}
920+
a2aProperties: {
921+
agentCardPath: '/.well-known/agent.json'
922+
agentCardBackendUrl: 'https://src-a2a-weather-agent.example.com/.well-known/agent.json'
923+
}
924+
jsonRpcProperties: {
925+
backendUrl: 'https://src-a2a-weather-agent.example.com'
926+
path: '/'
927+
}
928+
subscriptionRequired: true
929+
subscriptionKeyParameterNames: {
930+
header: 'Ocp-Apim-Subscription-Key'
931+
query: 'subscription-key'
932+
}
933+
})
934+
}
935+
904936
// ---------------------------------------------------------------------------
905937
// TIER 3: Child Resources
906938
// ---------------------------------------------------------------------------

tests/unit/clients/apim-client.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1155,6 +1155,13 @@ describe('ApimClient.getApiSpecification', () => {
11551155
expect(fetchSpy).not.toHaveBeenCalled();
11561156
});
11571157

1158+
it('should return undefined for a2a apiType without making an HTTP request', async () => {
1159+
const result = await client.getApiSpecification(testContext, 'a2a-api', 'a2a');
1160+
1161+
expect(result).toBeUndefined();
1162+
expect(fetchSpy).not.toHaveBeenCalled();
1163+
});
1164+
11581165
it('should handle top-level link format (api-version 2024-05-01)', async () => {
11591166
// APIM 2024-05-01 returns { link: "..." } at the top level, not nested under value
11601167
const exportResponse = {

tests/unit/services/api-product-extractor.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -791,6 +791,25 @@ describe('api-extractor', () => {
791791
expect(getApiSpecification).not.toHaveBeenCalled();
792792
});
793793

794+
it('should skip specification export for A2A APIs without calling the client', async () => {
795+
const getApiSpecification = vi.fn();
796+
const client = createMockClient({
797+
getApiSpecification,
798+
getResource: vi.fn().mockResolvedValue(undefined),
799+
});
800+
const store = createMockStore();
801+
802+
const result = await extractApiResources(
803+
client, store, testContext,
804+
{ type: ResourceType.Api, nameParts: ['a2a-api'] },
805+
{ name: 'a2a-api', properties: { type: 'a2a' } },
806+
'/output'
807+
);
808+
809+
expect(result.specification).toBe(false);
810+
expect(getApiSpecification).not.toHaveBeenCalled();
811+
});
812+
794813
it('should not call ARM for MCP child resource (data is embedded on the API)', async () => {
795814
// ARM does not serve apis/{id}/mcpServers/default for MCP APIs; all
796815
// configuration lives on the API resource itself. The extractor must

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,36 @@ describe('api-publisher', () => {
168168
expect(tasks).toHaveLength(2);
169169
});
170170

171+
it('should not inject specification import fields for A2A APIs', async () => {
172+
const client = createMockClient();
173+
const store = createMockStore([]);
174+
store.readResource.mockResolvedValue({
175+
name: 'a2a-api',
176+
properties: {
177+
type: 'a2a',
178+
path: 'ks/a2a',
179+
protocols: ['https'],
180+
},
181+
});
182+
store.readContent.mockResolvedValue({
183+
content: 'openapi: "3.0.0"',
184+
format: 'yaml',
185+
});
186+
187+
const apiDescriptor: ResourceDescriptor = {
188+
type: ResourceType.Api,
189+
nameParts: ['a2a-api'],
190+
};
191+
192+
await publishApi(client, store, testContext, apiDescriptor, testConfig);
193+
194+
const [, , payload] = client.putResource.mock.calls[0] as [unknown, unknown, Record<string, unknown>];
195+
const properties = payload.properties as Record<string, unknown>;
196+
expect(properties).not.toHaveProperty('format');
197+
expect(properties).not.toHaveProperty('value');
198+
expect(properties).not.toHaveProperty('apiType');
199+
});
200+
171201
it('should return failed result when root API publish fails', async () => {
172202
const client = createMockClient();
173203
const store = createMockStore([]);

0 commit comments

Comments
 (0)