Skip to content

Commit 2b03ee8

Browse files
feat(resources): support all LocalStack services via manifest + declarative providers
Introduce a static, coverage-derived service manifest as the single source of truth for supported AWS services, a declarative provider engine, a build-time detail-field generator, manifest-backed provider registration with a completeness tracker, and Batch 1 (10 curated services, 26 resource types). - manifest: build/generate-service-manifest.mjs -> resources/service-manifest.json (116 services); memoized loader + shared label mapping (stepfunctions -> states) - declarative engine: defineService/defineResourceType + DeclarativeServiceProvider (list, identifier mapping incl. region-synthesized ARNs, path-walked detail, matchArn, CFN mapping); imperative ServiceProvider kept as the escape hatch - detail-field generator: build/generate-detail-fields.mjs (importance heuristic) - ProviderFactory resolves by manifest id, no generic fallback; default icon path - cfnStackModel + metamodelFocus use the shared label mapping; Resources leaf tolerates non-ARN identifiers - Batch 1: S3, API Gateway, SSM, Secrets Manager, Kinesis, CloudWatch Logs, EventBridge, KMS, Cognito (cognito-idp; IdentityPool deferred), ECR Deferred: migrating the 7 imperative providers to declarative; manual emulator/ cloud verification. 105 tests passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 30664d3 commit 2b03ee8

40 files changed

Lines changed: 4783 additions & 100 deletions

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ The LocalStack sidebar provides three tree views for exploring resources:
3939

