Skip to content

Commit 71a8fe5

Browse files
fix(resources): resolve CloudFormation-origin API Gateway & Cognito resources
Opening an API Gateway or Cognito resource from a Stack view threw "Cannot resolve resource type for ARN". The live `list` path and the CloudFormation path synthesize ARNs differently, and these multi-type services rely on the engine's "self" describe path, which requires the synthesized ARN to string-equal the live `id` — but the CFN template always injects an account segment the live `id` omits. Fix the 4 types CloudFormation can support by re-encoding the discriminating token via cfnResourceName and adding a direct describe() (also removes the per-click re-list for the API Gateway types): - cognito userpool, apigateway restapi/apikey/usageplan Drop the cfn mapping for the 4 it cannot: their PhysicalResourceId is the leaf id only, without the parent pool/api id every describe API needs, so CFN-origin resolution is impossible. They are now skipped and logged in stack views (as the states definition does) rather than throwing on click; live resources still resolve. - cognito userpoolclient/userpoolgroup, apigateway stage/authorizer Add cfnRoundTrip.test.ts covering synthesize -> resolve -> describe for the 4 fixed types and asserting the 4 dropped types are unmapped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4dea1e1 commit 71a8fe5

3 files changed

Lines changed: 259 additions & 4 deletions

File tree

