@suss/contract-appsync 0.36.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,7 +11,11 @@ This package builds suss `BehavioralSummary[]` from an [AWS AppSync](https://doc
11
11
  - **Raw resources**: `AWS::AppSync::GraphQLApi`, `AWS::AppSync::GraphQLSchema`, `AWS::AppSync::Resolver`, `AWS::AppSync::FunctionConfiguration` and `AWS::AppSync::DataSource`.
12
12
  - **The SAM shorthand** `AWS::Serverless::GraphQLApi`. Its inline `SchemaInline` / `SchemaUri`, `DataSources`, `Functions` and `Resolvers` blocks are converted into the same model before any summary is built. Generated logical ids are prefixed with the API's logical id, so two GraphQL APIs in one template stay apart.
13
13
 
14
- The SDL comes from the inline `Definition` / `SchemaInline` property, or from a local file when `DefinitionS3Location` / `SchemaUri` is a path, resolved against the template's directory. A static reader cannot fetch an `s3://` or `http(s)://` URI, or a location the template computes with an intrinsic. Both cases are recorded under `metadata.appsync.schemaSource` with a `status` of `"unresolved"` and a reason, so a schema the reader could not open is always reported.
14
+ A resolver or a pipeline function can point at its data source by logical id or by the data source's `Name`. A name is looked up among the data sources of the same API, so two APIs can each have a data source called `orders`.
15
+
16
+ The reader also reads every template the file embeds through an `AWS::CloudFormation::Stack` or `AWS::Serverless::Application`, and treats them as one API surface. A child's resources get the stack path in front of their logical id, as in `DirectoryStack/DirectoryFunction`, the same id the aws-lambda pack gives that function. When a parent passes a resource to a child as a parameter, such as `ApiId: !GetAtt Api.ApiId`, the child's `!Ref ApiId` reads as that resource, so a child's resolvers attach to the API and schema the root declares. A child it cannot open is reported by name.
17
+
18
+ The SDL comes from the inline `Definition` / `SchemaInline` property, or from a local file when `DefinitionS3Location` / `SchemaUri` is a path, resolved against the directory of the template that declares it. A static reader cannot fetch an `s3://` or `http(s)://` URI, or a location the template computes with an intrinsic. Both cases are recorded under `metadata.appsync.schemaSource` with a `status` of `"unresolved"` and a reason, so a schema the reader could not open is always reported.
15
19
 
16
20
  ## What it produces
17
21
 
@@ -36,7 +40,7 @@ Data source types are normalized from `AWS_LAMBDA`, `AMAZON_DYNAMODB`, `AMAZON_E
36
40
  - **Dynamic intrinsics.** A resolver whose `TypeName` or `FieldName` comes from a `!Ref` or an `Fn::Join` is skipped, so the reader never guesses. A pipeline whose `Functions` array cannot be resolved statically still reports `kind: "PIPELINE"`, with an empty chain.
37
41
  - **Remote schemas.** Nothing is fetched over the network.
38
42
  - **Authorization.** The API's `AuthenticationType` is recorded on each resolver, and does not add transitions.
39
- - **Nested stacks.** Only the template you point at is walked.
43
+ - **Stack outputs.** A parent that reads a child's resource through `!GetAtt Child.Outputs.X` points at nothing the reader follows.
40
44
 
41
45
  ## Worked example
42
46
 
package/dist/index.cjs CHANGED
@@ -36,12 +36,22 @@ __export(index_exports, {
36
36
  module.exports = __toCommonJS(index_exports);
37
37
  var import_node_path2 = __toESM(require("path"), 1);
38
38
  var import_files = require("@suss/behavioral-ir/files");
39
- var import_manifest_aws2 = require("@suss/manifest-aws");
39
+ var import_manifest_aws4 = require("@suss/manifest-aws");
40
+
41
+ // src/cfn.ts
42
+ var import_manifest_aws3 = require("@suss/manifest-aws");
40
43
 
41
44
  // src/refs.ts
42
45
  var import_manifest_aws = require("@suss/manifest-aws");
43
- function resolveLogicalRef(value) {
44
- return (0, import_manifest_aws.refTarget)(value);
46
+ var OWN_DOCUMENT = {
47
+ own: (logicalId) => logicalId,
48
+ ref: (value) => (0, import_manifest_aws.refTarget)(value)
49
+ };
50
+ function documentRefsOf(document) {
51
+ return {
52
+ own: (logicalId) => (0, import_manifest_aws.qualifiedLogicalId)(document.stackPath, logicalId),
53
+ ref: (value) => (0, import_manifest_aws.documentRef)(document, value)
54
+ };
45
55
  }
46
56
  function stringField(value) {
47
57
  return typeof value === "string" ? value : null;
@@ -54,6 +64,7 @@ function asRecord(value) {
54
64
  }
55
65
 
56
66
  // src/sam.ts
67
+ var import_manifest_aws2 = require("@suss/manifest-aws");
57
68
  var RESOLVER_ROOT_TYPES = ["Query", "Mutation", "Subscription"];
58
69
  var DATA_SOURCE_CATEGORIES = {
59
70
  Lambda: "lambda",
@@ -68,7 +79,7 @@ var DATA_SOURCE_CATEGORIES = {
68
79
  RelationalDatabases: "relational",
69
80
  None: "none"
70
81
  };
71
- function readServerlessGraphQLApis(resources) {
82
+ function readServerlessGraphQLApis(resources, refs) {
72
83
  const out = {
73
84
  apis: [],
74
85
  resolvers: [],
@@ -79,18 +90,20 @@ function readServerlessGraphQLApis(resources) {
79
90
  if (resource?.Type !== "AWS::Serverless::GraphQLApi") {
80
91
  continue;
81
92
  }
82
- readOne(logicalId, resource.Properties ?? {}, out);
93
+ readOne(refs.own(logicalId), resource.Properties ?? {}, out, refs);
83
94
  }
84
95
  return out;
85
96
  }
86
- function readOne(apiLogicalId, props, out) {
97
+ function readOne(apiLogicalId, props, out, refs) {
87
98
  out.apis.push({
88
99
  logicalId: apiLogicalId,
89
100
  name: stringField(props.Name),
90
101
  schemaSource: readSchemaSource(props),
91
102
  authenticationType: readAuthType(props.Auth)
92
103
  });
93
- out.dataSources.push(...readDataSources(apiLogicalId, props.DataSources));
104
+ out.dataSources.push(
105
+ ...readDataSources(apiLogicalId, props.DataSources, refs)
106
+ );
94
107
  out.functions.push(...readFunctions(apiLogicalId, props.Functions));
95
108
  out.resolvers.push(...readResolvers(apiLogicalId, props.Resolvers));
96
109
  }
@@ -112,7 +125,7 @@ function readAuthType(auth) {
112
125
  const record = asRecord(auth);
113
126
  return record === null ? null : stringField(record.Type);
114
127
  }
115
- function readDataSources(apiLogicalId, raw) {
128
+ function readDataSources(apiLogicalId, raw, refs) {
116
129
  const dataSources = asRecord(raw);
117
130
  if (dataSources === null) {
118
131
  return [];
@@ -129,8 +142,9 @@ function readDataSources(apiLogicalId, raw) {
129
142
  out.push({
130
143
  logicalId: dataSourceId(apiLogicalId, name),
131
144
  apiLogicalId,
145
+ name,
132
146
  type,
133
- lambdaFunctionLogicalId: type === "lambda" && def !== null ? resolveLogicalRef(def.FunctionArn) : null
147
+ lambdaFunctionLogicalId: type === "lambda" && def !== null ? refs.ref(def.FunctionArn) : null
134
148
  });
135
149
  }
136
150
  }
@@ -149,6 +163,7 @@ function readFunctions(apiLogicalId, raw) {
149
163
  apiLogicalId,
150
164
  name,
151
165
  dataSourceLogicalId: referencedDataSourceId(apiLogicalId, def.DataSource),
166
+ dataSourceWritten: null,
152
167
  codeUri: stringField(def.CodeUri),
153
168
  runtime: readRuntime(def.Runtime)
154
169
  });
@@ -182,6 +197,7 @@ function buildResolver(apiLogicalId, typeName, fieldName, def) {
182
197
  typeName,
183
198
  fieldName,
184
199
  dataSourceLogicalId: isPipeline ? null : referencedDataSourceId(apiLogicalId, def.DataSource),
200
+ dataSourceWritten: null,
185
201
  kind: isPipeline ? "PIPELINE" : "UNIT",
186
202
  pipelineFunctionLogicalIds: pipeline,
187
203
  codeUri: stringField(def.CodeUri),
@@ -194,7 +210,7 @@ function readPipeline(apiLogicalId, raw) {
194
210
  }
195
211
  const out = [];
196
212
  for (const entry of raw) {
197
- const name = stringField(entry) ?? resolveLogicalRef(entry);
213
+ const name = stringField(entry) ?? (0, import_manifest_aws2.refTarget)(entry);
198
214
  if (name !== null) {
199
215
  out.push(functionId(apiLogicalId, name));
200
216
  }
@@ -217,15 +233,15 @@ function functionId(apiLogicalId, name) {
217
233
  }
218
234
 
219
235
  // src/cfn.ts
220
- function readAppSyncFromCfn(template) {
236
+ function readAppSyncFromCfn(template, refs = OWN_DOCUMENT) {
221
237
  const resources = template.Resources ?? {};
222
238
  const raw = {
223
- apis: collectApis(resources),
224
- resolvers: collectResolvers(resources),
225
- functions: collectFunctions(resources),
226
- dataSources: collectDataSources(resources)
239
+ apis: collectApis(resources, refs),
240
+ resolvers: collectResolvers(resources, refs),
241
+ functions: collectFunctions(resources, refs),
242
+ dataSources: collectDataSources(resources, refs)
227
243
  };
228
- const sam = readServerlessGraphQLApis(resources);
244
+ const sam = readServerlessGraphQLApis(resources, refs);
229
245
  return {
230
246
  apis: [...raw.apis, ...sam.apis],
231
247
  resolvers: [...raw.resolvers, ...sam.resolvers],
@@ -233,31 +249,63 @@ function readAppSyncFromCfn(template) {
233
249
  dataSources: [...raw.dataSources, ...sam.dataSources]
234
250
  };
235
251
  }
236
- function collectApis(resources) {
252
+ function mergeConfigs(configs) {
253
+ const merged = {
254
+ apis: configs.flatMap((c) => c.apis),
255
+ resolvers: configs.flatMap((c) => c.resolvers),
256
+ functions: configs.flatMap((c) => c.functions),
257
+ dataSources: configs.flatMap((c) => c.dataSources)
258
+ };
259
+ return withDataSourcesByName(merged);
260
+ }
261
+ function withDataSourcesByName(config) {
262
+ const byId = new Set(config.dataSources.map((ds) => ds.logicalId));
263
+ const byName = /* @__PURE__ */ new Map();
264
+ for (const ds of config.dataSources) {
265
+ if (ds.name !== null) {
266
+ byName.set((0, import_manifest_aws3.apiScopedKey)(ds.apiLogicalId, ds.name), ds.logicalId);
267
+ }
268
+ }
269
+ const resolve = (entry) => {
270
+ const { dataSourceLogicalId, dataSourceWritten } = entry;
271
+ if (dataSourceLogicalId !== null && byId.has(dataSourceLogicalId)) {
272
+ return entry;
273
+ }
274
+ const named = dataSourceWritten === null ? void 0 : byName.get((0, import_manifest_aws3.apiScopedKey)(entry.apiLogicalId, dataSourceWritten));
275
+ return named === void 0 ? entry : { ...entry, dataSourceLogicalId: named };
276
+ };
277
+ return {
278
+ ...config,
279
+ resolvers: config.resolvers.map(resolve),
280
+ functions: config.functions.map(resolve)
281
+ };
282
+ }
283
+ function collectApis(resources, refs) {
237
284
  const apis = [];
238
- const schemaByApi = indexSchemasByApi(resources);
285
+ const schemaByApi = indexSchemasByApi(resources, refs);
239
286
  for (const [logicalId, resource] of Object.entries(resources)) {
240
287
  if (resource?.Type !== "AWS::AppSync::GraphQLApi") {
241
288
  continue;
242
289
  }
243
290
  const props = resource.Properties ?? {};
291
+ const id = refs.own(logicalId);
244
292
  apis.push({
245
- logicalId,
293
+ logicalId: id,
246
294
  name: stringField(props.Name),
247
- schemaSource: schemaByApi.get(logicalId) ?? { kind: "absent" },
295
+ schemaSource: schemaByApi.get(id) ?? { kind: "absent" },
248
296
  authenticationType: stringField(props.AuthenticationType)
249
297
  });
250
298
  }
251
299
  return apis;
252
300
  }
253
- function indexSchemasByApi(resources) {
301
+ function indexSchemasByApi(resources, refs) {
254
302
  const out = /* @__PURE__ */ new Map();
255
303
  for (const resource of Object.values(resources)) {
256
304
  if (resource?.Type !== "AWS::AppSync::GraphQLSchema") {
257
305
  continue;
258
306
  }
259
307
  const props = resource.Properties ?? {};
260
- const apiRef = resolveLogicalRef(props.ApiId);
308
+ const apiRef = refs.ref(props.ApiId);
261
309
  if (apiRef === null) {
262
310
  continue;
263
311
  }
@@ -277,7 +325,7 @@ function indexSchemasByApi(resources) {
277
325
  }
278
326
  return out;
279
327
  }
280
- function collectResolvers(resources) {
328
+ function collectResolvers(resources, refs) {
281
329
  const out = [];
282
330
  for (const [logicalId, resource] of Object.entries(resources)) {
283
331
  if (resource?.Type !== "AWS::AppSync::Resolver") {
@@ -290,20 +338,24 @@ function collectResolvers(resources) {
290
338
  continue;
291
339
  }
292
340
  out.push({
293
- logicalId,
294
- apiLogicalId: resolveLogicalRef(props.ApiId),
341
+ logicalId: refs.own(logicalId),
342
+ apiLogicalId: refs.ref(props.ApiId),
295
343
  typeName,
296
344
  fieldName,
297
- dataSourceLogicalId: resolveLogicalRef(props.DataSourceName),
345
+ dataSourceLogicalId: refs.ref(props.DataSourceName),
346
+ dataSourceWritten: stringField(props.DataSourceName),
298
347
  kind: resolverKind(stringField(props.Kind)),
299
- pipelineFunctionLogicalIds: pipelineFunctionIds(props.PipelineConfig),
348
+ pipelineFunctionLogicalIds: pipelineFunctionIds(
349
+ props.PipelineConfig,
350
+ refs
351
+ ),
300
352
  codeUri: null,
301
353
  runtime: null
302
354
  });
303
355
  }
304
356
  return out;
305
357
  }
306
- function pipelineFunctionIds(pipelineConfig) {
358
+ function pipelineFunctionIds(pipelineConfig, refs) {
307
359
  const config = asRecord(pipelineConfig);
308
360
  if (config === null) {
309
361
  return [];
@@ -314,14 +366,14 @@ function pipelineFunctionIds(pipelineConfig) {
314
366
  }
315
367
  const out = [];
316
368
  for (const entry of functions) {
317
- const ref = resolveLogicalRef(entry);
369
+ const ref = refs.ref(entry);
318
370
  if (ref !== null) {
319
371
  out.push(ref);
320
372
  }
321
373
  }
322
374
  return out;
323
375
  }
324
- function collectFunctions(resources) {
376
+ function collectFunctions(resources, refs) {
325
377
  const out = [];
326
378
  for (const [logicalId, resource] of Object.entries(resources)) {
327
379
  if (resource?.Type !== "AWS::AppSync::FunctionConfiguration") {
@@ -329,17 +381,18 @@ function collectFunctions(resources) {
329
381
  }
330
382
  const props = resource.Properties ?? {};
331
383
  out.push({
332
- logicalId,
333
- apiLogicalId: resolveLogicalRef(props.ApiId),
384
+ logicalId: refs.own(logicalId),
385
+ apiLogicalId: refs.ref(props.ApiId),
334
386
  name: stringField(props.Name),
335
- dataSourceLogicalId: resolveLogicalRef(props.DataSourceName),
387
+ dataSourceLogicalId: refs.ref(props.DataSourceName),
388
+ dataSourceWritten: stringField(props.DataSourceName),
336
389
  codeUri: null,
337
390
  runtime: null
338
391
  });
339
392
  }
340
393
  return out;
341
394
  }
342
- function collectDataSources(resources) {
395
+ function collectDataSources(resources, refs) {
343
396
  const out = [];
344
397
  for (const [logicalId, resource] of Object.entries(resources)) {
345
398
  if (resource?.Type !== "AWS::AppSync::DataSource") {
@@ -348,10 +401,11 @@ function collectDataSources(resources) {
348
401
  const props = resource.Properties ?? {};
349
402
  const lambdaConfig = asRecord(props.LambdaConfig);
350
403
  out.push({
351
- logicalId,
352
- apiLogicalId: resolveLogicalRef(props.ApiId),
404
+ logicalId: refs.own(logicalId),
405
+ apiLogicalId: refs.ref(props.ApiId),
406
+ name: stringField(props.Name),
353
407
  type: dataSourceType(stringField(props.Type)),
354
- lambdaFunctionLogicalId: lambdaConfig === null ? null : resolveLogicalRef(lambdaConfig.LambdaFunctionArn)
408
+ lambdaFunctionLogicalId: lambdaConfig === null ? null : refs.ref(lambdaConfig.LambdaFunctionArn)
355
409
  });
356
410
  }
357
411
  return out;
@@ -720,18 +774,31 @@ function buildTransitions(ownerKey, resolver, field) {
720
774
 
721
775
  // src/index.ts
722
776
  function appsyncToSummaries(template, options = {}) {
723
- const config = readAppSyncFromCfn(template);
777
+ const config = mergeConfigs([readAppSyncFromCfn(template)]);
724
778
  const baseDir = options.baseDir ?? null;
725
- const resolvedByApi = resolveSchemas(config, baseDir);
779
+ return summariesOf(
780
+ config,
781
+ new Map(config.apis.map((api) => [api.logicalId, baseDir])),
782
+ options.source
783
+ );
784
+ }
785
+ function summariesOf(config, baseDirByApi, source) {
786
+ const resolvedByApi = resolveSchemas(config, baseDirByApi);
726
787
  const schemasByApi = indexSchemas(resolvedByApi);
727
788
  return buildResolverSummaries(config, resolvedByApi, schemasByApi, {
728
- ...options.source !== void 0 ? { source: options.source } : {}
789
+ ...source !== void 0 ? { source } : {}
729
790
  });
730
791
  }
731
- function resolveSchemas(config, baseDir) {
792
+ function resolveSchemas(config, baseDirByApi) {
732
793
  const out = /* @__PURE__ */ new Map();
733
794
  for (const api of config.apis) {
734
- out.set(api.logicalId, resolveSchemaSource(api.schemaSource, baseDir));
795
+ out.set(
796
+ api.logicalId,
797
+ resolveSchemaSource(
798
+ api.schemaSource,
799
+ baseDirByApi.get(api.logicalId) ?? null
800
+ )
801
+ );
735
802
  }
736
803
  return out;
737
804
  }
@@ -745,12 +812,34 @@ function indexSchemas(resolvedByApi) {
745
812
  return out;
746
813
  }
747
814
  function appsyncFileToSummaries(filePath, options = {}) {
748
- const template = (0, import_manifest_aws2.loadCloudFormationTemplate)(filePath);
749
- const root = options.projectRoot ?? (0, import_files.defaultProjectRoot)(filePath);
750
- return appsyncToSummaries(template, {
751
- source: options.source ?? (0, import_files.pathInProject)(filePath, root),
752
- baseDir: options.baseDir ?? import_node_path2.default.dirname(filePath)
815
+ const tree = (0, import_manifest_aws4.loadTemplateTree)(filePath);
816
+ for (const stack of tree.unfollowed) {
817
+ const message = (0, import_manifest_aws4.unfollowedStackMessage)(stack);
818
+ if (options.warnings !== void 0) {
819
+ options.warnings.push(message);
820
+ continue;
821
+ }
822
+ process.stderr.write(`[suss] appsync: ${message}
823
+ `);
824
+ }
825
+ const baseDirByApi = /* @__PURE__ */ new Map();
826
+ const configs = tree.documents.map((document) => {
827
+ const config = readAppSyncFromCfn(
828
+ document.template,
829
+ documentRefsOf(document)
830
+ );
831
+ const baseDir = document.stackPath.length === 0 && options.baseDir !== void 0 ? options.baseDir : import_node_path2.default.dirname(document.path);
832
+ for (const api of config.apis) {
833
+ baseDirByApi.set(api.logicalId, baseDir);
834
+ }
835
+ return config;
753
836
  });
837
+ const root = options.projectRoot ?? (0, import_files.defaultProjectRoot)(filePath);
838
+ return summariesOf(
839
+ mergeConfigs(configs),
840
+ baseDirByApi,
841
+ options.source ?? (0, import_files.pathInProject)(filePath, root)
842
+ );
754
843
  }
755
844
  // Annotate the CommonJS export names for ESM import in node:
756
845
  0 && (module.exports = {
package/dist/index.d.cts CHANGED
@@ -5,9 +5,12 @@ import { BehavioralSummary } from '@suss/behavioral-ir';
5
5
  * SAM AWS::Serverless::GraphQLApi reader finds, so both ways of writing
6
6
  * an API reach the summary builder as one model.
7
7
  *
8
- * A value written with a dynamic intrinsic, such as `!Ref` to a
9
- * parameter, is left unresolved and reported. The reader never guesses it.
8
+ * A value written with a dynamic intrinsic is left unresolved and
9
+ * reported. The reader never guesses it. A `!Ref` to a parameter the
10
+ * parent stack sets to a resource is read as that resource, which is how
11
+ * a nested stack's resolvers find the API their parent declares.
10
12
  */
13
+
11
14
  interface CfnTemplate {
12
15
  Resources?: Record<string, CfnResource | undefined>;
13
16
  }
@@ -38,6 +41,8 @@ interface AppsyncToSummariesOptions {
38
41
  interface AppsyncFileToSummariesOptions extends AppsyncToSummariesOptions {
39
42
  /** Absolute. The directory the template's path is measured from. */
40
43
  projectRoot?: string;
44
+ /** Collects each child template that could not be opened. Without it, each goes to stderr. */
45
+ warnings?: string[];
41
46
  }
42
47
  /**
43
48
  * Converts a template already in memory, such as a CDK
@@ -45,8 +50,11 @@ interface AppsyncFileToSummariesOptions extends AppsyncToSummariesOptions {
45
50
  */
46
51
  declare function appsyncToSummaries(template: CfnTemplate, options?: AppsyncToSummariesOptions): BehavioralSummary[];
47
52
  /**
48
- * Reads a CloudFormation or SAM template, in JSON or YAML, and converts
49
- * it. A relative schema path resolves against the template's directory.
53
+ * Reads a CloudFormation or SAM template, in JSON or YAML, and every
54
+ * template it embeds as a nested stack, and converts them as one API
55
+ * surface. A child's resolvers often belong to an API the root declares
56
+ * and passes down as a parameter. A relative schema path resolves
57
+ * against the directory of the template that declares it.
50
58
  */
51
59
  declare function appsyncFileToSummaries(filePath: string, options?: AppsyncFileToSummariesOptions): BehavioralSummary[];
52
60
 
package/dist/index.d.ts CHANGED
@@ -5,9 +5,12 @@ import { BehavioralSummary } from '@suss/behavioral-ir';
5
5
  * SAM AWS::Serverless::GraphQLApi reader finds, so both ways of writing
6
6
  * an API reach the summary builder as one model.
7
7
  *
8
- * A value written with a dynamic intrinsic, such as `!Ref` to a
9
- * parameter, is left unresolved and reported. The reader never guesses it.
8
+ * A value written with a dynamic intrinsic is left unresolved and
9
+ * reported. The reader never guesses it. A `!Ref` to a parameter the
10
+ * parent stack sets to a resource is read as that resource, which is how
11
+ * a nested stack's resolvers find the API their parent declares.
10
12
  */
13
+
11
14
  interface CfnTemplate {
12
15
  Resources?: Record<string, CfnResource | undefined>;
13
16
  }
@@ -38,6 +41,8 @@ interface AppsyncToSummariesOptions {
38
41
  interface AppsyncFileToSummariesOptions extends AppsyncToSummariesOptions {
39
42
  /** Absolute. The directory the template's path is measured from. */
40
43
  projectRoot?: string;
44
+ /** Collects each child template that could not be opened. Without it, each goes to stderr. */
45
+ warnings?: string[];
41
46
  }
42
47
  /**
43
48
  * Converts a template already in memory, such as a CDK
@@ -45,8 +50,11 @@ interface AppsyncFileToSummariesOptions extends AppsyncToSummariesOptions {
45
50
  */
46
51
  declare function appsyncToSummaries(template: CfnTemplate, options?: AppsyncToSummariesOptions): BehavioralSummary[];
47
52
  /**
48
- * Reads a CloudFormation or SAM template, in JSON or YAML, and converts
49
- * it. A relative schema path resolves against the template's directory.
53
+ * Reads a CloudFormation or SAM template, in JSON or YAML, and every
54
+ * template it embeds as a nested stack, and converts them as one API
55
+ * surface. A child's resolvers often belong to an API the root declares
56
+ * and passes down as a parameter. A relative schema path resolves
57
+ * against the directory of the template that declares it.
50
58
  */
51
59
  declare function appsyncFileToSummaries(filePath: string, options?: AppsyncFileToSummariesOptions): BehavioralSummary[];
52
60
 
package/dist/index.js CHANGED
@@ -1,12 +1,26 @@
1
1
  // src/index.ts
2
2
  import path2 from "path";
3
3
  import { defaultProjectRoot, pathInProject } from "@suss/behavioral-ir/files";
4
- import { loadCloudFormationTemplate } from "@suss/manifest-aws";
4
+ import { loadTemplateTree, unfollowedStackMessage } from "@suss/manifest-aws";
5
+
6
+ // src/cfn.ts
7
+ import { apiScopedKey } from "@suss/manifest-aws";
5
8
 
6
9
  // src/refs.ts
7
- import { refTarget } from "@suss/manifest-aws";
8
- function resolveLogicalRef(value) {
9
- return refTarget(value);
10
+ import {
11
+ documentRef,
12
+ qualifiedLogicalId,
13
+ refTarget
14
+ } from "@suss/manifest-aws";
15
+ var OWN_DOCUMENT = {
16
+ own: (logicalId) => logicalId,
17
+ ref: (value) => refTarget(value)
18
+ };
19
+ function documentRefsOf(document) {
20
+ return {
21
+ own: (logicalId) => qualifiedLogicalId(document.stackPath, logicalId),
22
+ ref: (value) => documentRef(document, value)
23
+ };
10
24
  }
11
25
  function stringField(value) {
12
26
  return typeof value === "string" ? value : null;
@@ -19,6 +33,7 @@ function asRecord(value) {
19
33
  }
20
34
 
21
35
  // src/sam.ts
36
+ import { refTarget as refTarget2 } from "@suss/manifest-aws";
22
37
  var RESOLVER_ROOT_TYPES = ["Query", "Mutation", "Subscription"];
23
38
  var DATA_SOURCE_CATEGORIES = {
24
39
  Lambda: "lambda",
@@ -33,7 +48,7 @@ var DATA_SOURCE_CATEGORIES = {
33
48
  RelationalDatabases: "relational",
34
49
  None: "none"
35
50
  };
36
- function readServerlessGraphQLApis(resources) {
51
+ function readServerlessGraphQLApis(resources, refs) {
37
52
  const out = {
38
53
  apis: [],
39
54
  resolvers: [],
@@ -44,18 +59,20 @@ function readServerlessGraphQLApis(resources) {
44
59
  if (resource?.Type !== "AWS::Serverless::GraphQLApi") {
45
60
  continue;
46
61
  }
47
- readOne(logicalId, resource.Properties ?? {}, out);
62
+ readOne(refs.own(logicalId), resource.Properties ?? {}, out, refs);
48
63
  }
49
64
  return out;
50
65
  }
51
- function readOne(apiLogicalId, props, out) {
66
+ function readOne(apiLogicalId, props, out, refs) {
52
67
  out.apis.push({
53
68
  logicalId: apiLogicalId,
54
69
  name: stringField(props.Name),
55
70
  schemaSource: readSchemaSource(props),
56
71
  authenticationType: readAuthType(props.Auth)
57
72
  });
58
- out.dataSources.push(...readDataSources(apiLogicalId, props.DataSources));
73
+ out.dataSources.push(
74
+ ...readDataSources(apiLogicalId, props.DataSources, refs)
75
+ );
59
76
  out.functions.push(...readFunctions(apiLogicalId, props.Functions));
60
77
  out.resolvers.push(...readResolvers(apiLogicalId, props.Resolvers));
61
78
  }
@@ -77,7 +94,7 @@ function readAuthType(auth) {
77
94
  const record = asRecord(auth);
78
95
  return record === null ? null : stringField(record.Type);
79
96
  }
80
- function readDataSources(apiLogicalId, raw) {
97
+ function readDataSources(apiLogicalId, raw, refs) {
81
98
  const dataSources = asRecord(raw);
82
99
  if (dataSources === null) {
83
100
  return [];
@@ -94,8 +111,9 @@ function readDataSources(apiLogicalId, raw) {
94
111
  out.push({
95
112
  logicalId: dataSourceId(apiLogicalId, name),
96
113
  apiLogicalId,
114
+ name,
97
115
  type,
98
- lambdaFunctionLogicalId: type === "lambda" && def !== null ? resolveLogicalRef(def.FunctionArn) : null
116
+ lambdaFunctionLogicalId: type === "lambda" && def !== null ? refs.ref(def.FunctionArn) : null
99
117
  });
100
118
  }
101
119
  }
@@ -114,6 +132,7 @@ function readFunctions(apiLogicalId, raw) {
114
132
  apiLogicalId,
115
133
  name,
116
134
  dataSourceLogicalId: referencedDataSourceId(apiLogicalId, def.DataSource),
135
+ dataSourceWritten: null,
117
136
  codeUri: stringField(def.CodeUri),
118
137
  runtime: readRuntime(def.Runtime)
119
138
  });
@@ -147,6 +166,7 @@ function buildResolver(apiLogicalId, typeName, fieldName, def) {
147
166
  typeName,
148
167
  fieldName,
149
168
  dataSourceLogicalId: isPipeline ? null : referencedDataSourceId(apiLogicalId, def.DataSource),
169
+ dataSourceWritten: null,
150
170
  kind: isPipeline ? "PIPELINE" : "UNIT",
151
171
  pipelineFunctionLogicalIds: pipeline,
152
172
  codeUri: stringField(def.CodeUri),
@@ -159,7 +179,7 @@ function readPipeline(apiLogicalId, raw) {
159
179
  }
160
180
  const out = [];
161
181
  for (const entry of raw) {
162
- const name = stringField(entry) ?? resolveLogicalRef(entry);
182
+ const name = stringField(entry) ?? refTarget2(entry);
163
183
  if (name !== null) {
164
184
  out.push(functionId(apiLogicalId, name));
165
185
  }
@@ -182,15 +202,15 @@ function functionId(apiLogicalId, name) {
182
202
  }
183
203
 
184
204
  // src/cfn.ts
185
- function readAppSyncFromCfn(template) {
205
+ function readAppSyncFromCfn(template, refs = OWN_DOCUMENT) {
186
206
  const resources = template.Resources ?? {};
187
207
  const raw = {
188
- apis: collectApis(resources),
189
- resolvers: collectResolvers(resources),
190
- functions: collectFunctions(resources),
191
- dataSources: collectDataSources(resources)
208
+ apis: collectApis(resources, refs),
209
+ resolvers: collectResolvers(resources, refs),
210
+ functions: collectFunctions(resources, refs),
211
+ dataSources: collectDataSources(resources, refs)
192
212
  };
193
- const sam = readServerlessGraphQLApis(resources);
213
+ const sam = readServerlessGraphQLApis(resources, refs);
194
214
  return {
195
215
  apis: [...raw.apis, ...sam.apis],
196
216
  resolvers: [...raw.resolvers, ...sam.resolvers],
@@ -198,31 +218,63 @@ function readAppSyncFromCfn(template) {
198
218
  dataSources: [...raw.dataSources, ...sam.dataSources]
199
219
  };
200
220
  }
201
- function collectApis(resources) {
221
+ function mergeConfigs(configs) {
222
+ const merged = {
223
+ apis: configs.flatMap((c) => c.apis),
224
+ resolvers: configs.flatMap((c) => c.resolvers),
225
+ functions: configs.flatMap((c) => c.functions),
226
+ dataSources: configs.flatMap((c) => c.dataSources)
227
+ };
228
+ return withDataSourcesByName(merged);
229
+ }
230
+ function withDataSourcesByName(config) {
231
+ const byId = new Set(config.dataSources.map((ds) => ds.logicalId));
232
+ const byName = /* @__PURE__ */ new Map();
233
+ for (const ds of config.dataSources) {
234
+ if (ds.name !== null) {
235
+ byName.set(apiScopedKey(ds.apiLogicalId, ds.name), ds.logicalId);
236
+ }
237
+ }
238
+ const resolve = (entry) => {
239
+ const { dataSourceLogicalId, dataSourceWritten } = entry;
240
+ if (dataSourceLogicalId !== null && byId.has(dataSourceLogicalId)) {
241
+ return entry;
242
+ }
243
+ const named = dataSourceWritten === null ? void 0 : byName.get(apiScopedKey(entry.apiLogicalId, dataSourceWritten));
244
+ return named === void 0 ? entry : { ...entry, dataSourceLogicalId: named };
245
+ };
246
+ return {
247
+ ...config,
248
+ resolvers: config.resolvers.map(resolve),
249
+ functions: config.functions.map(resolve)
250
+ };
251
+ }
252
+ function collectApis(resources, refs) {
202
253
  const apis = [];
203
- const schemaByApi = indexSchemasByApi(resources);
254
+ const schemaByApi = indexSchemasByApi(resources, refs);
204
255
  for (const [logicalId, resource] of Object.entries(resources)) {
205
256
  if (resource?.Type !== "AWS::AppSync::GraphQLApi") {
206
257
  continue;
207
258
  }
208
259
  const props = resource.Properties ?? {};
260
+ const id = refs.own(logicalId);
209
261
  apis.push({
210
- logicalId,
262
+ logicalId: id,
211
263
  name: stringField(props.Name),
212
- schemaSource: schemaByApi.get(logicalId) ?? { kind: "absent" },
264
+ schemaSource: schemaByApi.get(id) ?? { kind: "absent" },
213
265
  authenticationType: stringField(props.AuthenticationType)
214
266
  });
215
267
  }
216
268
  return apis;
217
269
  }
218
- function indexSchemasByApi(resources) {
270
+ function indexSchemasByApi(resources, refs) {
219
271
  const out = /* @__PURE__ */ new Map();
220
272
  for (const resource of Object.values(resources)) {
221
273
  if (resource?.Type !== "AWS::AppSync::GraphQLSchema") {
222
274
  continue;
223
275
  }
224
276
  const props = resource.Properties ?? {};
225
- const apiRef = resolveLogicalRef(props.ApiId);
277
+ const apiRef = refs.ref(props.ApiId);
226
278
  if (apiRef === null) {
227
279
  continue;
228
280
  }
@@ -242,7 +294,7 @@ function indexSchemasByApi(resources) {
242
294
  }
243
295
  return out;
244
296
  }
245
- function collectResolvers(resources) {
297
+ function collectResolvers(resources, refs) {
246
298
  const out = [];
247
299
  for (const [logicalId, resource] of Object.entries(resources)) {
248
300
  if (resource?.Type !== "AWS::AppSync::Resolver") {
@@ -255,20 +307,24 @@ function collectResolvers(resources) {
255
307
  continue;
256
308
  }
257
309
  out.push({
258
- logicalId,
259
- apiLogicalId: resolveLogicalRef(props.ApiId),
310
+ logicalId: refs.own(logicalId),
311
+ apiLogicalId: refs.ref(props.ApiId),
260
312
  typeName,
261
313
  fieldName,
262
- dataSourceLogicalId: resolveLogicalRef(props.DataSourceName),
314
+ dataSourceLogicalId: refs.ref(props.DataSourceName),
315
+ dataSourceWritten: stringField(props.DataSourceName),
263
316
  kind: resolverKind(stringField(props.Kind)),
264
- pipelineFunctionLogicalIds: pipelineFunctionIds(props.PipelineConfig),
317
+ pipelineFunctionLogicalIds: pipelineFunctionIds(
318
+ props.PipelineConfig,
319
+ refs
320
+ ),
265
321
  codeUri: null,
266
322
  runtime: null
267
323
  });
268
324
  }
269
325
  return out;
270
326
  }
271
- function pipelineFunctionIds(pipelineConfig) {
327
+ function pipelineFunctionIds(pipelineConfig, refs) {
272
328
  const config = asRecord(pipelineConfig);
273
329
  if (config === null) {
274
330
  return [];
@@ -279,14 +335,14 @@ function pipelineFunctionIds(pipelineConfig) {
279
335
  }
280
336
  const out = [];
281
337
  for (const entry of functions) {
282
- const ref = resolveLogicalRef(entry);
338
+ const ref = refs.ref(entry);
283
339
  if (ref !== null) {
284
340
  out.push(ref);
285
341
  }
286
342
  }
287
343
  return out;
288
344
  }
289
- function collectFunctions(resources) {
345
+ function collectFunctions(resources, refs) {
290
346
  const out = [];
291
347
  for (const [logicalId, resource] of Object.entries(resources)) {
292
348
  if (resource?.Type !== "AWS::AppSync::FunctionConfiguration") {
@@ -294,17 +350,18 @@ function collectFunctions(resources) {
294
350
  }
295
351
  const props = resource.Properties ?? {};
296
352
  out.push({
297
- logicalId,
298
- apiLogicalId: resolveLogicalRef(props.ApiId),
353
+ logicalId: refs.own(logicalId),
354
+ apiLogicalId: refs.ref(props.ApiId),
299
355
  name: stringField(props.Name),
300
- dataSourceLogicalId: resolveLogicalRef(props.DataSourceName),
356
+ dataSourceLogicalId: refs.ref(props.DataSourceName),
357
+ dataSourceWritten: stringField(props.DataSourceName),
301
358
  codeUri: null,
302
359
  runtime: null
303
360
  });
304
361
  }
305
362
  return out;
306
363
  }
307
- function collectDataSources(resources) {
364
+ function collectDataSources(resources, refs) {
308
365
  const out = [];
309
366
  for (const [logicalId, resource] of Object.entries(resources)) {
310
367
  if (resource?.Type !== "AWS::AppSync::DataSource") {
@@ -313,10 +370,11 @@ function collectDataSources(resources) {
313
370
  const props = resource.Properties ?? {};
314
371
  const lambdaConfig = asRecord(props.LambdaConfig);
315
372
  out.push({
316
- logicalId,
317
- apiLogicalId: resolveLogicalRef(props.ApiId),
373
+ logicalId: refs.own(logicalId),
374
+ apiLogicalId: refs.ref(props.ApiId),
375
+ name: stringField(props.Name),
318
376
  type: dataSourceType(stringField(props.Type)),
319
- lambdaFunctionLogicalId: lambdaConfig === null ? null : resolveLogicalRef(lambdaConfig.LambdaFunctionArn)
377
+ lambdaFunctionLogicalId: lambdaConfig === null ? null : refs.ref(lambdaConfig.LambdaFunctionArn)
320
378
  });
321
379
  }
322
380
  return out;
@@ -693,18 +751,31 @@ function buildTransitions(ownerKey, resolver, field) {
693
751
 
694
752
  // src/index.ts
695
753
  function appsyncToSummaries(template, options = {}) {
696
- const config = readAppSyncFromCfn(template);
754
+ const config = mergeConfigs([readAppSyncFromCfn(template)]);
697
755
  const baseDir = options.baseDir ?? null;
698
- const resolvedByApi = resolveSchemas(config, baseDir);
756
+ return summariesOf(
757
+ config,
758
+ new Map(config.apis.map((api) => [api.logicalId, baseDir])),
759
+ options.source
760
+ );
761
+ }
762
+ function summariesOf(config, baseDirByApi, source) {
763
+ const resolvedByApi = resolveSchemas(config, baseDirByApi);
699
764
  const schemasByApi = indexSchemas(resolvedByApi);
700
765
  return buildResolverSummaries(config, resolvedByApi, schemasByApi, {
701
- ...options.source !== void 0 ? { source: options.source } : {}
766
+ ...source !== void 0 ? { source } : {}
702
767
  });
703
768
  }
704
- function resolveSchemas(config, baseDir) {
769
+ function resolveSchemas(config, baseDirByApi) {
705
770
  const out = /* @__PURE__ */ new Map();
706
771
  for (const api of config.apis) {
707
- out.set(api.logicalId, resolveSchemaSource(api.schemaSource, baseDir));
772
+ out.set(
773
+ api.logicalId,
774
+ resolveSchemaSource(
775
+ api.schemaSource,
776
+ baseDirByApi.get(api.logicalId) ?? null
777
+ )
778
+ );
708
779
  }
709
780
  return out;
710
781
  }
@@ -718,12 +789,34 @@ function indexSchemas(resolvedByApi) {
718
789
  return out;
719
790
  }
720
791
  function appsyncFileToSummaries(filePath, options = {}) {
721
- const template = loadCloudFormationTemplate(filePath);
722
- const root = options.projectRoot ?? defaultProjectRoot(filePath);
723
- return appsyncToSummaries(template, {
724
- source: options.source ?? pathInProject(filePath, root),
725
- baseDir: options.baseDir ?? path2.dirname(filePath)
792
+ const tree = loadTemplateTree(filePath);
793
+ for (const stack of tree.unfollowed) {
794
+ const message = unfollowedStackMessage(stack);
795
+ if (options.warnings !== void 0) {
796
+ options.warnings.push(message);
797
+ continue;
798
+ }
799
+ process.stderr.write(`[suss] appsync: ${message}
800
+ `);
801
+ }
802
+ const baseDirByApi = /* @__PURE__ */ new Map();
803
+ const configs = tree.documents.map((document) => {
804
+ const config = readAppSyncFromCfn(
805
+ document.template,
806
+ documentRefsOf(document)
807
+ );
808
+ const baseDir = document.stackPath.length === 0 && options.baseDir !== void 0 ? options.baseDir : path2.dirname(document.path);
809
+ for (const api of config.apis) {
810
+ baseDirByApi.set(api.logicalId, baseDir);
811
+ }
812
+ return config;
726
813
  });
814
+ const root = options.projectRoot ?? defaultProjectRoot(filePath);
815
+ return summariesOf(
816
+ mergeConfigs(configs),
817
+ baseDirByApi,
818
+ options.source ?? pathInProject(filePath, root)
819
+ );
727
820
  }
728
821
  export {
729
822
  appsyncFileToSummaries,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suss/contract-appsync",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "type": "module",
5
5
  "description": "Turn the AppSync API in a CloudFormation or SAM template into a suss contract and check it against the resolvers and clients on either side of it.",
6
6
  "keywords": [
@@ -48,9 +48,9 @@
48
48
  "typecheck": "tsc --noEmit"
49
49
  },
50
50
  "dependencies": {
51
- "@suss/behavioral-ir": "0.36.0",
52
- "@suss/contract-graphql": "0.36.0",
53
- "@suss/manifest-aws": "0.36.0",
51
+ "@suss/behavioral-ir": "0.37.0",
52
+ "@suss/contract-graphql": "0.37.0",
53
+ "@suss/manifest-aws": "0.37.0",
54
54
  "graphql": "^16.8.0"
55
55
  },
56
56
  "devDependencies": {