4040
You can add regions and define service-scoped filters per profile (managed from the view's right-click actions), and optionally enable multi-select from the view's `...` menu to combine several selectors at once.
4141

42+
### Service coverage
43+
44+
The set of AWS services the toolkit knows about is a static **service manifest** (`resources/service-manifest.json`), generated from LocalStack's published coverage data and committed to the repo — nothing is discovered from a running emulator. Each service has a **curated provider** that declares its resource types, lists live resources, and selects which fields the Resource Details view shows; there is no generic catch-all provider, so a service appears only once it has been curated. Providers are added in batches, so coverage grows over time; a service with no provider yet is simply absent rather than shown broken.
45+
46+
Most providers are authored declaratively (data describing each resource type's list call, identifier, CloudFormation type, and detail fields), executed by a shared engine; the imperative provider class remains available as an escape hatch for services that need it. Two dev-time generators support this: one regenerates the manifest from coverage data, and one produces a first-cut set of detail fields from offline AWS API models for a developer to refine. Neither runs at build time.
47+
4248
## Changelog
4349

4450
[Read our full changelog](./CHANGELOG.md) to learn about the latest changes in each release.

build/generate-detail-fields.mjs

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/**
2+
* Detail-field generator (dev-time authoring aid, on-demand only).
3+
*
4+
* Produces a first-cut `detail: [{ label, path, type }]` spec for a resource
5+
* type by reading that resource's Describe/Get (or list-item) output shape from
6+
* an OFFLINE AWS API model — the `aws-sdk` v2 `apis/<service>-*.normal.json`
7+
* models, or an equivalent botocore `service-2.json`. The model source is used
8+
* only by this generator and is never bundled into the extension.
9+
*
10+
* The emitted spec is a STARTING POINT: it is committed into the service
11+
* definition and hand-refined. Runtime renders the committed, fixed subset (not
12+
* a raw response dump). Services whose detail spans multiple calls, or whose
13+
* only data is the list item, get a partial first cut and are finished by hand.
14+
*
15+
* node build/generate-detail-fields.mjs <model.normal.json> <OperationName>
16+
*
17+
* Prints a TypeScript `detail` array to stdout for pasting into a definition.
18+
*/
19+
/* This dev-time generator walks untyped AWS API model JSON (JSON.parse → any),
20+
* so the type-aware "unsafe" rules add only noise here. */
21+
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-return */
22+
import fs from "node:fs";
23+
24+
/* FieldType enum values, mirrored from src/.../serviceProvider.ts (the runtime
25+
* enum can't be imported into a plain .mjs). Keyed by enum member name so we
26+
* can emit `FieldType.NAME` identifiers. */
27+
export const FIELD_TYPE = {
28+
NAME: "name",
29+
ARN: "arn",
30+
DATE: "date",
31+
SHORT_TEXT: "shortText",
32+
LONG_TEXT: "longText",
33+
JSON: "json",
34+
NUMBER: "number",
35+
LOG_GROUP: "logGroup",
36+
};
37+
38+
/** Members never worth showing. */
39+
const EXCLUDED_NAMES = new Set([
40+
"ResponseMetadata",
41+
"NextToken",
42+
"NextMarker",
43+
"Marker",
44+
"nextToken",
45+
"MaxResults",
46+
"MaxItems",
47+
]);
48+
49+
const PAGINATION_SUFFIXES = ["Token", "Marker"];
50+
51+
/**
52+
* Map an API member (its name + resolved shape type) to a FieldType enum member
53+
* name. `shapeType` is the botocore/aws-sdk shape `type`
54+
* (string|integer|long|float|double|boolean|timestamp|structure|list|map|blob).
55+
*/
56+
export function mapFieldType(name, shapeType) {
57+
if (shapeType === "timestamp") return "DATE";
58+
if (["integer", "long", "float", "double"].includes(shapeType)) {
59+
return "NUMBER";
60+
}
61+
if (["structure", "list", "map"].includes(shapeType)) return "JSON";
62+
/* strings (and anything else) default to NAME, with a couple of refinements */
63+
if (/Arn$/.test(name)) return "ARN";
64+
if (/LogGroup/i.test(name)) return "LOG_GROUP";
65+
return "NAME";
66+
}
67+
68+
/** Importance bucket (lower = more important) for a member name + shape type. */
69+
export function importanceRank(name, shapeType) {
70+
if (/Name$/.test(name) || /Id$/.test(name) || /Arn$/.test(name)) {
71+
return 0; /* identifiers / names */
72+
}
73+
if (/(Status|State)$/.test(name)) return 1;
74+
if (/(Type|Mode)$/.test(name)) return 2;
75+
if (shapeType === "timestamp" || /(Time|Date|Timestamp)$/.test(name)) {
76+
return 3; /* timestamps */
77+
}
78+
if (["structure", "list", "map", "blob"].includes(shapeType)) {
79+
return 5; /* nested / blobs sink to the bottom */
80+
}
81+
return 4; /* other top-level scalars */
82+
}
83+
84+
/** Convert a PascalCase/camelCase member name into a spaced display label. */
85+
export function toLabel(name) {
86+
return name
87+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
88+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
89+
.replace(/^./, (c) => c.toUpperCase());
90+
}
91+
92+
/**
93+
* Rank, filter, and cap a list of `{ name, shapeType }` members into ordered
94+
* FieldSpec-like entries `{ label, path, typeName }` (typeName is a FIELD_TYPE
95+
* member name). Excludes metadata/pagination/blobs; collapses nested shapes to
96+
* JSON; caps at `cap` (default 12).
97+
*/
98+
export function rankAndSelect(members, cap = 12) {
99+
const kept = members.filter(({ name, shapeType }) => {
100+
if (EXCLUDED_NAMES.has(name)) return false;
101+
if (PAGINATION_SUFFIXES.some((s) => name.endsWith(s))) return false;
102+
if (shapeType === "blob") return false;
103+
return true;
104+
});
105+
106+
kept.sort((a, b) => {
107+
const ra = importanceRank(a.name, a.shapeType);
108+
const rb = importanceRank(b.name, b.shapeType);
109+
if (ra !== rb) return ra - rb;
110+
return a.name.localeCompare(b.name); /* stable, deterministic */
111+
});
112+
113+
return kept.slice(0, cap).map(({ name, shapeType }) => ({
114+
label: toLabel(name),
115+
path: name,
116+
typeName: mapFieldType(name, shapeType),
117+
}));
118+
}
119+
120+
/**
121+
* Resolve the top-level output members of an operation from an aws-sdk v2
122+
* `.normal.json` model, as `{ name, shapeType }[]`.
123+
*/
124+
export function resolveOutputMembers(model, operationName) {
125+
const op = model.operations?.[operationName];
126+
if (!op) {
127+
throw new Error(`Operation not found in model: ${operationName}`);
128+
}
129+
const outputShapeName = op.output?.shape;
130+
if (!outputShapeName) return [];
131+
const outputShape = model.shapes?.[outputShapeName];
132+
const members = outputShape?.members ?? {};
133+
return Object.entries(members).map(([name, ref]) => {
134+
const memberShape = model.shapes?.[ref.shape];
135+
return { name, shapeType: memberShape?.type ?? "string" };
136+
});
137+
}
138+
139+
/** Render the selected entries as a pasteable TypeScript `detail` array. */
140+
export function renderDetailArray(entries) {
141+
const lines = entries.map(
142+
({ label, path, typeName }) =>
143+
`\t\t\t\t{ label: ${JSON.stringify(label)}, path: ${JSON.stringify(
144+
path,
145+
)}, type: FieldType.${typeName} },`,
146+
);
147+
return `detail: [\n${lines.join("\n")}\n\t\t\t],`;
148+
}
149+
150+
function main() {
151+
const [modelPath, operationName] = process.argv.slice(2);
152+
if (!modelPath || !operationName) {
153+
console.error(
154+
"Usage: node build/generate-detail-fields.mjs <model.normal.json> <OperationName>",
155+
);
156+
process.exit(1);
157+
}
158+
const model = JSON.parse(fs.readFileSync(modelPath, "utf-8"));
159+
const members = resolveOutputMembers(model, operationName);
160+
const entries = rankAndSelect(members);
161+
console.log(renderDetailArray(entries));
162+
}
163+
164+
/* Run only when invoked directly, so the pure functions stay importable. */
165+
if (import.meta.url === `file://${process.argv[1]}`) {
166+
main();
167+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* Service-manifest generator (dev-time, on-demand only).
3+
*
4+
* Reads LocalStack's published coverage data and emits
5+
* `resources/service-manifest.json` — the static, single-source-of-truth list
6+
* of every service the resource browser knows about. Each manifest entry is
7+
* `{ id, name }` where `id` is AWS's own service code (the SDK/endpoint id,
8+
* e.g. `s3`, `logs`, `states`) and `name` is a human display name. Every
9+
* service in the coverage data is included; community/pro availability is
10+
* deliberately neither read nor stored (the browser also targets real AWS, so
11+
* every service is treated as fully available).
12+
*
13+
* Source: the `localstack-docs` repo's coverage data
14+
* (`src/data/coverage/*.json` + `service_display_name.json`). This generator
15+
* is NOT run at build time and the docs repo is NOT a build dependency — it is
16+
* run by hand when a developer notices the manifest is out of date:
17+
*
18+
* node build/generate-service-manifest.mjs [path-to-localstack-docs]
19+
*
20+
* The docs path defaults to a sibling `../localstack-docs` checkout and can be
21+
* overridden by the first CLI argument or the LOCALSTACK_DOCS_PATH env var.
22+
* The emitted JSON is committed to the repo so the extension build needs no
23+
* network and no sibling checkout.
24+
*/
25+
/* This dev-time generator walks untyped coverage JSON (JSON.parse → any), so
26+
* the type-aware "unsafe" rules add only noise here. */
27+
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return */
28+
import fs from "node:fs";
29+
import path from "node:path";
30+
31+
/* Coverage service-slug -> AWS service code, where they differ. The coverage
32+
* data keys Step Functions as `stepfunctions`, but its AWS service code (SDK
33+
* endpoint id, and the id the provider registers under) is `states`. */
34+
const SERVICE_CODE_OVERRIDES = {
35+
stepfunctions: "states",
36+
};
37+
38+
/* Coverage files that are not services (lookup tables etc.). */
39+
const NON_SERVICE_FILES = new Set(["service_display_name.json"]);
40+
41+
function resolveDocsRoot() {
42+
const fromArg = process.argv[2];
43+
const fromEnv = process.env.LOCALSTACK_DOCS_PATH;
44+
if (fromArg) return path.resolve(fromArg);
45+
if (fromEnv) return path.resolve(fromEnv);
46+
/* default: sibling checkout next to this repo */
47+
return path.resolve(import.meta.dirname, "..", "..", "localstack-docs");
48+
}
49+
50+
/** Title-case a service slug as a last-resort display name. */
51+
function titleCaseSlug(slug) {
52+
return slug
53+
.split(/[-_]/)
54+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
55+
.join(" ");
56+
}
57+
58+
function main() {
59+
const docsRoot = resolveDocsRoot();
60+
const coverageDir = path.join(docsRoot, "src", "data", "coverage");
61+
if (!fs.existsSync(coverageDir)) {
62+
console.error(
63+
`Coverage data not found at ${coverageDir}.\n` +
64+
`Pass the localstack-docs path as the first argument or set ` +
65+
`LOCALSTACK_DOCS_PATH.`,
66+
);
67+
process.exit(1);
68+
}
69+
70+
const displayNamePath = path.join(coverageDir, "service_display_name.json");
71+
const displayNames = fs.existsSync(displayNamePath)
72+
? JSON.parse(fs.readFileSync(displayNamePath, "utf-8"))
73+
: {};
74+
75+
const entries = [];
76+
const seen = new Set();
77+
for (const file of fs.readdirSync(coverageDir)) {
78+
if (!file.endsWith(".json") || NON_SERVICE_FILES.has(file)) continue;
79+
const data = JSON.parse(
80+
fs.readFileSync(path.join(coverageDir, file), "utf-8"),
81+
);
82+
const slug = data.service;
83+
if (!slug) continue; /* skip malformed / non-service entries */
84+
85+
const id = SERVICE_CODE_OVERRIDES[slug] ?? slug;
86+
if (seen.has(id)) continue;
87+
seen.add(id);
88+
89+
const dn = displayNames[slug];
90+
const name = (dn && (dn.short_name || dn.long_name)) || titleCaseSlug(slug);
91+
entries.push({ id, name });
92+
}
93+
94+
entries.sort((a, b) => a.id.localeCompare(b.id));
95+
96+
const output = {
97+
$comment:
98+
"Generated by build/generate-service-manifest.mjs from localstack-docs " +
99+
"coverage data. Regenerate on demand only (when noticed out of date); " +
100+
"do not edit by hand. Availability (community/pro) is intentionally omitted.",
101+
services: entries,
102+
};
103+
104+
const outPath = path.resolve(
105+
import.meta.dirname,
106+
"..",
107+
"resources",
108+
"service-manifest.json",
109+
);
110+
fs.writeFileSync(outPath, `${JSON.stringify(output, null, "\t")}\n`);
111+
console.log(
112+
`Wrote ${entries.length} services to ${path.relative(process.cwd(), outPath)}`,
113+
);
114+
}
115+
116+
main();
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-06-22

0 commit comments

Comments
 (0)