src/platforms/aws/services/definitions/apigateway.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import {
22
APIGatewayClient,
3+
GetApiKeyCommand,
34
GetApiKeysCommand,
45
GetAuthorizersCommand,
6+
GetRestApiCommand,
57
GetRestApisCommand,
68
GetStagesCommand,
9+
GetUsagePlanCommand,
710
GetUsagePlansCommand,
811
} from "@aws-sdk/client-api-gateway";
912
import type {
@@ -14,6 +17,7 @@ import type {
1417
UsagePlan,
1518
} from "@aws-sdk/client-api-gateway";
1619

20+
import type ARN from "../../models/arnModel.ts";
1721
import { defineService } from "../declarative/types.ts";
1822
import { FieldType } from "../serviceProvider.ts";
1923

@@ -39,6 +43,13 @@ export const apiGatewayDefinition = defineService<APIGatewayClient>({
3943
plural: "REST APIs",
4044
metamodelOp: "getRestApis",
4145
cfn: "AWS::ApiGateway::RestApi",
46+
/* CloudFormation's PhysicalResourceId is the REST API id; re-encode it as
47+
* the `/restapis/<id>` path the live `id` uses so a stack resource
48+
* resolves to this type and `describe` can read it back. */
49+
cfnResourceName: (summary) =>
50+
summary.PhysicalResourceId
51+
? `/restapis/${summary.PhysicalResourceId}`
52+
: undefined,
4253
matchArn: (identifier) =>
4354
identifier.arn.includes("/restapis/") &&
4455
!identifier.arn.includes("/stages/") &&
@@ -55,6 +66,12 @@ export const apiGatewayDefinition = defineService<APIGatewayClient>({
5566
},
5667
id: (api: RestApi, ctx) =>
5768
`arn:aws:apigateway:${ctx.region}::/restapis/${api.id}`,
69+
/* Fetch directly by id (works for both live and CloudFormation ARNs)
70+
* rather than re-listing every API. */
71+
describe: (client, identifier) =>
72+
client.send(
73+
new GetRestApiCommand({ restApiId: pathId(identifier, "restapis") }),
74+
),
5875
detail: [
5976
{ label: "Name", path: "name", type: FieldType.NAME },
6077
{ label: "ID", path: "id", type: FieldType.NAME },
@@ -72,7 +89,10 @@ export const apiGatewayDefinition = defineService<APIGatewayClient>({
7289
singular: "Stage",
7390
plural: "Stages",
7491
metamodelOp: "getStages",
75-
cfn: "AWS::ApiGateway::Stage",
92+
/* No `cfn` mapping: CloudFormation's PhysicalResourceId for a stage is the
93+
* stage name alone, without the REST API id needed to fetch it. Live
94+
* stages (whose ARN carries the api id) still resolve; CloudFormation
95+
* stages are skipped in stack views. */
7696
matchArn: (identifier) => identifier.arn.includes("/stages/"),
7797
list: async (client): Promise<StageWithApi[]> => {
7898
const apiIds = await listRestApiIds(client);
@@ -104,6 +124,12 @@ export const apiGatewayDefinition = defineService<APIGatewayClient>({
104124
plural: "API Keys",
105125
metamodelOp: "getApiKeys",
106126
cfn: "AWS::ApiGateway::ApiKey",
127+
/* CloudFormation's PhysicalResourceId is the API key id; re-encode it as
128+
* the `/apikeys/<id>` path the live `id` uses. */
129+
cfnResourceName: (summary) =>
130+
summary.PhysicalResourceId
131+
? `/apikeys/${summary.PhysicalResourceId}`
132+
: undefined,
107133
matchArn: (identifier) => identifier.arn.includes("/apikeys/"),
108134
list: async (client): Promise<ApiKey[]> => {
109135
const keys: ApiKey[] = [];
@@ -117,6 +143,11 @@ export const apiGatewayDefinition = defineService<APIGatewayClient>({
117143
},
118144
id: (key: ApiKey, ctx) =>
119145
`arn:aws:apigateway:${ctx.region}::/apikeys/${key.id}`,
146+
/* Fetch by id (no `includeValue`, so the key secret is never read). */
147+
describe: (client, identifier) =>
148+
client.send(
149+
new GetApiKeyCommand({ apiKey: pathId(identifier, "apikeys") }),
150+
),
120151
detail: [
121152
{ label: "Name", path: "name", type: FieldType.NAME },
122153
{ label: "ID", path: "id", type: FieldType.NAME },
@@ -134,6 +165,12 @@ export const apiGatewayDefinition = defineService<APIGatewayClient>({
134165
plural: "Usage Plans",
135166
metamodelOp: "getUsagePlans",
136167
cfn: "AWS::ApiGateway::UsagePlan",
168+
/* CloudFormation's PhysicalResourceId is the usage plan id; re-encode it
169+
* as the `/usageplans/<id>` path the live `id` uses. */
170+
cfnResourceName: (summary) =>
171+
summary.PhysicalResourceId
172+
? `/usageplans/${summary.PhysicalResourceId}`
173+
: undefined,
137174
matchArn: (identifier) => identifier.arn.includes("/usageplans/"),
138175
list: async (client): Promise<UsagePlan[]> => {
139176
const plans: UsagePlan[] = [];
@@ -147,6 +184,13 @@ export const apiGatewayDefinition = defineService<APIGatewayClient>({
147184
},
148185
id: (plan: UsagePlan, ctx) =>
149186
`arn:aws:apigateway:${ctx.region}::/usageplans/${plan.id}`,
187+
/* Fetch directly by id rather than re-listing every usage plan. */
188+
describe: (client, identifier) =>
189+
client.send(
190+
new GetUsagePlanCommand({
191+
usagePlanId: pathId(identifier, "usageplans"),
192+
}),
193+
),
150194
detail: [
151195
{ label: "Name", path: "name", type: FieldType.NAME },
152196
{ label: "ID", path: "id", type: FieldType.NAME },
@@ -162,7 +206,9 @@ export const apiGatewayDefinition = defineService<APIGatewayClient>({
162206
singular: "Authorizer",
163207
plural: "Authorizers",
164208
metamodelOp: "getAuthorizers",
165-
cfn: "AWS::ApiGateway::Authorizer",
209+
/* No `cfn` mapping: CloudFormation's PhysicalResourceId for an authorizer
210+
* is the authorizer id alone, without the REST API id needed to fetch it.
211+
* Live authorizers still resolve; CloudFormation authorizers are skipped. */
166212
matchArn: (identifier) => identifier.arn.includes("/authorizers/"),
167213
list: async (client): Promise<AuthorizerWithApi[]> => {
168214
const apiIds = await listRestApiIds(client);
@@ -197,6 +243,19 @@ export const apiGatewayDefinition = defineService<APIGatewayClient>({
197243
},
198244
});
199245

246+
/**
247+
* Extract the id following a path collection in an API Gateway ARN, e.g. the
248+
* `<id>` in `/restapis/<id>` or `/apikeys/<id>`. Works for both the live
249+
* (account-less) ARN and the CloudFormation-synthesized one, since both encode
250+
* the same `/collection/<id>` path in the resource portion. Returns `""` when
251+
* the collection segment is absent.
252+
*/
253+
function pathId(identifier: ARN, collection: string): string {
254+
const parts = identifier.resourceId.split("/").filter(Boolean);
255+
const index = parts.indexOf(collection);
256+
return index >= 0 ? (parts[index + 1] ?? "") : "";
257+
}
258+
200259
/** Enumerate every REST API id (for resource types scoped to an API). */
201260
async function listRestApiIds(client: APIGatewayClient): Promise<string[]> {
202261
const ids: string[] = [];

src/platforms/aws/services/definitions/cognito-idp.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ export const cognitoIdpDefinition =
3333
singular: "User Pool",
3434
plural: "User Pools",
3535
cfn: "AWS::Cognito::UserPool",
36+
/* CloudFormation's PhysicalResourceId for a user pool is the pool id;
37+
* re-encode it as the `userpool/<id>` token the live `id` uses so a
38+
* stack resource resolves to this type and `describe` can read it. */
39+
cfnResourceName: (summary) =>
40+
summary.PhysicalResourceId
41+
? `userpool/${summary.PhysicalResourceId}`
42+
: undefined,
3643
matchArn: (identifier) => identifier.arn.includes(":userpool/"),
3744
list: async (client): Promise<UserPoolDescriptionType[]> => {
3845
const pools: UserPoolDescriptionType[] = [];
@@ -81,7 +88,11 @@ export const cognitoIdpDefinition =
8188
userpoolclient: {
8289
singular: "User Pool Client",
8390
plural: "User Pool Clients",
84-
cfn: "AWS::Cognito::UserPoolClient",
91+
/* No `cfn` mapping: CloudFormation's PhysicalResourceId for an app
92+
* client is the client id alone, without the user pool id that every
93+
* Cognito API needs to describe it — so a stack-origin client can't be
94+
* resolved. Live resources (which carry the pool id in their ARN) still
95+
* work; CloudFormation app clients are skipped in stack views. */
8596
matchArn: (identifier) => identifier.arn.includes(":userpoolclient/"),
8697
list: async (client): Promise<UserPoolClientDescription[]> => {
8798
const poolIds = await listUserPoolIds(client);
@@ -113,7 +124,9 @@ export const cognitoIdpDefinition =
113124
userpoolgroup: {
114125
singular: "User Pool Group",
115126
plural: "User Pool Groups",
116-
cfn: "AWS::Cognito::UserPoolGroup",
127+
/* No `cfn` mapping: CloudFormation's PhysicalResourceId for a group is
128+
* the group name alone, without the user pool id needed to describe it.
129+
* Live resources still resolve; CloudFormation groups are skipped. */
117130
matchArn: (identifier) => identifier.arn.includes(":userpoolgroup/"),
118131
list: async (client): Promise<GroupType[]> => {
119132
const poolIds = await listUserPoolIds(client);
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import assert from "node:assert";
2+
3+
import type { StackResourceSummary } from "@aws-sdk/client-cloudformation";
4+
5+
import ARN from "../../platforms/aws/models/arnModel.ts";
6+
import { DeclarativeServiceProvider } from "../../platforms/aws/services/declarative/engine.ts";
7+
import type { ServiceDefinition } from "../../platforms/aws/services/declarative/types.ts";
8+
import { apiGatewayDefinition } from "../../platforms/aws/services/definitions/apigateway.ts";
9+
import { cognitoIdpDefinition } from "../../platforms/aws/services/definitions/cognito-idp.ts";
10+
11+
/**
12+
* Round-trip tests for CloudFormation-origin resources: synthesize the ARN
13+
* exactly as `CfnStackModel` does (`getArnResourceNameForCloudFormationResource`
14+
* → `arn:aws:<service>:<region>:<account>:<resourceName>`), then `describeResource`
15+
* that ARN and assert it resolves to the right type and fetches by the right id.
16+
*
17+
* This is the gap that let API Gateway and Cognito resources throw when opened
18+
* from a stack view: the live `id` and the CloudFormation ARN are built by two
19+
* different code paths, so a type whose `cfnResourceName` doesn't re-encode the
20+
* discriminating token is unresolvable. The dropped types assert the honest
21+
* fallback — they have no `cfn` mapping (CloudFormation can't supply the parent
22+
* id they need), so the stack model skips them rather than showing a broken row.
23+
*/
24+
25+
type Handlers = Record<string, (input: Record<string, unknown>) => unknown>;
26+
function fakeClient(handlers: Handlers): { send: (command: unknown) => unknown } {
27+
return {
28+
send: (command: unknown) => {
29+
const name = (command as { constructor: { name: string } }).constructor
30+
.name;
31+
const handler = handlers[name];
32+
if (!handler) {
33+
throw new Error(`Unexpected SDK command: ${name}`);
34+
}
35+
const input = (command as { input?: Record<string, unknown> }).input ?? {};
36+
return Promise.resolve(handler(input));
37+
},
38+
};
39+
}
40+
41+
function providerWith<TClient>(
42+
definition: ServiceDefinition<TClient>,
43+
client: unknown,
44+
) {
45+
return new DeclarativeServiceProvider({
46+
...definition,
47+
client: () => client as TClient,
48+
});
49+
}
50+
51+
function summary(partial: Partial<StackResourceSummary>): StackResourceSummary {
52+
return partial as StackResourceSummary;
53+
}
54+
55+
/** Build the ARN a stack resource would get, mirroring `CfnStackModel`. */
56+
function cfnArn<TClient>(
57+
provider: DeclarativeServiceProvider<TClient>,
58+
stackResource: StackResourceSummary,
59+
): { resourceType: string; arn: ARN } {
60+
const { resourceType, resourceName } =
61+
provider.getArnResourceNameForCloudFormationResource(stackResource);
62+
const arn = `arn:aws:${provider.getId()}:us-east-1:000000000000:${resourceName}`;
63+
return { resourceType, arn: new ARN(arn) };
64+
}
65+
66+
suite("CloudFormation round-trip: API Gateway", () => {
67+
let seen: Record<string, string> = {};
68+
const client = fakeClient({
69+
GetRestApiCommand: (input) => {
70+
seen.restApiId = input.restApiId as string;
71+
return { id: input.restApiId, name: "My API" };
72+
},
73+
GetApiKeyCommand: (input) => {
74+
seen.apiKey = input.apiKey as string;
75+
return { id: input.apiKey, name: "My Key", enabled: true };
76+
},
77+
GetUsagePlanCommand: (input) => {
78+
seen.usagePlanId = input.usagePlanId as string;
79+
return { id: input.usagePlanId, name: "My Plan" };
80+
},
81+
});
82+
const provider = providerWith(apiGatewayDefinition, client);
83+
84+
test("REST API resolves and describes by id", async () => {
85+
seen = {};
86+
const { resourceType, arn } = cfnArn(
87+
provider,
88+
summary({
89+
ResourceType: "AWS::ApiGateway::RestApi",
90+
PhysicalResourceId: "abc123",
91+
}),
92+
);
93+
assert.strictEqual(resourceType, "restapi");
94+
const fields = await provider.describeResource("default", arn);
95+
assert.strictEqual(seen.restApiId, "abc123");
96+
assert.ok(fields.some((f) => f.field === "Name" && f.value === "My API"));
97+
});
98+
99+
test("API Key resolves and describes by id", async () => {
100+
seen = {};
101+
const { resourceType, arn } = cfnArn(
102+
provider,
103+
summary({
104+
ResourceType: "AWS::ApiGateway::ApiKey",
105+
PhysicalResourceId: "key123",
106+
}),
107+
);
108+
assert.strictEqual(resourceType, "apikey");
109+
await provider.describeResource("default", arn);
110+
assert.strictEqual(seen.apiKey, "key123");
111+
});
112+
113+
test("Usage Plan resolves and describes by id", async () => {
114+
seen = {};
115+
const { resourceType, arn } = cfnArn(
116+
provider,
117+
summary({
118+
ResourceType: "AWS::ApiGateway::UsagePlan",
119+
PhysicalResourceId: "plan123",
120+
}),
121+
);
122+
assert.strictEqual(resourceType, "usageplan");
123+
await provider.describeResource("default", arn);
124+
assert.strictEqual(seen.usagePlanId, "plan123");
125+
});
126+
127+
test("Stage and Authorizer have no CloudFormation mapping", () => {
128+
for (const ResourceType of [
129+
"AWS::ApiGateway::Stage",
130+
"AWS::ApiGateway::Authorizer",
131+
]) {
132+
assert.throws(
133+
() =>
134+
provider.getArnResourceNameForCloudFormationResource(
135+
summary({ ResourceType, PhysicalResourceId: "x" }),
136+
),
137+
/Unsupported resource type/,
138+
`expected ${ResourceType} to be unmapped`,
139+
);
140+
}
141+
});
142+
});
143+
144+
suite("CloudFormation round-trip: Cognito", () => {
145+
let seenUserPoolId: string | undefined;
146+
const client = fakeClient({
147+
DescribeUserPoolCommand: (input) => {
148+
seenUserPoolId = input.UserPoolId as string;
149+
return { UserPool: { Name: "my-pool", Id: input.UserPoolId } };
150+
},
151+
});
152+
const provider = providerWith(cognitoIdpDefinition, client);
153+
154+
test("User Pool resolves and describes by id", async () => {
155+
const { resourceType, arn } = cfnArn(
156+
provider,
157+
summary({
158+
ResourceType: "AWS::Cognito::UserPool",
159+
PhysicalResourceId: "us-east-1_AbC123",
160+
}),
161+
);
162+
assert.strictEqual(resourceType, "userpool");
163+
const fields = await provider.describeResource("default", arn);
164+
assert.strictEqual(seenUserPoolId, "us-east-1_AbC123");
165+
assert.ok(fields.some((f) => f.field === "Name" && f.value === "my-pool"));
166+
});
167+
168+
test("User Pool Client and Group have no CloudFormation mapping", () => {
169+
for (const ResourceType of [
170+
"AWS::Cognito::UserPoolClient",
171+
"AWS::Cognito::UserPoolGroup",
172+
]) {
173+
assert.throws(
174+
() =>
175+
provider.getArnResourceNameForCloudFormationResource(
176+
summary({ ResourceType, PhysicalResourceId: "x" }),
177+
),
178+
/Unsupported resource type/,
179+
`expected ${ResourceType} to be unmapped`,
180+
);
181+
}
182+
});
183+
});

0 commit comments

Comments
 (0)