@go-to-k/cdkd 0.131.0 → 0.132.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 +23 -14
- package/dist/cli.js +1686 -10
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -40433,20 +40433,74 @@ function discoverRestV1Method(logicalId, resource, template, stackName) {
|
|
|
40433
40433
|
};
|
|
40434
40434
|
const integrationType = integration["Type"];
|
|
40435
40435
|
if (integrationType === "MOCK") {
|
|
40436
|
-
|
|
40437
|
-
|
|
40436
|
+
if (httpMethod === "OPTIONS") {
|
|
40437
|
+
const preflight = extractRestV1MockCorsConfig(integration);
|
|
40438
|
+
if (preflight) return [{
|
|
40439
|
+
...baseRoute,
|
|
40440
|
+
method: "OPTIONS",
|
|
40441
|
+
pathPattern: path,
|
|
40442
|
+
lambdaLogicalId: "",
|
|
40443
|
+
mockCors: preflight
|
|
40444
|
+
}];
|
|
40445
|
+
}
|
|
40446
|
+
const config = buildMockIntegrationConfig(integration);
|
|
40447
|
+
return [{
|
|
40448
|
+
...baseRoute,
|
|
40449
|
+
method: httpMethod,
|
|
40450
|
+
pathPattern: path,
|
|
40451
|
+
lambdaLogicalId: "",
|
|
40452
|
+
restV1Integration: config
|
|
40453
|
+
}];
|
|
40454
|
+
}
|
|
40455
|
+
if (integrationType === "HTTP_PROXY") {
|
|
40456
|
+
const config = buildHttpProxyIntegrationConfig(integration, stackName, logicalId);
|
|
40457
|
+
if (config.kind === "unsupported") return [{
|
|
40458
|
+
...baseRoute,
|
|
40459
|
+
method: httpMethod,
|
|
40460
|
+
pathPattern: path,
|
|
40461
|
+
lambdaLogicalId: "",
|
|
40462
|
+
unsupported: { reason: config.reason }
|
|
40463
|
+
}];
|
|
40464
|
+
return [{
|
|
40465
|
+
...baseRoute,
|
|
40466
|
+
method: httpMethod,
|
|
40467
|
+
pathPattern: path,
|
|
40468
|
+
lambdaLogicalId: "",
|
|
40469
|
+
restV1Integration: config.config
|
|
40470
|
+
}];
|
|
40471
|
+
}
|
|
40472
|
+
if (integrationType === "HTTP") {
|
|
40473
|
+
const config = buildHttpIntegrationConfig(integration, stackName, logicalId);
|
|
40474
|
+
if (config.kind === "unsupported") return [{
|
|
40438
40475
|
...baseRoute,
|
|
40439
|
-
method:
|
|
40476
|
+
method: httpMethod,
|
|
40440
40477
|
pathPattern: path,
|
|
40441
40478
|
lambdaLogicalId: "",
|
|
40442
|
-
|
|
40479
|
+
unsupported: { reason: config.reason }
|
|
40443
40480
|
}];
|
|
40444
40481
|
return [{
|
|
40445
40482
|
...baseRoute,
|
|
40446
40483
|
method: httpMethod,
|
|
40447
40484
|
pathPattern: path,
|
|
40448
40485
|
lambdaLogicalId: "",
|
|
40449
|
-
|
|
40486
|
+
restV1Integration: config.config
|
|
40487
|
+
}];
|
|
40488
|
+
}
|
|
40489
|
+
if (integrationType === "AWS") {
|
|
40490
|
+
const config = buildAwsIntegrationConfig(integration, stackName, logicalId);
|
|
40491
|
+
if (config.kind === "unsupported") return [{
|
|
40492
|
+
...baseRoute,
|
|
40493
|
+
method: httpMethod,
|
|
40494
|
+
pathPattern: path,
|
|
40495
|
+
lambdaLogicalId: "",
|
|
40496
|
+
unsupported: { reason: config.reason }
|
|
40497
|
+
}];
|
|
40498
|
+
return [{
|
|
40499
|
+
...baseRoute,
|
|
40500
|
+
method: httpMethod,
|
|
40501
|
+
pathPattern: path,
|
|
40502
|
+
lambdaLogicalId: config.config.lambdaLogicalId,
|
|
40503
|
+
restV1Integration: config.config
|
|
40450
40504
|
}];
|
|
40451
40505
|
}
|
|
40452
40506
|
if (integrationType !== "AWS_PROXY") return [{
|
|
@@ -40454,7 +40508,7 @@ function discoverRestV1Method(logicalId, resource, template, stackName) {
|
|
|
40454
40508
|
method: httpMethod,
|
|
40455
40509
|
pathPattern: path,
|
|
40456
40510
|
lambdaLogicalId: "",
|
|
40457
|
-
unsupported: { reason: `${stackName}/${logicalId}: REST v1 integration type '${String(integrationType)}'
|
|
40511
|
+
unsupported: { reason: `${stackName}/${logicalId}: unknown REST v1 integration type '${String(integrationType)}' (expected AWS_PROXY / AWS / HTTP / HTTP_PROXY / MOCK).` }
|
|
40458
40512
|
}];
|
|
40459
40513
|
const integrationUri = integration["Uri"];
|
|
40460
40514
|
const arnOutcome = resolveLambdaArnOutcome(integrationUri);
|
|
@@ -40524,6 +40578,188 @@ function extractRestV1MockCorsConfig(integration) {
|
|
|
40524
40578
|
};
|
|
40525
40579
|
}
|
|
40526
40580
|
/**
|
|
40581
|
+
* Marker sequence on a Lambda invoke ARN — used to tell apart REST v1
|
|
40582
|
+
* `AWS` integrations whose backend is Lambda (`functions/<arn>/invocations`)
|
|
40583
|
+
* from other AWS-service integrations (`:s3:path/...`, `:sqs:path/...`).
|
|
40584
|
+
* Closes #457's AWS-vs-Lambda discrimination.
|
|
40585
|
+
*/
|
|
40586
|
+
const LAMBDA_INVOKE_PATH = ":lambda:path/2015-03-31/functions/";
|
|
40587
|
+
/**
|
|
40588
|
+
* Build a MOCK integration config for `cdkd local start-api` dispatch.
|
|
40589
|
+
*
|
|
40590
|
+
* Pulls `Integration.RequestTemplates['application/json']` (drives MOCK
|
|
40591
|
+
* status-code selection — AWS reads `{"statusCode": N}` from the rendered
|
|
40592
|
+
* template) and `Integration.IntegrationResponses[]` (drives the shaped
|
|
40593
|
+
* response).
|
|
40594
|
+
*/
|
|
40595
|
+
function buildMockIntegrationConfig(integration) {
|
|
40596
|
+
const requestTemplate = pickStringFromRecord(integration["RequestTemplates"], "application/json");
|
|
40597
|
+
const responses = readIntegrationResponses(integration);
|
|
40598
|
+
return {
|
|
40599
|
+
kind: "mock",
|
|
40600
|
+
requestTemplate: requestTemplate ?? void 0,
|
|
40601
|
+
responses
|
|
40602
|
+
};
|
|
40603
|
+
}
|
|
40604
|
+
/**
|
|
40605
|
+
* Build a HTTP_PROXY integration config. The Uri must be a literal
|
|
40606
|
+
* string at template-author time — `Fn::Sub` shapes with literal
|
|
40607
|
+
* placeholders are rare and unsupported in v1 (surfaces as a 501 with
|
|
40608
|
+
* a clear reason).
|
|
40609
|
+
*/
|
|
40610
|
+
function buildHttpProxyIntegrationConfig(integration, stackName, logicalId) {
|
|
40611
|
+
const uri = integration["Uri"];
|
|
40612
|
+
if (typeof uri !== "string" || uri.length === 0) return {
|
|
40613
|
+
kind: "unsupported",
|
|
40614
|
+
reason: `${stackName}/${logicalId}: HTTP_PROXY Integration.Uri must be a literal string in v1 (cdkd local start-api does not resolve Fn::Sub / Fn::Join in HTTP_PROXY Uris); got ${shortJson$1(uri)}.`
|
|
40615
|
+
};
|
|
40616
|
+
const integrationHttpMethod = pickStringField(integration, "IntegrationHttpMethod");
|
|
40617
|
+
const requestParameters = pickStringRecord(integration["RequestParameters"]);
|
|
40618
|
+
const responses = readIntegrationResponses(integration);
|
|
40619
|
+
return {
|
|
40620
|
+
kind: "config",
|
|
40621
|
+
config: {
|
|
40622
|
+
kind: "http-proxy",
|
|
40623
|
+
uri,
|
|
40624
|
+
...integrationHttpMethod !== void 0 && { integrationHttpMethod },
|
|
40625
|
+
...requestParameters !== void 0 && { requestParameters },
|
|
40626
|
+
responses
|
|
40627
|
+
}
|
|
40628
|
+
};
|
|
40629
|
+
}
|
|
40630
|
+
/**
|
|
40631
|
+
* Build an HTTP (non-proxy) integration config. Like HTTP_PROXY but with
|
|
40632
|
+
* `RequestTemplates` for VTL transformation.
|
|
40633
|
+
*/
|
|
40634
|
+
function buildHttpIntegrationConfig(integration, stackName, logicalId) {
|
|
40635
|
+
const uri = integration["Uri"];
|
|
40636
|
+
if (typeof uri !== "string" || uri.length === 0) return {
|
|
40637
|
+
kind: "unsupported",
|
|
40638
|
+
reason: `${stackName}/${logicalId}: HTTP Integration.Uri must be a literal string in v1 (cdkd local start-api does not resolve Fn::Sub / Fn::Join in HTTP Uris); got ${shortJson$1(uri)}.`
|
|
40639
|
+
};
|
|
40640
|
+
const integrationHttpMethod = pickStringField(integration, "IntegrationHttpMethod");
|
|
40641
|
+
const requestParameters = pickStringRecord(integration["RequestParameters"]);
|
|
40642
|
+
const requestTemplates = pickStringRecord(integration["RequestTemplates"]);
|
|
40643
|
+
const responses = readIntegrationResponses(integration);
|
|
40644
|
+
return {
|
|
40645
|
+
kind: "config",
|
|
40646
|
+
config: {
|
|
40647
|
+
kind: "http",
|
|
40648
|
+
uri,
|
|
40649
|
+
...integrationHttpMethod !== void 0 && { integrationHttpMethod },
|
|
40650
|
+
...requestParameters !== void 0 && { requestParameters },
|
|
40651
|
+
...requestTemplates !== void 0 && { requestTemplates },
|
|
40652
|
+
responses
|
|
40653
|
+
}
|
|
40654
|
+
};
|
|
40655
|
+
}
|
|
40656
|
+
/**
|
|
40657
|
+
* Build an AWS integration config. Branches on whether the integration
|
|
40658
|
+
* targets a Lambda (`:lambda:path/2015-03-31/functions/<arn>/invocations`)
|
|
40659
|
+
* or a non-Lambda AWS service (`:s3:path/...` / `:sqs:action/...` etc.).
|
|
40660
|
+
*
|
|
40661
|
+
* cdkd v1 supports Lambda non-proxy AWS integrations end-to-end. Non-
|
|
40662
|
+
* Lambda AWS service integrations surface as deferred-501 unsupported
|
|
40663
|
+
* routes — they would require an AWS SDK client per service, IAM
|
|
40664
|
+
* credential threading, and a sizable per-service unit-test matrix.
|
|
40665
|
+
* See [docs/local-emulation.md](docs/local-emulation.md) for the deferred
|
|
40666
|
+
* AWS-service-action list.
|
|
40667
|
+
*/
|
|
40668
|
+
function buildAwsIntegrationConfig(integration, stackName, logicalId) {
|
|
40669
|
+
const uri = integration["Uri"];
|
|
40670
|
+
if (!uriContainsLambdaMarker(uri)) return {
|
|
40671
|
+
kind: "unsupported",
|
|
40672
|
+
reason: `${stackName}/${logicalId}: REST v1 AWS integration targeting a non-Lambda service (Uri ${shortJson$1(uri)}) is not emulated locally in cdkd v1. Lambda non-proxy AWS integrations are supported; direct AWS service integrations (S3 / SQS / SNS / DynamoDB) require deploying to AWS. See docs/local-emulation.md.`
|
|
40673
|
+
};
|
|
40674
|
+
const arnOutcome = resolveLambdaArnOutcome(uri);
|
|
40675
|
+
if (arnOutcome.kind === "unsupported") return {
|
|
40676
|
+
kind: "unsupported",
|
|
40677
|
+
reason: `${stackName}/${logicalId}.Integration.Uri: ${arnOutcome.detail} (got ${shortJson$1(uri)}). Lambda Arn intrinsics on cross-stack / imported references are not resolvable locally.`
|
|
40678
|
+
};
|
|
40679
|
+
const requestTemplates = pickStringRecord(integration["RequestTemplates"]);
|
|
40680
|
+
const responses = readIntegrationResponses(integration);
|
|
40681
|
+
return {
|
|
40682
|
+
kind: "config",
|
|
40683
|
+
config: {
|
|
40684
|
+
kind: "aws-lambda",
|
|
40685
|
+
lambdaLogicalId: arnOutcome.logicalId,
|
|
40686
|
+
...requestTemplates !== void 0 && { requestTemplates },
|
|
40687
|
+
responses
|
|
40688
|
+
}
|
|
40689
|
+
};
|
|
40690
|
+
}
|
|
40691
|
+
/**
|
|
40692
|
+
* Determine whether an `Integration.Uri` references a Lambda invoke path.
|
|
40693
|
+
* Recognises the canonical Lambda invoke ARN shape across the same
|
|
40694
|
+
* intrinsic forms `intrinsic-lambda-arn.ts` accepts (the shared resolver
|
|
40695
|
+
* never produces this Boolean directly, so we walk the shape here).
|
|
40696
|
+
*/
|
|
40697
|
+
function uriContainsLambdaMarker(uri) {
|
|
40698
|
+
if (typeof uri === "string") return uri.includes(LAMBDA_INVOKE_PATH);
|
|
40699
|
+
if (uri && typeof uri === "object" && !Array.isArray(uri)) {
|
|
40700
|
+
const obj = uri;
|
|
40701
|
+
if ("Fn::Sub" in obj) {
|
|
40702
|
+
const v = obj["Fn::Sub"];
|
|
40703
|
+
if (typeof v === "string") return v.includes(LAMBDA_INVOKE_PATH);
|
|
40704
|
+
if (Array.isArray(v) && typeof v[0] === "string") return v[0].includes(LAMBDA_INVOKE_PATH);
|
|
40705
|
+
}
|
|
40706
|
+
if ("Fn::Join" in obj) {
|
|
40707
|
+
const join = obj["Fn::Join"];
|
|
40708
|
+
if (Array.isArray(join) && join.length === 2 && Array.isArray(join[1])) {
|
|
40709
|
+
for (const piece of join[1]) if (typeof piece === "string" && piece.includes(LAMBDA_INVOKE_PATH)) return true;
|
|
40710
|
+
}
|
|
40711
|
+
}
|
|
40712
|
+
if ("Ref" in obj || "Fn::GetAtt" in obj) return true;
|
|
40713
|
+
}
|
|
40714
|
+
return false;
|
|
40715
|
+
}
|
|
40716
|
+
/**
|
|
40717
|
+
* Read `Integration.IntegrationResponses[]` from a Method's Integration
|
|
40718
|
+
* sub-object and return the entries cdkd's dispatchers consume.
|
|
40719
|
+
*
|
|
40720
|
+
* Defensive: rejects non-object entries with a clear inline warning.
|
|
40721
|
+
*/
|
|
40722
|
+
function readIntegrationResponses(integration) {
|
|
40723
|
+
const raw = integration["IntegrationResponses"];
|
|
40724
|
+
if (!Array.isArray(raw)) return [];
|
|
40725
|
+
const out = [];
|
|
40726
|
+
for (const entry of raw) {
|
|
40727
|
+
if (!entry || typeof entry !== "object") continue;
|
|
40728
|
+
const obj = entry;
|
|
40729
|
+
const statusCode = obj["StatusCode"];
|
|
40730
|
+
if (statusCode === void 0) continue;
|
|
40731
|
+
if (typeof statusCode !== "string" && typeof statusCode !== "number") continue;
|
|
40732
|
+
const e = { StatusCode: String(statusCode) };
|
|
40733
|
+
if (typeof obj["SelectionPattern"] === "string") e.SelectionPattern = obj["SelectionPattern"];
|
|
40734
|
+
const responseParameters = pickStringRecord(obj["ResponseParameters"]);
|
|
40735
|
+
if (responseParameters !== void 0) e.ResponseParameters = responseParameters;
|
|
40736
|
+
const responseTemplates = pickStringRecord(obj["ResponseTemplates"]);
|
|
40737
|
+
if (responseTemplates !== void 0) e.ResponseTemplates = responseTemplates;
|
|
40738
|
+
if (typeof obj["ContentHandling"] === "string") e.ContentHandling = obj["ContentHandling"];
|
|
40739
|
+
out.push(e);
|
|
40740
|
+
}
|
|
40741
|
+
return out;
|
|
40742
|
+
}
|
|
40743
|
+
function pickStringField(props, key) {
|
|
40744
|
+
const v = props[key];
|
|
40745
|
+
return typeof v === "string" ? v : void 0;
|
|
40746
|
+
}
|
|
40747
|
+
function pickStringRecord(value) {
|
|
40748
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
40749
|
+
const out = {};
|
|
40750
|
+
let any = false;
|
|
40751
|
+
for (const [k, v] of Object.entries(value)) if (typeof v === "string") {
|
|
40752
|
+
out[k] = v;
|
|
40753
|
+
any = true;
|
|
40754
|
+
}
|
|
40755
|
+
return any ? out : void 0;
|
|
40756
|
+
}
|
|
40757
|
+
function pickStringFromRecord(value, key) {
|
|
40758
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
40759
|
+
const v = value[key];
|
|
40760
|
+
return typeof v === "string" ? v : void 0;
|
|
40761
|
+
}
|
|
40762
|
+
/**
|
|
40527
40763
|
* Walk a chain of `AWS::ApiGateway::Resource` parent pointers up to the
|
|
40528
40764
|
* `RestApi` root to build the full path. Each `Resource` contributes a
|
|
40529
40765
|
* `PathPart` segment; the `RestApi` itself contributes the leading `/`.
|
|
@@ -40891,6 +41127,1354 @@ function shortJson$1(value) {
|
|
|
40891
41127
|
}
|
|
40892
41128
|
}
|
|
40893
41129
|
|
|
41130
|
+
//#endregion
|
|
41131
|
+
//#region src/local/vtl-engine.ts
|
|
41132
|
+
/** Error thrown when a template references an unsupported VTL feature. */
|
|
41133
|
+
var VtlEvaluationError = class VtlEvaluationError extends Error {
|
|
41134
|
+
constructor(message) {
|
|
41135
|
+
super(message);
|
|
41136
|
+
this.name = "VtlEvaluationError";
|
|
41137
|
+
Object.setPrototypeOf(this, VtlEvaluationError.prototype);
|
|
41138
|
+
}
|
|
41139
|
+
};
|
|
41140
|
+
/** Built-in `$util` implementation. */
|
|
41141
|
+
function buildDefaultUtil() {
|
|
41142
|
+
const coerce = (v) => {
|
|
41143
|
+
if (v == null) return "";
|
|
41144
|
+
if (typeof v === "string") return v;
|
|
41145
|
+
if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint") return String(v);
|
|
41146
|
+
try {
|
|
41147
|
+
return JSON.stringify(v);
|
|
41148
|
+
} catch {
|
|
41149
|
+
return "";
|
|
41150
|
+
}
|
|
41151
|
+
};
|
|
41152
|
+
return {
|
|
41153
|
+
escapeJavaScript(input) {
|
|
41154
|
+
return coerce(input).replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
|
|
41155
|
+
},
|
|
41156
|
+
base64Encode(input) {
|
|
41157
|
+
return Buffer.from(coerce(input), "utf-8").toString("base64");
|
|
41158
|
+
},
|
|
41159
|
+
base64Decode(input) {
|
|
41160
|
+
return Buffer.from(coerce(input), "base64").toString("utf-8");
|
|
41161
|
+
},
|
|
41162
|
+
urlEncode(input) {
|
|
41163
|
+
return encodeURIComponent(coerce(input));
|
|
41164
|
+
},
|
|
41165
|
+
urlDecode(input) {
|
|
41166
|
+
try {
|
|
41167
|
+
return decodeURIComponent(coerce(input));
|
|
41168
|
+
} catch {
|
|
41169
|
+
return coerce(input);
|
|
41170
|
+
}
|
|
41171
|
+
},
|
|
41172
|
+
parseJson(input) {
|
|
41173
|
+
const s = coerce(input);
|
|
41174
|
+
try {
|
|
41175
|
+
return JSON.parse(s);
|
|
41176
|
+
} catch (err) {
|
|
41177
|
+
throw new VtlEvaluationError(`$util.parseJson: invalid JSON input: ${err instanceof Error ? err.message : String(err)}`);
|
|
41178
|
+
}
|
|
41179
|
+
}
|
|
41180
|
+
};
|
|
41181
|
+
}
|
|
41182
|
+
/**
|
|
41183
|
+
* Public entry point — evaluate a VTL template against a context and
|
|
41184
|
+
* return the rendered string. Throws {@link VtlEvaluationError} on any
|
|
41185
|
+
* unsupported syntax or runtime failure.
|
|
41186
|
+
*
|
|
41187
|
+
* Empty / undefined templates short-circuit to an empty string, matching
|
|
41188
|
+
* AWS API Gateway behavior when `RequestTemplates` / `ResponseTemplates`
|
|
41189
|
+
* is absent for the selected content type.
|
|
41190
|
+
*/
|
|
41191
|
+
function evaluateVtl(template, ctx) {
|
|
41192
|
+
if (template === void 0 || template.length === 0) return "";
|
|
41193
|
+
return new VtlEvaluator(ctx).evaluate(template);
|
|
41194
|
+
}
|
|
41195
|
+
/**
|
|
41196
|
+
* Stateful evaluator. Tokenizes + parses + renders in one pass — minimal
|
|
41197
|
+
* subset, so a recursive-descent walk over the template suffices. Tracks
|
|
41198
|
+
* a per-template scope chain for `#set` and `#foreach` bindings.
|
|
41199
|
+
*/
|
|
41200
|
+
var VtlEvaluator = class {
|
|
41201
|
+
ctx;
|
|
41202
|
+
scopes;
|
|
41203
|
+
output = [];
|
|
41204
|
+
constructor(ctx) {
|
|
41205
|
+
this.ctx = ctx;
|
|
41206
|
+
this.scopes = [/* @__PURE__ */ new Map()];
|
|
41207
|
+
}
|
|
41208
|
+
evaluate(template) {
|
|
41209
|
+
this.renderBlock(template);
|
|
41210
|
+
return this.output.join("");
|
|
41211
|
+
}
|
|
41212
|
+
/**
|
|
41213
|
+
* Render a block — walks the template, interpolating `${var}` /
|
|
41214
|
+
* `$var.field.method(args)` and handling `#set` / `#if` / `#foreach`
|
|
41215
|
+
* directives.
|
|
41216
|
+
*
|
|
41217
|
+
* The walk is line-aware for directives: every `#directive` MUST start
|
|
41218
|
+
* a line (after whitespace) per Velocity convention, but for ergonomics
|
|
41219
|
+
* we also accept directives at the start of the template. Inline `$var`
|
|
41220
|
+
* references are handled anywhere.
|
|
41221
|
+
*/
|
|
41222
|
+
renderBlock(block) {
|
|
41223
|
+
let i = 0;
|
|
41224
|
+
while (i < block.length) {
|
|
41225
|
+
const ch = block[i];
|
|
41226
|
+
if (ch === "#" && this.isDirectiveStart(block, i)) {
|
|
41227
|
+
i = this.handleDirective(block, i);
|
|
41228
|
+
continue;
|
|
41229
|
+
}
|
|
41230
|
+
if (ch === "$") {
|
|
41231
|
+
const consumed = this.handleVariable(block, i);
|
|
41232
|
+
if (consumed > 0) {
|
|
41233
|
+
i += consumed;
|
|
41234
|
+
continue;
|
|
41235
|
+
}
|
|
41236
|
+
}
|
|
41237
|
+
if (ch === "\\" && i + 1 < block.length && block[i + 1] === "$") {
|
|
41238
|
+
this.output.push("$");
|
|
41239
|
+
i += 2;
|
|
41240
|
+
continue;
|
|
41241
|
+
}
|
|
41242
|
+
this.output.push(ch ?? "");
|
|
41243
|
+
i++;
|
|
41244
|
+
}
|
|
41245
|
+
}
|
|
41246
|
+
isDirectiveStart(block, i) {
|
|
41247
|
+
if (i + 1 >= block.length) return false;
|
|
41248
|
+
const next = block[i + 1];
|
|
41249
|
+
if (next === "#") return true;
|
|
41250
|
+
return next !== void 0 && /[a-zA-Z]/.test(next);
|
|
41251
|
+
}
|
|
41252
|
+
/**
|
|
41253
|
+
* Handle one directive (`#set`, `#if`, `#foreach`, etc.) — returns the
|
|
41254
|
+
* NEW index in `block` (i.e. how far we consumed past the directive).
|
|
41255
|
+
*/
|
|
41256
|
+
handleDirective(block, start) {
|
|
41257
|
+
if (block[start + 1] === "#") {
|
|
41258
|
+
const eol = block.indexOf("\n", start);
|
|
41259
|
+
return eol === -1 ? block.length : eol + 1;
|
|
41260
|
+
}
|
|
41261
|
+
const directiveMatch = /^#([a-zA-Z]+)/.exec(block.slice(start));
|
|
41262
|
+
if (!directiveMatch) {
|
|
41263
|
+
this.output.push("#");
|
|
41264
|
+
return start + 1;
|
|
41265
|
+
}
|
|
41266
|
+
const name = directiveMatch[1];
|
|
41267
|
+
const afterDirective = start + 1 + name.length;
|
|
41268
|
+
switch (name) {
|
|
41269
|
+
case "set": return this.handleSetDirective(block, afterDirective);
|
|
41270
|
+
case "if": return this.handleIfDirective(block, afterDirective);
|
|
41271
|
+
case "foreach": return this.handleForeachDirective(block, afterDirective);
|
|
41272
|
+
case "else":
|
|
41273
|
+
case "elseif":
|
|
41274
|
+
case "end": throw new VtlEvaluationError(`Unexpected #${name} outside of a #if / #foreach block`);
|
|
41275
|
+
default: throw new VtlEvaluationError(`Unsupported VTL directive #${name} (cdkd local start-api supports #set / #if / #elseif / #else / #foreach / #end / ##)`);
|
|
41276
|
+
}
|
|
41277
|
+
}
|
|
41278
|
+
/**
|
|
41279
|
+
* `#set($var = expression)` — assigns to the innermost scope.
|
|
41280
|
+
*/
|
|
41281
|
+
handleSetDirective(block, after) {
|
|
41282
|
+
const { args, end } = this.readParenArgs(block, after);
|
|
41283
|
+
const eq = args.indexOf("=");
|
|
41284
|
+
if (eq === -1) throw new VtlEvaluationError(`#set requires '=': got #set(${args})`);
|
|
41285
|
+
const left = args.slice(0, eq).trim();
|
|
41286
|
+
const right = args.slice(eq + 1).trim();
|
|
41287
|
+
if (!left.startsWith("$")) throw new VtlEvaluationError(`#set left side must be a $var reference (got '${left}')`);
|
|
41288
|
+
const varName = left.slice(1).replace(/^\{/, "").replace(/\}$/, "");
|
|
41289
|
+
const value = this.evaluateExpression(right);
|
|
41290
|
+
this.scopes[this.scopes.length - 1].set(varName, value);
|
|
41291
|
+
return this.skipDirectiveTrailingNewline(block, end);
|
|
41292
|
+
}
|
|
41293
|
+
/**
|
|
41294
|
+
* `#if (cond) ... #elseif (cond) ... #else ... #end`. Renders the
|
|
41295
|
+
* first true branch only; the rest are skipped (their text NOT emitted).
|
|
41296
|
+
*/
|
|
41297
|
+
handleIfDirective(block, after) {
|
|
41298
|
+
const { args: condExpr, end } = this.readParenArgs(block, after);
|
|
41299
|
+
let rendered = false;
|
|
41300
|
+
let renderedAny = false;
|
|
41301
|
+
const branches = [{
|
|
41302
|
+
condition: condExpr,
|
|
41303
|
+
bodyStart: this.skipDirectiveTrailingNewline(block, end),
|
|
41304
|
+
bodyEnd: -1
|
|
41305
|
+
}];
|
|
41306
|
+
let cursor = branches[0].bodyStart;
|
|
41307
|
+
let depth = 1;
|
|
41308
|
+
while (cursor < block.length && depth > 0) {
|
|
41309
|
+
if (block[cursor] !== "#") {
|
|
41310
|
+
cursor++;
|
|
41311
|
+
continue;
|
|
41312
|
+
}
|
|
41313
|
+
const m = /^#([a-zA-Z]+)/.exec(block.slice(cursor));
|
|
41314
|
+
if (!m) {
|
|
41315
|
+
cursor++;
|
|
41316
|
+
continue;
|
|
41317
|
+
}
|
|
41318
|
+
const tag = m[1];
|
|
41319
|
+
const tagAfter = cursor + 1 + tag.length;
|
|
41320
|
+
if (tag === "if" || tag === "foreach") {
|
|
41321
|
+
depth++;
|
|
41322
|
+
cursor = tagAfter;
|
|
41323
|
+
continue;
|
|
41324
|
+
}
|
|
41325
|
+
if (tag === "end") {
|
|
41326
|
+
depth--;
|
|
41327
|
+
if (depth === 0) {
|
|
41328
|
+
branches[branches.length - 1].bodyEnd = cursor;
|
|
41329
|
+
const endIdx = this.skipDirectiveTrailingNewline(block, tagAfter);
|
|
41330
|
+
for (const branch of branches) {
|
|
41331
|
+
if (rendered) break;
|
|
41332
|
+
const truthy = branch.condition === null ? !renderedAny : this.evaluateCondition(branch.condition);
|
|
41333
|
+
if (truthy) {
|
|
41334
|
+
this.renderBlock(block.slice(branch.bodyStart, branch.bodyEnd));
|
|
41335
|
+
rendered = true;
|
|
41336
|
+
}
|
|
41337
|
+
renderedAny = renderedAny || truthy;
|
|
41338
|
+
}
|
|
41339
|
+
return endIdx;
|
|
41340
|
+
}
|
|
41341
|
+
cursor = tagAfter;
|
|
41342
|
+
continue;
|
|
41343
|
+
}
|
|
41344
|
+
if (depth === 1 && (tag === "elseif" || tag === "else")) {
|
|
41345
|
+
branches[branches.length - 1].bodyEnd = cursor;
|
|
41346
|
+
if (tag === "elseif") {
|
|
41347
|
+
const { args, end: elseifEnd } = this.readParenArgs(block, tagAfter);
|
|
41348
|
+
branches.push({
|
|
41349
|
+
condition: args,
|
|
41350
|
+
bodyStart: this.skipDirectiveTrailingNewline(block, elseifEnd),
|
|
41351
|
+
bodyEnd: -1
|
|
41352
|
+
});
|
|
41353
|
+
cursor = branches[branches.length - 1].bodyStart;
|
|
41354
|
+
} else {
|
|
41355
|
+
branches.push({
|
|
41356
|
+
condition: null,
|
|
41357
|
+
bodyStart: this.skipDirectiveTrailingNewline(block, tagAfter),
|
|
41358
|
+
bodyEnd: -1
|
|
41359
|
+
});
|
|
41360
|
+
cursor = branches[branches.length - 1].bodyStart;
|
|
41361
|
+
}
|
|
41362
|
+
continue;
|
|
41363
|
+
}
|
|
41364
|
+
cursor = tagAfter;
|
|
41365
|
+
}
|
|
41366
|
+
throw new VtlEvaluationError("#if without matching #end");
|
|
41367
|
+
}
|
|
41368
|
+
/**
|
|
41369
|
+
* `#foreach($x in $list) ... #end` — iterates a list / object's values.
|
|
41370
|
+
*/
|
|
41371
|
+
handleForeachDirective(block, after) {
|
|
41372
|
+
const { args, end } = this.readParenArgs(block, after);
|
|
41373
|
+
const m = /^\s*\$([a-zA-Z_][a-zA-Z_0-9]*)\s+in\s+(.+)$/.exec(args);
|
|
41374
|
+
if (!m) throw new VtlEvaluationError(`Invalid #foreach syntax: ${args}`);
|
|
41375
|
+
const varName = m[1];
|
|
41376
|
+
const listExpr = m[2];
|
|
41377
|
+
const listValue = this.evaluateExpression(listExpr);
|
|
41378
|
+
let depth = 1;
|
|
41379
|
+
let cursor = this.skipDirectiveTrailingNewline(block, end);
|
|
41380
|
+
const bodyStart = cursor;
|
|
41381
|
+
while (cursor < block.length && depth > 0) {
|
|
41382
|
+
if (block[cursor] !== "#") {
|
|
41383
|
+
cursor++;
|
|
41384
|
+
continue;
|
|
41385
|
+
}
|
|
41386
|
+
const tm = /^#([a-zA-Z]+)/.exec(block.slice(cursor));
|
|
41387
|
+
if (!tm) {
|
|
41388
|
+
cursor++;
|
|
41389
|
+
continue;
|
|
41390
|
+
}
|
|
41391
|
+
const tag = tm[1];
|
|
41392
|
+
if (tag === "if" || tag === "foreach") {
|
|
41393
|
+
depth++;
|
|
41394
|
+
cursor += 1 + tag.length;
|
|
41395
|
+
continue;
|
|
41396
|
+
}
|
|
41397
|
+
if (tag === "end") {
|
|
41398
|
+
depth--;
|
|
41399
|
+
if (depth === 0) {
|
|
41400
|
+
const bodyEnd = cursor;
|
|
41401
|
+
const endIdx = this.skipDirectiveTrailingNewline(block, cursor + 1 + tag.length);
|
|
41402
|
+
const items = this.coerceToIterable(listValue);
|
|
41403
|
+
for (const item of items) {
|
|
41404
|
+
this.scopes.push(new Map([[varName, item]]));
|
|
41405
|
+
try {
|
|
41406
|
+
this.renderBlock(block.slice(bodyStart, bodyEnd));
|
|
41407
|
+
} finally {
|
|
41408
|
+
this.scopes.pop();
|
|
41409
|
+
}
|
|
41410
|
+
}
|
|
41411
|
+
return endIdx;
|
|
41412
|
+
}
|
|
41413
|
+
}
|
|
41414
|
+
cursor += 1 + tag.length;
|
|
41415
|
+
}
|
|
41416
|
+
throw new VtlEvaluationError("#foreach without matching #end");
|
|
41417
|
+
}
|
|
41418
|
+
/** Convert a value into an iterable sequence for `#foreach`. */
|
|
41419
|
+
coerceToIterable(value) {
|
|
41420
|
+
if (Array.isArray(value)) return value;
|
|
41421
|
+
if (value && typeof value === "object") return Object.values(value);
|
|
41422
|
+
if (value == null) return [];
|
|
41423
|
+
return [value];
|
|
41424
|
+
}
|
|
41425
|
+
/**
|
|
41426
|
+
* Skip whitespace and a single trailing newline immediately after a
|
|
41427
|
+
* directive — matches Velocity's "directive eats its own newline"
|
|
41428
|
+
* convention. Without this rule, every `#set(...)` line in a template
|
|
41429
|
+
* would leave a blank line in the output.
|
|
41430
|
+
*/
|
|
41431
|
+
skipDirectiveTrailingNewline(block, after) {
|
|
41432
|
+
let i = after;
|
|
41433
|
+
while (i < block.length && (block[i] === " " || block[i] === " ")) i++;
|
|
41434
|
+
if (block[i] === "\r") i++;
|
|
41435
|
+
if (block[i] === "\n") i++;
|
|
41436
|
+
return i;
|
|
41437
|
+
}
|
|
41438
|
+
/**
|
|
41439
|
+
* Read `(...)` arguments after a directive name. Returns the inner
|
|
41440
|
+
* string + the index AFTER the closing paren. Handles nested parens
|
|
41441
|
+
* inside string literals / method calls.
|
|
41442
|
+
*/
|
|
41443
|
+
readParenArgs(block, after) {
|
|
41444
|
+
let i = after;
|
|
41445
|
+
while (i < block.length && (block[i] === " " || block[i] === " ")) i++;
|
|
41446
|
+
if (block[i] !== "(") throw new VtlEvaluationError(`Expected '(' after directive at offset ${after}`);
|
|
41447
|
+
i++;
|
|
41448
|
+
let depth = 1;
|
|
41449
|
+
const start = i;
|
|
41450
|
+
let inString = null;
|
|
41451
|
+
while (i < block.length && depth > 0) {
|
|
41452
|
+
const c = block[i];
|
|
41453
|
+
if (inString) {
|
|
41454
|
+
if (c === "\\" && i + 1 < block.length) {
|
|
41455
|
+
i += 2;
|
|
41456
|
+
continue;
|
|
41457
|
+
}
|
|
41458
|
+
if (c === inString) inString = null;
|
|
41459
|
+
i++;
|
|
41460
|
+
continue;
|
|
41461
|
+
}
|
|
41462
|
+
if (c === "\"" || c === "'") {
|
|
41463
|
+
inString = c;
|
|
41464
|
+
i++;
|
|
41465
|
+
continue;
|
|
41466
|
+
}
|
|
41467
|
+
if (c === "(") depth++;
|
|
41468
|
+
else if (c === ")") depth--;
|
|
41469
|
+
if (depth === 0) break;
|
|
41470
|
+
i++;
|
|
41471
|
+
}
|
|
41472
|
+
if (depth !== 0) throw new VtlEvaluationError(`Unterminated parenthesised argument at offset ${after}`);
|
|
41473
|
+
return {
|
|
41474
|
+
args: block.slice(start, i),
|
|
41475
|
+
end: i + 1
|
|
41476
|
+
};
|
|
41477
|
+
}
|
|
41478
|
+
/**
|
|
41479
|
+
* Handle a `$var` / `${var}` / `$obj.field.method(args)` reference.
|
|
41480
|
+
* Returns the number of characters consumed (0 if not a reference —
|
|
41481
|
+
* caller emits the literal `$`).
|
|
41482
|
+
*/
|
|
41483
|
+
handleVariable(block, start) {
|
|
41484
|
+
const m = /^\$(\{[^}]+\}|[a-zA-Z_][a-zA-Z_0-9]*(?:\.[a-zA-Z_][a-zA-Z_0-9]*)*)/.exec(block.slice(start));
|
|
41485
|
+
if (!m) return 0;
|
|
41486
|
+
const ref = m[1];
|
|
41487
|
+
const refStr = ref.startsWith("{") ? ref.slice(1, -1) : ref;
|
|
41488
|
+
let consumed = m[0].length;
|
|
41489
|
+
let value = this.resolveReference(refStr);
|
|
41490
|
+
let pos = start + consumed;
|
|
41491
|
+
while (pos < block.length) {
|
|
41492
|
+
if (block[pos] === "(") {
|
|
41493
|
+
const { args, end } = this.readParenArgs(block, pos);
|
|
41494
|
+
value = this.callValueAsMethod(value, args, refStr);
|
|
41495
|
+
consumed = end - start;
|
|
41496
|
+
pos = end;
|
|
41497
|
+
if (pos < block.length && block[pos] === ".") {
|
|
41498
|
+
const tailMatch = /^\.([a-zA-Z_][a-zA-Z_0-9]*)/.exec(block.slice(pos));
|
|
41499
|
+
if (tailMatch) {
|
|
41500
|
+
const field = tailMatch[1];
|
|
41501
|
+
value = lookupField(value, field);
|
|
41502
|
+
consumed += tailMatch[0].length;
|
|
41503
|
+
pos += tailMatch[0].length;
|
|
41504
|
+
continue;
|
|
41505
|
+
}
|
|
41506
|
+
}
|
|
41507
|
+
break;
|
|
41508
|
+
}
|
|
41509
|
+
break;
|
|
41510
|
+
}
|
|
41511
|
+
this.output.push(this.stringifyForOutput(value));
|
|
41512
|
+
return consumed;
|
|
41513
|
+
}
|
|
41514
|
+
/**
|
|
41515
|
+
* Resolve a dotted reference path against context + scopes. The first
|
|
41516
|
+
* segment is matched against built-in roots (`input` / `context` / `util`
|
|
41517
|
+
* / `inputRoot`) and the scope chain in order.
|
|
41518
|
+
*/
|
|
41519
|
+
resolveReference(path) {
|
|
41520
|
+
const parts = path.split(".");
|
|
41521
|
+
const first = parts[0];
|
|
41522
|
+
const rest = parts.slice(1);
|
|
41523
|
+
let base;
|
|
41524
|
+
if (first === "input") base = this.ctx.input;
|
|
41525
|
+
else if (first === "context") base = this.ctx.context;
|
|
41526
|
+
else if (first === "util") base = this.ctx.util;
|
|
41527
|
+
else if (first === "inputRoot") base = this.ctx.inputRoot;
|
|
41528
|
+
else {
|
|
41529
|
+
let found = false;
|
|
41530
|
+
for (let i = this.scopes.length - 1; i >= 0; i--) {
|
|
41531
|
+
const scope = this.scopes[i];
|
|
41532
|
+
if (scope.has(first)) {
|
|
41533
|
+
base = scope.get(first);
|
|
41534
|
+
found = true;
|
|
41535
|
+
break;
|
|
41536
|
+
}
|
|
41537
|
+
}
|
|
41538
|
+
if (!found) return null;
|
|
41539
|
+
}
|
|
41540
|
+
return rest.reduce((acc, seg) => lookupField(acc, seg), base);
|
|
41541
|
+
}
|
|
41542
|
+
/**
|
|
41543
|
+
* Invoke a value as a method — used after a `$ref(args)` shape. The
|
|
41544
|
+
* value must be a function or a special-cased built-in.
|
|
41545
|
+
*/
|
|
41546
|
+
callValueAsMethod(value, argsRaw, refPath) {
|
|
41547
|
+
if (typeof value !== "function") throw new VtlEvaluationError(`Reference '$${refPath}' is not callable (got ${typeof value}). cdkd supports calling $input / $util / $context method-style references only.`);
|
|
41548
|
+
return value(...this.parseArgList(argsRaw));
|
|
41549
|
+
}
|
|
41550
|
+
/**
|
|
41551
|
+
* Parse a comma-separated argument list — recursively evaluates each
|
|
41552
|
+
* expression. Handles string literals, numbers, booleans, and nested
|
|
41553
|
+
* `$var` refs.
|
|
41554
|
+
*/
|
|
41555
|
+
parseArgList(raw) {
|
|
41556
|
+
const trimmed = raw.trim();
|
|
41557
|
+
if (trimmed.length === 0) return [];
|
|
41558
|
+
const parts = [];
|
|
41559
|
+
let depth = 0;
|
|
41560
|
+
let inString = null;
|
|
41561
|
+
let start = 0;
|
|
41562
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
41563
|
+
const c = trimmed[i];
|
|
41564
|
+
if (inString) {
|
|
41565
|
+
if (c === "\\" && i + 1 < trimmed.length) {
|
|
41566
|
+
i++;
|
|
41567
|
+
continue;
|
|
41568
|
+
}
|
|
41569
|
+
if (c === inString) inString = null;
|
|
41570
|
+
continue;
|
|
41571
|
+
}
|
|
41572
|
+
if (c === "\"" || c === "'") {
|
|
41573
|
+
inString = c;
|
|
41574
|
+
continue;
|
|
41575
|
+
}
|
|
41576
|
+
if (c === "(" || c === "[") depth++;
|
|
41577
|
+
else if (c === ")" || c === "]") depth--;
|
|
41578
|
+
else if (c === "," && depth === 0) {
|
|
41579
|
+
parts.push(trimmed.slice(start, i));
|
|
41580
|
+
start = i + 1;
|
|
41581
|
+
}
|
|
41582
|
+
}
|
|
41583
|
+
parts.push(trimmed.slice(start));
|
|
41584
|
+
return parts.map((p) => this.evaluateExpression(p.trim()));
|
|
41585
|
+
}
|
|
41586
|
+
/**
|
|
41587
|
+
* Evaluate a sub-expression (string literal / number / boolean / null /
|
|
41588
|
+
* `$ref` / `$ref.field`). Tiny grammar — no arithmetic operators.
|
|
41589
|
+
*/
|
|
41590
|
+
evaluateExpression(expr) {
|
|
41591
|
+
const trimmed = expr.trim();
|
|
41592
|
+
if (trimmed.length === 0) return null;
|
|
41593
|
+
if (trimmed === "true") return true;
|
|
41594
|
+
if (trimmed === "false") return false;
|
|
41595
|
+
if (trimmed === "null") return null;
|
|
41596
|
+
if (trimmed.startsWith("\"") && trimmed.endsWith("\"") || trimmed.startsWith("'") && trimmed.endsWith("'")) return this.unescapeStringLiteral(trimmed.slice(1, -1));
|
|
41597
|
+
if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
|
41598
|
+
if (trimmed.startsWith("$")) {
|
|
41599
|
+
const refMatch = /^\$(\{[^}]+\}|[a-zA-Z_][a-zA-Z_0-9]*(?:\.[a-zA-Z_][a-zA-Z_0-9]*)*)$/.exec(trimmed);
|
|
41600
|
+
if (refMatch) {
|
|
41601
|
+
const refStr = refMatch[1];
|
|
41602
|
+
const refPath = refStr.startsWith("{") ? refStr.slice(1, -1) : refStr;
|
|
41603
|
+
return this.resolveReference(refPath);
|
|
41604
|
+
}
|
|
41605
|
+
const callMatch = /^\$([a-zA-Z_][a-zA-Z_0-9]*(?:\.[a-zA-Z_][a-zA-Z_0-9]*)*)\((.*)\)$/.exec(trimmed);
|
|
41606
|
+
if (callMatch) {
|
|
41607
|
+
const refPath = callMatch[1];
|
|
41608
|
+
const argsRaw = callMatch[2];
|
|
41609
|
+
const value = this.resolveReference(refPath);
|
|
41610
|
+
return this.callValueAsMethod(value, argsRaw, refPath);
|
|
41611
|
+
}
|
|
41612
|
+
}
|
|
41613
|
+
throw new VtlEvaluationError(`Could not evaluate VTL sub-expression: '${trimmed}'`);
|
|
41614
|
+
}
|
|
41615
|
+
unescapeStringLiteral(s) {
|
|
41616
|
+
return s.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\\\/g, "\\").replace(/\\"/g, "\"").replace(/\\'/g, "'");
|
|
41617
|
+
}
|
|
41618
|
+
/**
|
|
41619
|
+
* Evaluate a `#if` / `#elseif` condition expression. Supports `&&`,
|
|
41620
|
+
* `||`, `!`, comparison ops, and bare value tests (truthy/falsy).
|
|
41621
|
+
*/
|
|
41622
|
+
evaluateCondition(expr) {
|
|
41623
|
+
const trimmed = expr.trim();
|
|
41624
|
+
const orParts = splitTopLevel(trimmed, "||");
|
|
41625
|
+
if (orParts.length > 1) return orParts.some((p) => this.evaluateCondition(p));
|
|
41626
|
+
const andParts = splitTopLevel(trimmed, "&&");
|
|
41627
|
+
if (andParts.length > 1) return andParts.every((p) => this.evaluateCondition(p));
|
|
41628
|
+
if (trimmed.startsWith("!")) return !this.evaluateCondition(trimmed.slice(1).trim());
|
|
41629
|
+
if (trimmed.startsWith("(") && trimmed.endsWith(")")) return this.evaluateCondition(trimmed.slice(1, -1));
|
|
41630
|
+
for (const op of [
|
|
41631
|
+
"==",
|
|
41632
|
+
"!=",
|
|
41633
|
+
"<=",
|
|
41634
|
+
">=",
|
|
41635
|
+
"<",
|
|
41636
|
+
">"
|
|
41637
|
+
]) {
|
|
41638
|
+
const parts = splitTopLevel(trimmed, op);
|
|
41639
|
+
if (parts.length === 2) return compareValues(this.evaluateExpression(parts[0]), this.evaluateExpression(parts[1]), op);
|
|
41640
|
+
}
|
|
41641
|
+
return isTruthy(this.evaluateExpression(trimmed));
|
|
41642
|
+
}
|
|
41643
|
+
/**
|
|
41644
|
+
* Convert a value to its template output form. Mirrors Velocity's
|
|
41645
|
+
* `toString` convention: `null` → empty string; objects → JSON; numbers
|
|
41646
|
+
* / booleans → standard.
|
|
41647
|
+
*/
|
|
41648
|
+
stringifyForOutput(value) {
|
|
41649
|
+
if (value === null || value === void 0) return "";
|
|
41650
|
+
if (typeof value === "string") return value;
|
|
41651
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
41652
|
+
return JSON.stringify(value);
|
|
41653
|
+
}
|
|
41654
|
+
};
|
|
41655
|
+
/**
|
|
41656
|
+
* Look up `field` on `obj`. Returns `null` for missing fields (Velocity
|
|
41657
|
+
* silent-undefined convention).
|
|
41658
|
+
*/
|
|
41659
|
+
function lookupField(obj, field) {
|
|
41660
|
+
if (obj == null) return null;
|
|
41661
|
+
if (typeof obj === "object") {
|
|
41662
|
+
const rec = obj;
|
|
41663
|
+
if (Object.prototype.hasOwnProperty.call(rec, field)) return rec[field];
|
|
41664
|
+
return null;
|
|
41665
|
+
}
|
|
41666
|
+
return null;
|
|
41667
|
+
}
|
|
41668
|
+
function splitTopLevel(s, sep) {
|
|
41669
|
+
const out = [];
|
|
41670
|
+
let depth = 0;
|
|
41671
|
+
let inString = null;
|
|
41672
|
+
let start = 0;
|
|
41673
|
+
for (let i = 0; i < s.length; i++) {
|
|
41674
|
+
const c = s[i];
|
|
41675
|
+
if (inString) {
|
|
41676
|
+
if (c === "\\" && i + 1 < s.length) {
|
|
41677
|
+
i++;
|
|
41678
|
+
continue;
|
|
41679
|
+
}
|
|
41680
|
+
if (c === inString) inString = null;
|
|
41681
|
+
continue;
|
|
41682
|
+
}
|
|
41683
|
+
if (c === "\"" || c === "'") {
|
|
41684
|
+
inString = c;
|
|
41685
|
+
continue;
|
|
41686
|
+
}
|
|
41687
|
+
if (c === "(" || c === "[") depth++;
|
|
41688
|
+
else if (c === ")" || c === "]") depth--;
|
|
41689
|
+
else if (depth === 0 && s.startsWith(sep, i)) {
|
|
41690
|
+
out.push(s.slice(start, i));
|
|
41691
|
+
start = i + sep.length;
|
|
41692
|
+
i += sep.length - 1;
|
|
41693
|
+
}
|
|
41694
|
+
}
|
|
41695
|
+
out.push(s.slice(start));
|
|
41696
|
+
return out;
|
|
41697
|
+
}
|
|
41698
|
+
function compareValues(lhs, rhs, op) {
|
|
41699
|
+
if (op === "==") return looseEqual(lhs, rhs);
|
|
41700
|
+
if (op === "!=") return !looseEqual(lhs, rhs);
|
|
41701
|
+
const a = typeof lhs === "number" ? lhs : Number(lhs);
|
|
41702
|
+
const b = typeof rhs === "number" ? rhs : Number(rhs);
|
|
41703
|
+
if (Number.isFinite(a) && Number.isFinite(b)) switch (op) {
|
|
41704
|
+
case "<": return a < b;
|
|
41705
|
+
case "<=": return a <= b;
|
|
41706
|
+
case ">": return a > b;
|
|
41707
|
+
case ">=": return a >= b;
|
|
41708
|
+
}
|
|
41709
|
+
const sa = String(lhs);
|
|
41710
|
+
const sb = String(rhs);
|
|
41711
|
+
switch (op) {
|
|
41712
|
+
case "<": return sa < sb;
|
|
41713
|
+
case "<=": return sa <= sb;
|
|
41714
|
+
case ">": return sa > sb;
|
|
41715
|
+
case ">=": return sa >= sb;
|
|
41716
|
+
}
|
|
41717
|
+
}
|
|
41718
|
+
function looseEqual(a, b) {
|
|
41719
|
+
if (a === b) return true;
|
|
41720
|
+
if (a == null || b == null) return a == null && b == null;
|
|
41721
|
+
if (typeof a === typeof b) {
|
|
41722
|
+
if (typeof a === "object") return JSON.stringify(a) === JSON.stringify(b);
|
|
41723
|
+
return false;
|
|
41724
|
+
}
|
|
41725
|
+
return safeStringify(a) === safeStringify(b);
|
|
41726
|
+
}
|
|
41727
|
+
function safeStringify(v) {
|
|
41728
|
+
if (v == null) return "";
|
|
41729
|
+
if (typeof v === "string") return v;
|
|
41730
|
+
if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint") return String(v);
|
|
41731
|
+
try {
|
|
41732
|
+
return JSON.stringify(v);
|
|
41733
|
+
} catch {
|
|
41734
|
+
return "";
|
|
41735
|
+
}
|
|
41736
|
+
}
|
|
41737
|
+
function isTruthy(v) {
|
|
41738
|
+
if (v == null) return false;
|
|
41739
|
+
if (typeof v === "boolean") return v;
|
|
41740
|
+
if (typeof v === "number") return v !== 0;
|
|
41741
|
+
if (typeof v === "string") return v.length > 0;
|
|
41742
|
+
if (Array.isArray(v)) return v.length > 0;
|
|
41743
|
+
if (typeof v === "object") return Object.keys(v).length > 0;
|
|
41744
|
+
return true;
|
|
41745
|
+
}
|
|
41746
|
+
/**
|
|
41747
|
+
* Build a `VtlInput` binding from an HTTP request snapshot + matched
|
|
41748
|
+
* route context. `$input` exposes the body + parameter accessors used by
|
|
41749
|
+
* AWS API Gateway's VTL templates.
|
|
41750
|
+
*
|
|
41751
|
+
* `params()` returns the union of header / querystring / path maps;
|
|
41752
|
+
* `params(name)` resolves first against path, then querystring, then
|
|
41753
|
+
* header (matches AWS-deployed precedence).
|
|
41754
|
+
*
|
|
41755
|
+
* `json(jsonPath)` returns a JSON-stringified slice of the parsed body;
|
|
41756
|
+
* `path(jsonPath)` returns the raw native value (primitives unquoted).
|
|
41757
|
+
*
|
|
41758
|
+
* JSONPath support is minimal: supports `$` (root), `$.field`,
|
|
41759
|
+
* `$.field.subField`, `$.array[0]`. AWS supports more (filter
|
|
41760
|
+
* expressions, recursive descent); cdkd surfaces a clear error on
|
|
41761
|
+
* unsupported expressions rather than silently producing wrong output.
|
|
41762
|
+
*/
|
|
41763
|
+
function buildVtlInput(body, headers, querystring, pathParams) {
|
|
41764
|
+
let jsonBodyCache;
|
|
41765
|
+
let jsonBodyParsed = false;
|
|
41766
|
+
function lazyJson() {
|
|
41767
|
+
if (!jsonBodyParsed) {
|
|
41768
|
+
jsonBodyParsed = true;
|
|
41769
|
+
try {
|
|
41770
|
+
jsonBodyCache = body.length === 0 ? null : JSON.parse(body);
|
|
41771
|
+
} catch {
|
|
41772
|
+
jsonBodyCache = null;
|
|
41773
|
+
}
|
|
41774
|
+
}
|
|
41775
|
+
return jsonBodyCache;
|
|
41776
|
+
}
|
|
41777
|
+
function jsonFn(...args) {
|
|
41778
|
+
const expr = args.length > 0 ? String(args[0]) : "$";
|
|
41779
|
+
const val = applyJsonPath(lazyJson(), expr);
|
|
41780
|
+
return JSON.stringify(val ?? null);
|
|
41781
|
+
}
|
|
41782
|
+
function pathFn(...args) {
|
|
41783
|
+
const expr = args.length > 0 ? String(args[0]) : "$";
|
|
41784
|
+
return applyJsonPath(lazyJson(), expr);
|
|
41785
|
+
}
|
|
41786
|
+
function paramsFn(...args) {
|
|
41787
|
+
if (args.length === 0) return {
|
|
41788
|
+
header: headers,
|
|
41789
|
+
querystring,
|
|
41790
|
+
path: pathParams
|
|
41791
|
+
};
|
|
41792
|
+
const arg = String(args[0]);
|
|
41793
|
+
if (arg === "header") return headers;
|
|
41794
|
+
if (arg === "querystring") return querystring;
|
|
41795
|
+
if (arg === "path") return pathParams;
|
|
41796
|
+
if (Object.prototype.hasOwnProperty.call(pathParams, arg)) return pathParams[arg];
|
|
41797
|
+
if (Object.prototype.hasOwnProperty.call(querystring, arg)) return querystring[arg];
|
|
41798
|
+
if (Object.prototype.hasOwnProperty.call(headers, arg)) return headers[arg];
|
|
41799
|
+
const lowerArg = arg.toLowerCase();
|
|
41800
|
+
for (const [k, v] of Object.entries(headers)) if (k.toLowerCase() === lowerArg) return v;
|
|
41801
|
+
return null;
|
|
41802
|
+
}
|
|
41803
|
+
return {
|
|
41804
|
+
body,
|
|
41805
|
+
get jsonBody() {
|
|
41806
|
+
return lazyJson();
|
|
41807
|
+
},
|
|
41808
|
+
headers,
|
|
41809
|
+
querystring,
|
|
41810
|
+
path: pathParams,
|
|
41811
|
+
json: jsonFn,
|
|
41812
|
+
path: pathFn,
|
|
41813
|
+
params: paramsFn
|
|
41814
|
+
};
|
|
41815
|
+
}
|
|
41816
|
+
/**
|
|
41817
|
+
* Minimal JSONPath evaluator. Supports `$`, `$.field`, `$.field.sub`,
|
|
41818
|
+
* `$.array[index]`. Unsupported syntax throws so the user sees a clear
|
|
41819
|
+
* pointer to the gap.
|
|
41820
|
+
*/
|
|
41821
|
+
function applyJsonPath(root, expr) {
|
|
41822
|
+
const trimmed = expr.trim();
|
|
41823
|
+
if (trimmed === "$" || trimmed.length === 0) return root;
|
|
41824
|
+
if (!trimmed.startsWith("$")) throw new VtlEvaluationError(`JSONPath must start with '$': got '${trimmed}'`);
|
|
41825
|
+
let cursor = root;
|
|
41826
|
+
let i = 1;
|
|
41827
|
+
while (i < trimmed.length) {
|
|
41828
|
+
const c = trimmed[i];
|
|
41829
|
+
if (c === ".") {
|
|
41830
|
+
i++;
|
|
41831
|
+
const m = /^[a-zA-Z_][a-zA-Z_0-9]*/.exec(trimmed.slice(i));
|
|
41832
|
+
if (!m) throw new VtlEvaluationError(`Unsupported JSONPath syntax at position ${i}: '${trimmed}' (cdkd supports $, $.field, $.field.sub, $.array[index] only).`);
|
|
41833
|
+
cursor = lookupField(cursor, m[0]);
|
|
41834
|
+
i += m[0].length;
|
|
41835
|
+
continue;
|
|
41836
|
+
}
|
|
41837
|
+
if (c === "[") {
|
|
41838
|
+
const close = trimmed.indexOf("]", i);
|
|
41839
|
+
if (close === -1) throw new VtlEvaluationError(`Unterminated [ in JSONPath: '${trimmed}'`);
|
|
41840
|
+
const inside = trimmed.slice(i + 1, close).trim();
|
|
41841
|
+
if (/^-?\d+$/.test(inside)) {
|
|
41842
|
+
const idx = Number(inside);
|
|
41843
|
+
if (Array.isArray(cursor)) cursor = cursor[idx];
|
|
41844
|
+
else cursor = null;
|
|
41845
|
+
} else if (inside.startsWith("\"") && inside.endsWith("\"") || inside.startsWith("'") && inside.endsWith("'")) cursor = lookupField(cursor, inside.slice(1, -1));
|
|
41846
|
+
else throw new VtlEvaluationError(`Unsupported JSONPath bracket expression: '${inside}' (cdkd supports integer indices and quoted string keys only).`);
|
|
41847
|
+
i = close + 1;
|
|
41848
|
+
continue;
|
|
41849
|
+
}
|
|
41850
|
+
throw new VtlEvaluationError(`Unexpected character in JSONPath at position ${i}: '${trimmed}'`);
|
|
41851
|
+
}
|
|
41852
|
+
return cursor;
|
|
41853
|
+
}
|
|
41854
|
+
/**
|
|
41855
|
+
* Build a `$context` binding from a request snapshot + matched route.
|
|
41856
|
+
* The mapping mirrors what AWS API Gateway exposes (see AWS docs:
|
|
41857
|
+
* https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html).
|
|
41858
|
+
*/
|
|
41859
|
+
function buildVtlRequestContext(args) {
|
|
41860
|
+
return {
|
|
41861
|
+
requestId: args.requestId,
|
|
41862
|
+
httpMethod: args.httpMethod,
|
|
41863
|
+
resourcePath: args.resourcePath,
|
|
41864
|
+
stage: args.stage,
|
|
41865
|
+
identity: {
|
|
41866
|
+
sourceIp: args.sourceIp,
|
|
41867
|
+
userAgent: args.userAgent
|
|
41868
|
+
}
|
|
41869
|
+
};
|
|
41870
|
+
}
|
|
41871
|
+
|
|
41872
|
+
//#endregion
|
|
41873
|
+
//#region src/local/integration-response-selector.ts
|
|
41874
|
+
/**
|
|
41875
|
+
* Pick the right `IntegrationResponses[]` entry for the given outcome.
|
|
41876
|
+
*
|
|
41877
|
+
* Per AWS docs, `SelectionPattern` is matched against the backend
|
|
41878
|
+
* outcome regardless of whether the backend returned success or error —
|
|
41879
|
+
* a `SelectionPattern: '200'` entry IS expected to match an HTTP 200
|
|
41880
|
+
* upstream response. cdkd ALWAYS runs the regex loop first and only
|
|
41881
|
+
* falls to the default entry when no pattern matches; pre-#505-review
|
|
41882
|
+
* the success branch short-circuited to the default entry without
|
|
41883
|
+
* running the regex loop, which silently dropped success-side selection.
|
|
41884
|
+
*
|
|
41885
|
+
* @param entries - The `IntegrationResponses[]` array from the template
|
|
41886
|
+
* (already extracted from the route's Integration property).
|
|
41887
|
+
* @param matchTarget - The string AWS would match `SelectionPattern`
|
|
41888
|
+
* against. For HTTP / HTTP_PROXY this is `String(upstream.status)`;
|
|
41889
|
+
* for Lambda this is the `errorMessage` field on the parsed payload,
|
|
41890
|
+
* or the sentinel `'success'` when the payload has no `errorMessage`.
|
|
41891
|
+
* For MOCK this is unused (MOCK dispatch picks by `StatusCode`).
|
|
41892
|
+
* @param fallbackStatusCode - Status code to use when `entries` is empty
|
|
41893
|
+
* or no entry matches AND no default entry exists. HTTP / HTTP_PROXY
|
|
41894
|
+
* pass the upstream status; Lambda passes 200 on success / 500 on
|
|
41895
|
+
* error.
|
|
41896
|
+
*/
|
|
41897
|
+
function selectIntegrationResponse(entries, matchTarget, fallbackStatusCode = 200) {
|
|
41898
|
+
if (!entries || entries.length === 0) return {
|
|
41899
|
+
entry: null,
|
|
41900
|
+
statusCode: fallbackStatusCode
|
|
41901
|
+
};
|
|
41902
|
+
const defaultEntry = entries.find((e) => e.SelectionPattern === void 0 || e.SelectionPattern === "");
|
|
41903
|
+
for (const entry of entries) {
|
|
41904
|
+
if (entry.SelectionPattern === void 0 || entry.SelectionPattern === "") continue;
|
|
41905
|
+
try {
|
|
41906
|
+
if (new RegExp(`^${entry.SelectionPattern}$`).test(matchTarget)) return {
|
|
41907
|
+
entry,
|
|
41908
|
+
statusCode: parseStatus$1(entry.StatusCode, fallbackStatusCode)
|
|
41909
|
+
};
|
|
41910
|
+
} catch {}
|
|
41911
|
+
}
|
|
41912
|
+
const entry = defaultEntry ?? null;
|
|
41913
|
+
return {
|
|
41914
|
+
entry,
|
|
41915
|
+
statusCode: entry !== null ? parseStatus$1(entry.StatusCode, fallbackStatusCode) : fallbackStatusCode
|
|
41916
|
+
};
|
|
41917
|
+
}
|
|
41918
|
+
function parseStatus$1(raw, fallback) {
|
|
41919
|
+
if (typeof raw === "number" && Number.isFinite(raw)) return raw;
|
|
41920
|
+
if (typeof raw === "string") {
|
|
41921
|
+
const parsed = Number.parseInt(raw, 10);
|
|
41922
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
41923
|
+
}
|
|
41924
|
+
return fallback;
|
|
41925
|
+
}
|
|
41926
|
+
/**
|
|
41927
|
+
* Evaluate `IntegrationResponse.ResponseParameters` — header literals
|
|
41928
|
+
* mapped onto the HTTP response. Returns `{name: value}` for every entry
|
|
41929
|
+
* we could resolve; unresolvable entries (non-literal / mapping
|
|
41930
|
+
* expression) get a warning via `onUnsupported` and are skipped.
|
|
41931
|
+
*
|
|
41932
|
+
* AWS format: keys are `method.response.header.<HeaderName>`; values
|
|
41933
|
+
* are `'literal'` (with single quotes) or mapping expressions
|
|
41934
|
+
* (`integration.response.body.X` / `integration.response.header.X` /
|
|
41935
|
+
* `context.X`). cdkd v1 supports the literal form only.
|
|
41936
|
+
*/
|
|
41937
|
+
function evaluateResponseParameters(responseParameters, opts = {}) {
|
|
41938
|
+
if (!responseParameters) return {};
|
|
41939
|
+
const out = {};
|
|
41940
|
+
for (const [key, value] of Object.entries(responseParameters)) {
|
|
41941
|
+
const headerMatch = /^method\.response\.header\.(.+)$/.exec(key);
|
|
41942
|
+
if (!headerMatch) {
|
|
41943
|
+
opts.onUnsupported?.(key, value, `Only method.response.header.<name> keys are supported on REST v1 ResponseParameters; cdkd cannot map ${key}.`);
|
|
41944
|
+
continue;
|
|
41945
|
+
}
|
|
41946
|
+
const headerName = headerMatch[1];
|
|
41947
|
+
if (typeof value !== "string") {
|
|
41948
|
+
opts.onUnsupported?.(key, String(value), `non-string ResponseParameter value`);
|
|
41949
|
+
continue;
|
|
41950
|
+
}
|
|
41951
|
+
if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) {
|
|
41952
|
+
out[headerName] = value.slice(1, -1);
|
|
41953
|
+
continue;
|
|
41954
|
+
}
|
|
41955
|
+
opts.onUnsupported?.(key, value, `ResponseParameter value '${value}' is a mapping expression (integration.response.* / context.*) which cdkd local start-api does not emulate. Only single-quoted literals are honored.`);
|
|
41956
|
+
}
|
|
41957
|
+
return out;
|
|
41958
|
+
}
|
|
41959
|
+
/**
|
|
41960
|
+
* Pick the response template AWS would render for the given Accept
|
|
41961
|
+
* header. AWS uses content negotiation; cdkd picks `application/json`
|
|
41962
|
+
* first, then any other entry. Returns `undefined` when no template is
|
|
41963
|
+
* configured (caller emits the backend body verbatim).
|
|
41964
|
+
*
|
|
41965
|
+
* The chosen template's content-type is also returned so the dispatcher
|
|
41966
|
+
* can emit a matching `Content-Type` header (matches AWS-deployed
|
|
41967
|
+
* behavior).
|
|
41968
|
+
*/
|
|
41969
|
+
function pickResponseTemplate(responseTemplates, accept) {
|
|
41970
|
+
if (!responseTemplates) return void 0;
|
|
41971
|
+
const entries = Object.entries(responseTemplates);
|
|
41972
|
+
if (entries.length === 0) return void 0;
|
|
41973
|
+
if (accept) {
|
|
41974
|
+
const acceptTypes = accept.split(",").map((s) => s.split(";")[0].trim()).filter(Boolean);
|
|
41975
|
+
for (const acceptType of acceptTypes) for (const [ct, template] of entries) if (ct === acceptType) return {
|
|
41976
|
+
template,
|
|
41977
|
+
contentType: ct
|
|
41978
|
+
};
|
|
41979
|
+
}
|
|
41980
|
+
const jsonEntry = responseTemplates["application/json"];
|
|
41981
|
+
if (jsonEntry !== void 0) return {
|
|
41982
|
+
template: jsonEntry,
|
|
41983
|
+
contentType: "application/json"
|
|
41984
|
+
};
|
|
41985
|
+
const first = entries[0];
|
|
41986
|
+
return {
|
|
41987
|
+
template: first[1],
|
|
41988
|
+
contentType: first[0]
|
|
41989
|
+
};
|
|
41990
|
+
}
|
|
41991
|
+
|
|
41992
|
+
//#endregion
|
|
41993
|
+
//#region src/local/rest-v1-integrations.ts
|
|
41994
|
+
/**
|
|
41995
|
+
* Dispatch a MOCK integration. AWS MOCK semantics:
|
|
41996
|
+
*
|
|
41997
|
+
* 1. Render `RequestTemplates['application/json']` (VTL) against the
|
|
41998
|
+
* request — yields a JSON object like `{"statusCode": 200}`.
|
|
41999
|
+
* 2. Parse the rendered JSON; pick the `IntegrationResponses[]` entry
|
|
42000
|
+
* whose `StatusCode` equals the parsed `statusCode` (string compare,
|
|
42001
|
+
* mirroring AWS).
|
|
42002
|
+
* 3. Render the picked entry's `ResponseTemplates[<content-type>]`
|
|
42003
|
+
* against an empty body context and emit it.
|
|
42004
|
+
* 4. Apply `ResponseParameters` header literals.
|
|
42005
|
+
*
|
|
42006
|
+
* When no request template is configured AWS defaults to picking the
|
|
42007
|
+
* `IntegrationResponses[]` entry with `SelectionPattern === ''` (or the
|
|
42008
|
+
* first entry).
|
|
42009
|
+
*/
|
|
42010
|
+
function dispatchMockIntegration(config, req) {
|
|
42011
|
+
const logger = getLogger().child("start-api");
|
|
42012
|
+
const ctx = buildVtlContextFromRequest(req, "");
|
|
42013
|
+
let pickedStatus;
|
|
42014
|
+
if (config.requestTemplate !== void 0 && config.requestTemplate.trim().length > 0) try {
|
|
42015
|
+
pickedStatus = extractStatusCodeFromRendered(evaluateVtl(config.requestTemplate, ctx));
|
|
42016
|
+
} catch (err) {
|
|
42017
|
+
return vtlFailure("request", err, config.requestTemplate);
|
|
42018
|
+
}
|
|
42019
|
+
let entry = null;
|
|
42020
|
+
if (pickedStatus !== void 0) entry = config.responses.find((e) => parseStatus(e.StatusCode) === pickedStatus) ?? defaultResponseEntry(config.responses);
|
|
42021
|
+
else entry = defaultResponseEntry(config.responses);
|
|
42022
|
+
if (!entry) return {
|
|
42023
|
+
statusCode: pickedStatus ?? 200,
|
|
42024
|
+
headers: { "content-type": "application/json" },
|
|
42025
|
+
body: ""
|
|
42026
|
+
};
|
|
42027
|
+
const accept = req.headers["accept"];
|
|
42028
|
+
const picked = pickResponseTemplate(entry.ResponseTemplates, accept);
|
|
42029
|
+
const respCtx = buildVtlContextFromRequest(req, "", null);
|
|
42030
|
+
let body = "";
|
|
42031
|
+
let contentType = "application/json";
|
|
42032
|
+
if (picked) {
|
|
42033
|
+
try {
|
|
42034
|
+
body = evaluateVtl(picked.template, respCtx);
|
|
42035
|
+
} catch (err) {
|
|
42036
|
+
return vtlFailure("response", err, picked.template);
|
|
42037
|
+
}
|
|
42038
|
+
contentType = picked.contentType;
|
|
42039
|
+
}
|
|
42040
|
+
const headers = { "content-type": contentType };
|
|
42041
|
+
Object.assign(headers, evaluateResponseParameters(entry.ResponseParameters, { onUnsupported: (_k, _v, reason) => logger.warn(`MOCK response: ${reason}`) }));
|
|
42042
|
+
return {
|
|
42043
|
+
statusCode: parseStatus(entry.StatusCode) ?? 200,
|
|
42044
|
+
headers,
|
|
42045
|
+
body
|
|
42046
|
+
};
|
|
42047
|
+
}
|
|
42048
|
+
/**
|
|
42049
|
+
* Dispatch an HTTP_PROXY integration. The request is forwarded verbatim
|
|
42050
|
+
* with `RequestParameters` mappings applied; the response is also
|
|
42051
|
+
* forwarded verbatim (AWS does NOT apply ResponseTemplates on HTTP_PROXY,
|
|
42052
|
+
* only IntegrationResponses[].SelectionPattern routes the status code).
|
|
42053
|
+
*/
|
|
42054
|
+
async function dispatchHttpProxyIntegration(config, req, deps) {
|
|
42055
|
+
const url = substituteUriPlaceholders(config.uri, req);
|
|
42056
|
+
const method = config.integrationHttpMethod ?? req.method;
|
|
42057
|
+
const outHeaders = { ...req.headers };
|
|
42058
|
+
for (const drop of [
|
|
42059
|
+
"host",
|
|
42060
|
+
"connection",
|
|
42061
|
+
"content-length",
|
|
42062
|
+
"transfer-encoding"
|
|
42063
|
+
]) delete outHeaders[drop];
|
|
42064
|
+
applyRequestParameters(config.requestParameters, req, {
|
|
42065
|
+
headers: outHeaders,
|
|
42066
|
+
urlObj: void 0
|
|
42067
|
+
});
|
|
42068
|
+
const fetchImpl = deps.fetch ?? globalThis.fetch;
|
|
42069
|
+
const fetchInit = {
|
|
42070
|
+
method,
|
|
42071
|
+
headers: outHeaders
|
|
42072
|
+
};
|
|
42073
|
+
if (req.body.length > 0) fetchInit.body = new Uint8Array(req.body);
|
|
42074
|
+
let upstream;
|
|
42075
|
+
try {
|
|
42076
|
+
upstream = await fetchImpl(url, fetchInit);
|
|
42077
|
+
} catch (err) {
|
|
42078
|
+
return {
|
|
42079
|
+
statusCode: 502,
|
|
42080
|
+
headers: { "content-type": "application/json" },
|
|
42081
|
+
body: JSON.stringify({
|
|
42082
|
+
message: "HTTP_PROXY upstream unreachable",
|
|
42083
|
+
url,
|
|
42084
|
+
reason: err instanceof Error ? err.message : String(err)
|
|
42085
|
+
})
|
|
42086
|
+
};
|
|
42087
|
+
}
|
|
42088
|
+
const upstreamBody = Buffer.from(await upstream.arrayBuffer());
|
|
42089
|
+
const selected = selectIntegrationResponse(config.responses, String(upstream.status), upstream.status);
|
|
42090
|
+
const headers = {};
|
|
42091
|
+
upstream.headers.forEach((value, name) => {
|
|
42092
|
+
headers[name.toLowerCase()] = value;
|
|
42093
|
+
});
|
|
42094
|
+
delete headers["content-encoding"];
|
|
42095
|
+
delete headers["content-length"];
|
|
42096
|
+
const logger = getLogger().child("start-api");
|
|
42097
|
+
Object.assign(headers, evaluateResponseParameters(selected.entry?.ResponseParameters, { onUnsupported: (_k, _v, reason) => logger.warn(`HTTP_PROXY response: ${reason}`) }));
|
|
42098
|
+
return {
|
|
42099
|
+
statusCode: selected.entry ? selected.statusCode : upstream.status,
|
|
42100
|
+
headers,
|
|
42101
|
+
body: upstreamBody
|
|
42102
|
+
};
|
|
42103
|
+
}
|
|
42104
|
+
/**
|
|
42105
|
+
* Dispatch an HTTP (non-proxy) integration: HTTP_PROXY + VTL on both
|
|
42106
|
+
* directions. Same upstream-call shape; the request body is transformed
|
|
42107
|
+
* via VTL, and the response body is transformed via VTL too.
|
|
42108
|
+
*/
|
|
42109
|
+
async function dispatchHttpIntegration(config, req, deps) {
|
|
42110
|
+
const logger = getLogger().child("start-api");
|
|
42111
|
+
const url = substituteUriPlaceholders(config.uri, req);
|
|
42112
|
+
const method = config.integrationHttpMethod ?? req.method;
|
|
42113
|
+
const ctx = buildVtlContextFromRequest(req, req.body.toString("utf-8"));
|
|
42114
|
+
const reqTemplate = pickRequestTemplate(config.requestTemplates, req.headers["content-type"]);
|
|
42115
|
+
let outBody;
|
|
42116
|
+
let outContentType = req.headers["content-type"] ?? "application/json";
|
|
42117
|
+
if (reqTemplate) {
|
|
42118
|
+
try {
|
|
42119
|
+
outBody = evaluateVtl(reqTemplate.template, ctx);
|
|
42120
|
+
} catch (err) {
|
|
42121
|
+
return vtlFailure("request", err, reqTemplate.template);
|
|
42122
|
+
}
|
|
42123
|
+
outContentType = reqTemplate.contentType;
|
|
42124
|
+
} else outBody = req.body.toString("utf-8");
|
|
42125
|
+
const outHeaders = {
|
|
42126
|
+
...req.headers,
|
|
42127
|
+
"content-type": outContentType
|
|
42128
|
+
};
|
|
42129
|
+
for (const drop of [
|
|
42130
|
+
"host",
|
|
42131
|
+
"connection",
|
|
42132
|
+
"content-length",
|
|
42133
|
+
"transfer-encoding"
|
|
42134
|
+
]) delete outHeaders[drop];
|
|
42135
|
+
applyRequestParameters(config.requestParameters, req, {
|
|
42136
|
+
headers: outHeaders,
|
|
42137
|
+
urlObj: void 0
|
|
42138
|
+
});
|
|
42139
|
+
const fetchImpl = deps.fetch ?? globalThis.fetch;
|
|
42140
|
+
const fetchInit = {
|
|
42141
|
+
method,
|
|
42142
|
+
headers: outHeaders
|
|
42143
|
+
};
|
|
42144
|
+
if (outBody !== void 0 && outBody.length > 0) fetchInit.body = outBody;
|
|
42145
|
+
let upstream;
|
|
42146
|
+
try {
|
|
42147
|
+
upstream = await fetchImpl(url, fetchInit);
|
|
42148
|
+
} catch (err) {
|
|
42149
|
+
return {
|
|
42150
|
+
statusCode: 502,
|
|
42151
|
+
headers: { "content-type": "application/json" },
|
|
42152
|
+
body: JSON.stringify({
|
|
42153
|
+
message: "HTTP upstream unreachable",
|
|
42154
|
+
url,
|
|
42155
|
+
reason: err instanceof Error ? err.message : String(err)
|
|
42156
|
+
})
|
|
42157
|
+
};
|
|
42158
|
+
}
|
|
42159
|
+
const upstreamContentType = upstream.headers.get("content-type") ?? "application/octet-stream";
|
|
42160
|
+
const isUpstreamTextLike = isTextLikeContentType(upstreamContentType);
|
|
42161
|
+
let upstreamText;
|
|
42162
|
+
let upstreamBinary;
|
|
42163
|
+
if (isUpstreamTextLike) upstreamText = await upstream.text();
|
|
42164
|
+
else upstreamBinary = Buffer.from(await upstream.arrayBuffer());
|
|
42165
|
+
const selected = selectIntegrationResponse(config.responses, String(upstream.status), upstream.status);
|
|
42166
|
+
let body;
|
|
42167
|
+
let contentType = upstreamContentType;
|
|
42168
|
+
if (selected.entry) {
|
|
42169
|
+
const picked = pickResponseTemplate(selected.entry.ResponseTemplates, req.headers["accept"]);
|
|
42170
|
+
if (picked) if (upstreamText === void 0) {
|
|
42171
|
+
logger.warn(`HTTP response: ResponseTemplates set but upstream Content-Type '${upstreamContentType}' is binary; passing body through unchanged.`);
|
|
42172
|
+
body = upstreamBinary;
|
|
42173
|
+
} else {
|
|
42174
|
+
const respCtx = buildVtlContextFromRequest(req, upstreamText, safeJsonParse(upstreamText));
|
|
42175
|
+
try {
|
|
42176
|
+
body = evaluateVtl(picked.template, respCtx);
|
|
42177
|
+
} catch (err) {
|
|
42178
|
+
return vtlFailure("response", err, picked.template);
|
|
42179
|
+
}
|
|
42180
|
+
contentType = picked.contentType;
|
|
42181
|
+
}
|
|
42182
|
+
else body = upstreamText ?? upstreamBinary;
|
|
42183
|
+
} else body = upstreamText ?? upstreamBinary;
|
|
42184
|
+
const headers = { "content-type": contentType };
|
|
42185
|
+
Object.assign(headers, evaluateResponseParameters(selected.entry?.ResponseParameters, { onUnsupported: (_k, _v, reason) => logger.warn(`HTTP response: ${reason}`) }));
|
|
42186
|
+
return {
|
|
42187
|
+
statusCode: selected.statusCode,
|
|
42188
|
+
headers,
|
|
42189
|
+
body
|
|
42190
|
+
};
|
|
42191
|
+
}
|
|
42192
|
+
/**
|
|
42193
|
+
* Dispatch an AWS (Lambda non-proxy) integration. The request body is
|
|
42194
|
+
* transformed via VTL into the Lambda event; the Lambda is invoked via
|
|
42195
|
+
* RIE; the return value is transformed via ResponseTemplates.
|
|
42196
|
+
*
|
|
42197
|
+
* AWS error routing: when the Lambda returns an object with an
|
|
42198
|
+
* `errorMessage` field (Node Lambda runtime convention), AWS treats it
|
|
42199
|
+
* as an error and matches `SelectionPattern` against the
|
|
42200
|
+
* `errorMessage`. Otherwise success.
|
|
42201
|
+
*/
|
|
42202
|
+
async function dispatchAwsLambdaIntegration(config, req, deps) {
|
|
42203
|
+
const logger = getLogger().child("start-api");
|
|
42204
|
+
const ctx = buildVtlContextFromRequest(req, req.body.toString("utf-8"));
|
|
42205
|
+
const template = pickRequestTemplate(config.requestTemplates, req.headers["content-type"]);
|
|
42206
|
+
let eventPayload;
|
|
42207
|
+
if (template) {
|
|
42208
|
+
let rendered;
|
|
42209
|
+
try {
|
|
42210
|
+
rendered = evaluateVtl(template.template, ctx);
|
|
42211
|
+
} catch (err) {
|
|
42212
|
+
return vtlFailure("request", err, template.template);
|
|
42213
|
+
}
|
|
42214
|
+
try {
|
|
42215
|
+
eventPayload = JSON.parse(rendered);
|
|
42216
|
+
} catch {
|
|
42217
|
+
eventPayload = rendered;
|
|
42218
|
+
}
|
|
42219
|
+
} else eventPayload = safeJsonParse(req.body.toString("utf-8")) ?? req.body.toString("utf-8");
|
|
42220
|
+
let handle;
|
|
42221
|
+
try {
|
|
42222
|
+
handle = await deps.pool.acquire(config.lambdaLogicalId);
|
|
42223
|
+
} catch (err) {
|
|
42224
|
+
return {
|
|
42225
|
+
statusCode: 502,
|
|
42226
|
+
headers: { "content-type": "application/json" },
|
|
42227
|
+
body: JSON.stringify({
|
|
42228
|
+
message: "Failed to acquire RIE container for AWS Lambda non-proxy integration",
|
|
42229
|
+
reason: err instanceof Error ? err.message : String(err)
|
|
42230
|
+
})
|
|
42231
|
+
};
|
|
42232
|
+
}
|
|
42233
|
+
let invokeOutcome;
|
|
42234
|
+
try {
|
|
42235
|
+
invokeOutcome = await invokeRie(handle.containerHost, handle.hostPort, eventPayload, deps.rieTimeoutMs);
|
|
42236
|
+
} catch (err) {
|
|
42237
|
+
deps.pool.release(handle);
|
|
42238
|
+
return {
|
|
42239
|
+
statusCode: 502,
|
|
42240
|
+
headers: { "content-type": "application/json" },
|
|
42241
|
+
body: JSON.stringify({
|
|
42242
|
+
message: "AWS Lambda non-proxy invocation failed",
|
|
42243
|
+
reason: err instanceof Error ? err.message : String(err)
|
|
42244
|
+
})
|
|
42245
|
+
};
|
|
42246
|
+
}
|
|
42247
|
+
deps.pool.release(handle);
|
|
42248
|
+
const payload = invokeOutcome.payload;
|
|
42249
|
+
const isError = payload !== null && typeof payload === "object" && "errorMessage" in payload;
|
|
42250
|
+
const matchTarget = isError ? String(payload["errorMessage"]) : "success";
|
|
42251
|
+
const selected = selectIntegrationResponse(config.responses, matchTarget, isError ? 500 : 200);
|
|
42252
|
+
const respCtx = buildVtlContextFromRequest(req, JSON.stringify(payload ?? null), payload);
|
|
42253
|
+
let body = "";
|
|
42254
|
+
let contentType = "application/json";
|
|
42255
|
+
if (selected.entry) {
|
|
42256
|
+
const picked = pickResponseTemplate(selected.entry.ResponseTemplates, req.headers["accept"]);
|
|
42257
|
+
if (picked) {
|
|
42258
|
+
try {
|
|
42259
|
+
body = evaluateVtl(picked.template, respCtx);
|
|
42260
|
+
} catch (err) {
|
|
42261
|
+
return vtlFailure("response", err, picked.template);
|
|
42262
|
+
}
|
|
42263
|
+
contentType = picked.contentType;
|
|
42264
|
+
} else body = JSON.stringify(payload ?? null);
|
|
42265
|
+
} else body = JSON.stringify(payload ?? null);
|
|
42266
|
+
const headers = { "content-type": contentType };
|
|
42267
|
+
Object.assign(headers, evaluateResponseParameters(selected.entry?.ResponseParameters, { onUnsupported: (_k, _v, reason) => logger.warn(`AWS Lambda non-proxy response: ${reason}`) }));
|
|
42268
|
+
return {
|
|
42269
|
+
statusCode: selected.statusCode,
|
|
42270
|
+
headers,
|
|
42271
|
+
body
|
|
42272
|
+
};
|
|
42273
|
+
}
|
|
42274
|
+
function buildVtlContextFromRequest(req, body, inputRoot) {
|
|
42275
|
+
return {
|
|
42276
|
+
input: buildVtlInput(body, req.headers, req.querystring, req.pathParameters),
|
|
42277
|
+
context: buildVtlRequestContext({
|
|
42278
|
+
requestId: req.requestId,
|
|
42279
|
+
httpMethod: req.method,
|
|
42280
|
+
resourcePath: req.resourcePath,
|
|
42281
|
+
stage: req.stage,
|
|
42282
|
+
sourceIp: req.sourceIp,
|
|
42283
|
+
userAgent: req.userAgent
|
|
42284
|
+
}),
|
|
42285
|
+
util: buildDefaultUtil(),
|
|
42286
|
+
...inputRoot !== void 0 && { inputRoot }
|
|
42287
|
+
};
|
|
42288
|
+
}
|
|
42289
|
+
function pickRequestTemplate(requestTemplates, contentType) {
|
|
42290
|
+
if (!requestTemplates) return void 0;
|
|
42291
|
+
const entries = Object.entries(requestTemplates);
|
|
42292
|
+
if (entries.length === 0) return void 0;
|
|
42293
|
+
if (contentType) {
|
|
42294
|
+
const primary = contentType.split(";")[0].trim();
|
|
42295
|
+
if (requestTemplates[primary] !== void 0) return {
|
|
42296
|
+
template: requestTemplates[primary],
|
|
42297
|
+
contentType: primary
|
|
42298
|
+
};
|
|
42299
|
+
}
|
|
42300
|
+
if (requestTemplates["application/json"] !== void 0) return {
|
|
42301
|
+
template: requestTemplates["application/json"],
|
|
42302
|
+
contentType: "application/json"
|
|
42303
|
+
};
|
|
42304
|
+
const first = entries[0];
|
|
42305
|
+
return {
|
|
42306
|
+
template: first[1],
|
|
42307
|
+
contentType: first[0]
|
|
42308
|
+
};
|
|
42309
|
+
}
|
|
42310
|
+
/**
|
|
42311
|
+
* Extract `{"statusCode": <N>}` from a rendered MOCK request template.
|
|
42312
|
+
* AWS uses this single key to drive `IntegrationResponses[]` selection.
|
|
42313
|
+
*/
|
|
42314
|
+
function extractStatusCodeFromRendered(rendered) {
|
|
42315
|
+
try {
|
|
42316
|
+
const parsed = JSON.parse(rendered);
|
|
42317
|
+
if (parsed && typeof parsed === "object" && "statusCode" in parsed) {
|
|
42318
|
+
const val = parsed["statusCode"];
|
|
42319
|
+
if (typeof val === "number") return val;
|
|
42320
|
+
if (typeof val === "string") {
|
|
42321
|
+
const n = Number.parseInt(val, 10);
|
|
42322
|
+
if (Number.isFinite(n)) return n;
|
|
42323
|
+
}
|
|
42324
|
+
}
|
|
42325
|
+
} catch {}
|
|
42326
|
+
}
|
|
42327
|
+
function defaultResponseEntry(entries) {
|
|
42328
|
+
return entries.find((e) => e.SelectionPattern === void 0 || e.SelectionPattern === "") ?? null;
|
|
42329
|
+
}
|
|
42330
|
+
function parseStatus(raw) {
|
|
42331
|
+
if (typeof raw === "number" && Number.isFinite(raw)) return raw;
|
|
42332
|
+
if (typeof raw === "string") {
|
|
42333
|
+
const n = Number.parseInt(raw, 10);
|
|
42334
|
+
if (Number.isFinite(n)) return n;
|
|
42335
|
+
}
|
|
42336
|
+
}
|
|
42337
|
+
/**
|
|
42338
|
+
* Heuristic: is the given HTTP `Content-Type` header value likely to
|
|
42339
|
+
* carry text content that VTL ResponseTemplates can safely render
|
|
42340
|
+
* against? Used by `dispatchHttpIntegration` to branch the upstream
|
|
42341
|
+
* body read between `.text()` (text-like) and `.arrayBuffer()` (binary
|
|
42342
|
+
* pass-through). Charset parameters are stripped before matching.
|
|
42343
|
+
*
|
|
42344
|
+
* Exported for unit testing.
|
|
42345
|
+
*/
|
|
42346
|
+
function isTextLikeContentType(contentType) {
|
|
42347
|
+
const primary = contentType.split(";")[0].trim().toLowerCase();
|
|
42348
|
+
if (primary.startsWith("text/")) return true;
|
|
42349
|
+
if (primary === "application/json" || primary === "application/xml" || primary === "application/x-www-form-urlencoded" || primary === "application/javascript" || primary === "application/ld+json") return true;
|
|
42350
|
+
if (primary.startsWith("application/") && (primary.endsWith("+json") || primary.endsWith("+xml"))) return true;
|
|
42351
|
+
return false;
|
|
42352
|
+
}
|
|
42353
|
+
/**
|
|
42354
|
+
* Classify a hostname or IP literal against well-known internal address
|
|
42355
|
+
* spaces. Used by `warnSsrfRiskyUri` at server boot to surface a warn
|
|
42356
|
+
* line per HTTP / HTTP_PROXY integration whose URI points at a
|
|
42357
|
+
* potentially-sensitive destination. Best-effort; does NOT do DNS
|
|
42358
|
+
* resolution — only matches hostname literals that are already an IP.
|
|
42359
|
+
*
|
|
42360
|
+
* Returns `undefined` when the host appears safe (public DNS name) OR
|
|
42361
|
+
* cannot be classified (DNS name that may resolve to an internal IP
|
|
42362
|
+
* the helper cannot see without async DNS).
|
|
42363
|
+
*
|
|
42364
|
+
* Exported for unit testing.
|
|
42365
|
+
*/
|
|
42366
|
+
function classifyInternalHost(host) {
|
|
42367
|
+
const h = host.replace(/^\[|\]$/g, "");
|
|
42368
|
+
if (h === "169.254.169.254" || h === "[fd00:ec2::254]" || h === "fd00:ec2::254") return "AWS IMDS (169.254.169.254) — credentials exfiltration risk";
|
|
42369
|
+
if (/^127\.\d+\.\d+\.\d+$/.test(h)) return "IPv4 loopback (127.0.0.0/8)";
|
|
42370
|
+
if (h === "::1") return "IPv6 loopback (::1)";
|
|
42371
|
+
if (/^169\.254\.\d+\.\d+$/.test(h)) return "IPv4 link-local (169.254.0.0/16)";
|
|
42372
|
+
if (/^fe[89ab][0-9a-f]?:/i.test(h)) return "IPv6 link-local (fe80::/10)";
|
|
42373
|
+
if (/^10\.\d+\.\d+\.\d+$/.test(h)) return "RFC1918 private (10.0.0.0/8)";
|
|
42374
|
+
if (/^192\.168\.\d+\.\d+$/.test(h)) return "RFC1918 private (192.168.0.0/16)";
|
|
42375
|
+
const m = /^172\.(\d+)\.\d+\.\d+$/.exec(h);
|
|
42376
|
+
if (m && Number(m[1]) >= 16 && Number(m[1]) <= 31) return "RFC1918 private (172.16.0.0/12)";
|
|
42377
|
+
}
|
|
42378
|
+
/**
|
|
42379
|
+
* Emit a `logger.warn` line for each HTTP / HTTP_PROXY integration
|
|
42380
|
+
* whose `Integration.Uri` parses to a hostname classified as internal
|
|
42381
|
+
* by `classifyInternalHost`. Called once at server boot from
|
|
42382
|
+
* `cdkd local start-api`'s discovery pass; per-route deduplicated.
|
|
42383
|
+
*
|
|
42384
|
+
* cdkd does NOT block the URI — this is a developer-loop tool, not a
|
|
42385
|
+
* security boundary, and warn-and-proceed matches the precedent set by
|
|
42386
|
+
* the cognito JWKS pass-through fallback. The right v2 follow-up is an
|
|
42387
|
+
* `--allow-internal-uri` flag (and an opposite default block) once the
|
|
42388
|
+
* surface is well-understood.
|
|
42389
|
+
*/
|
|
42390
|
+
function warnSsrfRiskyUri(uri, routeLabel, warn) {
|
|
42391
|
+
let host;
|
|
42392
|
+
try {
|
|
42393
|
+
const sanitized = uri.replace(/\{[^/{}]+\}/g, "x");
|
|
42394
|
+
host = new URL(sanitized).hostname;
|
|
42395
|
+
} catch {
|
|
42396
|
+
return;
|
|
42397
|
+
}
|
|
42398
|
+
const classification = classifyInternalHost(host);
|
|
42399
|
+
if (classification !== void 0) warn(`Integration URI for ${routeLabel} points at ${host} — ${classification}. cdkd does NOT block this; ensure the upstream is intentional.`);
|
|
42400
|
+
}
|
|
42401
|
+
function safeJsonParse(s) {
|
|
42402
|
+
try {
|
|
42403
|
+
return JSON.parse(s);
|
|
42404
|
+
} catch {
|
|
42405
|
+
return null;
|
|
42406
|
+
}
|
|
42407
|
+
}
|
|
42408
|
+
/**
|
|
42409
|
+
* Apply `Integration.RequestParameters` mappings — header / query / path
|
|
42410
|
+
* rewrites that copy from `method.request.X` to `integration.request.Y`.
|
|
42411
|
+
*
|
|
42412
|
+
* Supported key shapes:
|
|
42413
|
+
* - `integration.request.header.<name>` → outgoing header
|
|
42414
|
+
* - `integration.request.querystring.<name>` → query string param
|
|
42415
|
+
* - `integration.request.path.<name>` → path placeholder substitution
|
|
42416
|
+
*
|
|
42417
|
+
* Supported value shapes:
|
|
42418
|
+
* - `method.request.header.<name>` → read incoming header
|
|
42419
|
+
* - `method.request.querystring.<name>` → read incoming query param
|
|
42420
|
+
* - `method.request.path.<name>` → read path parameter
|
|
42421
|
+
* - `'literal'` → single-quoted literal
|
|
42422
|
+
*
|
|
42423
|
+
* Unsupported mapping expressions are logged at warn and skipped (matches
|
|
42424
|
+
* the ResponseParameters handling in `integration-response-selector.ts`).
|
|
42425
|
+
*/
|
|
42426
|
+
function applyRequestParameters(requestParameters, req, out) {
|
|
42427
|
+
if (!requestParameters) return;
|
|
42428
|
+
const logger = getLogger().child("start-api");
|
|
42429
|
+
for (const [key, value] of Object.entries(requestParameters)) {
|
|
42430
|
+
const resolved = resolveRequestParameterValue(value, req);
|
|
42431
|
+
if (resolved === void 0) {
|
|
42432
|
+
logger.warn(`RequestParameter '${key}' value '${value}' is not a recognized mapping; skipping.`);
|
|
42433
|
+
continue;
|
|
42434
|
+
}
|
|
42435
|
+
const headerMatch = /^integration\.request\.header\.(.+)$/.exec(key);
|
|
42436
|
+
const queryMatch = /^integration\.request\.querystring\.(.+)$/.exec(key);
|
|
42437
|
+
const pathMatch = /^integration\.request\.path\.(.+)$/.exec(key);
|
|
42438
|
+
if (headerMatch) out.headers[headerMatch[1].toLowerCase()] = resolved;
|
|
42439
|
+
else if (queryMatch) logger.warn(`RequestParameter '${key}' (querystring rewrite) is recognized but cdkd applies querystring rewrites only via URI placeholder substitution; ignoring.`);
|
|
42440
|
+
else if (pathMatch) logger.warn(`RequestParameter '${key}' (path rewrite) is recognized but cdkd substitutes path placeholders via {param} in the URI; ignoring.`);
|
|
42441
|
+
else logger.warn(`Unsupported RequestParameter key '${key}'; skipping.`);
|
|
42442
|
+
}
|
|
42443
|
+
}
|
|
42444
|
+
function resolveRequestParameterValue(raw, req) {
|
|
42445
|
+
if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) return raw.slice(1, -1);
|
|
42446
|
+
const headerMatch = /^method\.request\.header\.(.+)$/.exec(raw);
|
|
42447
|
+
if (headerMatch) return req.headers[headerMatch[1].toLowerCase()];
|
|
42448
|
+
const queryMatch = /^method\.request\.querystring\.(.+)$/.exec(raw);
|
|
42449
|
+
if (queryMatch) return req.querystring[queryMatch[1]];
|
|
42450
|
+
const pathMatch = /^method\.request\.path\.(.+)$/.exec(raw);
|
|
42451
|
+
if (pathMatch) return req.pathParameters[pathMatch[1]];
|
|
42452
|
+
}
|
|
42453
|
+
/**
|
|
42454
|
+
* Substitute `{paramName}` placeholders in a URI string with the value
|
|
42455
|
+
* of the matching path parameter on the request. Used by HTTP_PROXY /
|
|
42456
|
+
* HTTP integrations whose `Integration.Uri` may contain such
|
|
42457
|
+
* placeholders (e.g. `https://upstream.example.com/users/{userId}`).
|
|
42458
|
+
*/
|
|
42459
|
+
function substituteUriPlaceholders(uri, req) {
|
|
42460
|
+
return uri.replace(/\{([^/{}]+)\}/g, (_, name) => {
|
|
42461
|
+
const val = req.pathParameters[name];
|
|
42462
|
+
return val !== void 0 ? encodeURIComponent(val) : "";
|
|
42463
|
+
});
|
|
42464
|
+
}
|
|
42465
|
+
function vtlFailure(direction, err, template) {
|
|
42466
|
+
const reason = err instanceof VtlEvaluationError ? err.message : err instanceof Error ? err.message : String(err);
|
|
42467
|
+
return {
|
|
42468
|
+
statusCode: 502,
|
|
42469
|
+
headers: { "content-type": "application/json" },
|
|
42470
|
+
body: JSON.stringify({
|
|
42471
|
+
message: `VTL ${direction}-template evaluation failed`,
|
|
42472
|
+
reason,
|
|
42473
|
+
template: template.length > 200 ? template.slice(0, 200) + "..." : template
|
|
42474
|
+
})
|
|
42475
|
+
};
|
|
42476
|
+
}
|
|
42477
|
+
|
|
40894
42478
|
//#endregion
|
|
40895
42479
|
//#region src/local/container-pool.ts
|
|
40896
42480
|
const DEFAULT_IDLE_MS = 6e4;
|
|
@@ -43871,6 +45455,16 @@ async function handleRequest(req, res, state, opts) {
|
|
|
43871
45455
|
const overlay = buildOverlay(authorizer, authResult);
|
|
43872
45456
|
if (overlay) baseEvent = applyAuthorizerOverlay(baseEvent, overlay);
|
|
43873
45457
|
}
|
|
45458
|
+
if (match.route.restV1Integration) {
|
|
45459
|
+
try {
|
|
45460
|
+
writeIntegrationOutcome(res, await dispatchRestV1Integration(match.route.restV1Integration, snapshot, matchCtx, state, opts));
|
|
45461
|
+
} catch (err) {
|
|
45462
|
+
logger.error(`REST v1 ${match.route.restV1Integration.kind} dispatch failed for ${match.route.declaredAt}: ${err instanceof Error ? err.message : String(err)}`);
|
|
45463
|
+
if (!res.headersSent) writeError(res, 502);
|
|
45464
|
+
else res.end();
|
|
45465
|
+
}
|
|
45466
|
+
return;
|
|
45467
|
+
}
|
|
43874
45468
|
let handle;
|
|
43875
45469
|
try {
|
|
43876
45470
|
handle = await state.pool.acquire(match.route.lambdaLogicalId);
|
|
@@ -43950,6 +45544,50 @@ function writeStreamingResponse(res, result, releasePool) {
|
|
|
43950
45544
|
body.pipe(res);
|
|
43951
45545
|
}
|
|
43952
45546
|
/**
|
|
45547
|
+
* Dispatch a REST v1 non-AWS_PROXY integration to the matching handler.
|
|
45548
|
+
* Built once per request from the matched route + request snapshot.
|
|
45549
|
+
*
|
|
45550
|
+
* Returns a {@link RestV1IntegrationOutcome} — the caller writes it
|
|
45551
|
+
* onto the `ServerResponse` via {@link writeIntegrationOutcome}.
|
|
45552
|
+
*/
|
|
45553
|
+
async function dispatchRestV1Integration(integration, snapshot, matchCtx, state, opts) {
|
|
45554
|
+
const headers = lowercaseSingularHeaders(snapshot.headers);
|
|
45555
|
+
const querystring = parseQueryStringSingular(snapshot.rawUrl);
|
|
45556
|
+
const sourceIp = snapshot.sourceIp ?? "127.0.0.1";
|
|
45557
|
+
const userAgent = headers["user-agent"] ?? "";
|
|
45558
|
+
const req = {
|
|
45559
|
+
method: snapshot.method.toUpperCase(),
|
|
45560
|
+
matchedPath: matchCtx.matchedPath,
|
|
45561
|
+
pathParameters: matchCtx.pathParameters,
|
|
45562
|
+
querystring,
|
|
45563
|
+
headers,
|
|
45564
|
+
body: snapshot.body,
|
|
45565
|
+
sourceIp,
|
|
45566
|
+
userAgent,
|
|
45567
|
+
stage: matchCtx.route.stage,
|
|
45568
|
+
resourcePath: matchCtx.route.pathPattern,
|
|
45569
|
+
requestId: randomUUID()
|
|
45570
|
+
};
|
|
45571
|
+
const deps = {
|
|
45572
|
+
pool: state.pool,
|
|
45573
|
+
rieTimeoutMs: opts.rieTimeoutMs
|
|
45574
|
+
};
|
|
45575
|
+
switch (integration.kind) {
|
|
45576
|
+
case "mock": return dispatchMockIntegration(integration, req);
|
|
45577
|
+
case "http-proxy": return await dispatchHttpProxyIntegration(integration, req, deps);
|
|
45578
|
+
case "http": return await dispatchHttpIntegration(integration, req, deps);
|
|
45579
|
+
case "aws-lambda": return await dispatchAwsLambdaIntegration(integration, req, deps);
|
|
45580
|
+
}
|
|
45581
|
+
}
|
|
45582
|
+
/**
|
|
45583
|
+
* Write a {@link RestV1IntegrationOutcome} to the HTTP response.
|
|
45584
|
+
*/
|
|
45585
|
+
function writeIntegrationOutcome(res, outcome) {
|
|
45586
|
+
res.statusCode = outcome.statusCode;
|
|
45587
|
+
for (const [name, value] of Object.entries(outcome.headers)) res.setHeader(name, value);
|
|
45588
|
+
res.end(outcome.body);
|
|
45589
|
+
}
|
|
45590
|
+
/**
|
|
43953
45591
|
* Attempt CORS preflight interception. Returns `true` when the
|
|
43954
45592
|
* preflight response was written (caller must NOT continue to route
|
|
43955
45593
|
* dispatch); `false` when no preflight match (caller falls through to
|
|
@@ -45265,7 +46903,9 @@ async function localStartApiCommand(target, options) {
|
|
|
45265
46903
|
if (basePort !== 0) nextPort += 1;
|
|
45266
46904
|
}
|
|
45267
46905
|
printPerServerRouteTables(servers);
|
|
45268
|
-
|
|
46906
|
+
const allRoutes = servers.flatMap((s) => s.group.routes.map((r) => r.route));
|
|
46907
|
+
warnUnsupportedRoutes(allRoutes, logger);
|
|
46908
|
+
warnSsrfRiskyIntegrations(allRoutes, logger);
|
|
45269
46909
|
logger.info(`Per-Lambda concurrency: ${perLambdaConcurrency} (override with --per-lambda-concurrency)`);
|
|
45270
46910
|
for (const { group, server } of servers) process.stdout.write(`Server listening on ${server.scheme}://${server.host}:${server.port} (${group.displayName})\n`);
|
|
45271
46911
|
process.stdout.write("^C to stop and clean up containers.\n");
|
|
@@ -45814,12 +47454,26 @@ function printRouteTable(routes) {
|
|
|
45814
47454
|
process.stdout.write("Discovered routes:\n");
|
|
45815
47455
|
for (const r of sorted) {
|
|
45816
47456
|
const sourceLabel = r.source === "http-api" ? "HTTP API" : r.source === "rest-v1" ? `REST v1, stage '${r.stage}'` : "Function URL";
|
|
45817
|
-
const target = r.mockCors ? "[MOCK CORS preflight]" : r.unsupported ? "[501 Not Implemented]" : r.serviceIntegration ? `[${r.serviceIntegration.subtype}]` : r.lambdaLogicalId;
|
|
47457
|
+
const target = r.mockCors ? "[MOCK CORS preflight]" : r.unsupported ? "[501 Not Implemented]" : r.serviceIntegration ? `[${r.serviceIntegration.subtype}]` : r.restV1Integration ? formatRestV1IntegrationLabel(r.restV1Integration) : r.lambdaLogicalId;
|
|
45818
47458
|
process.stdout.write(` ${r.method.padEnd(methodWidth)} ${r.pathPattern.padEnd(pathWidth)} -> ${target} (${sourceLabel})\n`);
|
|
45819
47459
|
}
|
|
45820
47460
|
process.stdout.write("\n");
|
|
45821
47461
|
}
|
|
45822
47462
|
/**
|
|
47463
|
+
* Format the route-table label for a REST v1 non-AWS_PROXY integration.
|
|
47464
|
+
* `MOCK` / `HTTP` / `HTTP_PROXY` show their integration kind directly;
|
|
47465
|
+
* `AWS` (Lambda non-proxy) shows the Lambda logical id with an `[AWS]`
|
|
47466
|
+
* suffix so it's distinguishable from AWS_PROXY rows. Closes #457.
|
|
47467
|
+
*/
|
|
47468
|
+
function formatRestV1IntegrationLabel(integration) {
|
|
47469
|
+
switch (integration.kind) {
|
|
47470
|
+
case "mock": return "[MOCK]";
|
|
47471
|
+
case "http-proxy": return `[HTTP_PROXY ${integration.uri}]`;
|
|
47472
|
+
case "http": return `[HTTP ${integration.uri}]`;
|
|
47473
|
+
case "aws-lambda": return `${integration.lambdaLogicalId} [AWS]`;
|
|
47474
|
+
}
|
|
47475
|
+
}
|
|
47476
|
+
/**
|
|
45823
47477
|
* Materialize an inline Lambda body (`Code.ZipFile`) to a tmpdir and
|
|
45824
47478
|
* return the directory the container should mount at /var/task.
|
|
45825
47479
|
* Mirrors `cdkd local invoke`'s implementation; the only divergence is
|
|
@@ -45973,6 +47627,26 @@ function warnUnsupportedRoutes(routes, logger) {
|
|
|
45973
47627
|
return unsupported.length;
|
|
45974
47628
|
}
|
|
45975
47629
|
/**
|
|
47630
|
+
* Surface a one-line warn per HTTP / HTTP_PROXY integration whose
|
|
47631
|
+
* `Integration.Uri` points at a well-known internal address space
|
|
47632
|
+
* (AWS IMDS, loopback, link-local, RFC1918). PR #505 / issue #457
|
|
47633
|
+
* follow-up: cdkd does NOT block these — warn-and-proceed matches the
|
|
47634
|
+
* cognito JWKS pass-through pattern — but the user should see the
|
|
47635
|
+
* destination at boot so a malicious / typo'd template Uri does not
|
|
47636
|
+
* silently exfiltrate credentials in CI. Deduplicated per-Uri.
|
|
47637
|
+
*/
|
|
47638
|
+
function warnSsrfRiskyIntegrations(routes, logger) {
|
|
47639
|
+
const seen = /* @__PURE__ */ new Set();
|
|
47640
|
+
for (const r of routes) {
|
|
47641
|
+
const integ = r.restV1Integration;
|
|
47642
|
+
if (!integ) continue;
|
|
47643
|
+
if (integ.kind !== "http" && integ.kind !== "http-proxy") continue;
|
|
47644
|
+
if (seen.has(integ.uri)) continue;
|
|
47645
|
+
seen.add(integ.uri);
|
|
47646
|
+
warnSsrfRiskyUri(integ.uri, `${r.method} ${r.pathPattern}`, (msg) => logger.warn(msg));
|
|
47647
|
+
}
|
|
47648
|
+
}
|
|
47649
|
+
/**
|
|
45976
47650
|
* One reload cycle for the multi-server topology (issue #260). The
|
|
45977
47651
|
* watcher serializes calls via a chain promise; this function:
|
|
45978
47652
|
*
|
|
@@ -46021,7 +47695,9 @@ async function reloadAllServers(args) {
|
|
|
46021
47695
|
lastAssetPaths.value = computeAssetPaths(material.specs);
|
|
46022
47696
|
if (watcher) watcher.update([output, ...lastAssetPaths.value]);
|
|
46023
47697
|
printPerServerRouteTables(servers);
|
|
46024
|
-
|
|
47698
|
+
const allRoutes = servers.flatMap((s) => s.group.routes.map((r) => r.route));
|
|
47699
|
+
warnUnsupportedRoutes(allRoutes, logger);
|
|
47700
|
+
warnSsrfRiskyIntegrations(allRoutes, logger);
|
|
46025
47701
|
}
|
|
46026
47702
|
/**
|
|
46027
47703
|
* Returns true when any value in the function's template env map is a
|
|
@@ -50031,7 +51707,7 @@ function reorderArgs(argv) {
|
|
|
50031
51707
|
*/
|
|
50032
51708
|
async function main() {
|
|
50033
51709
|
const program = new Command();
|
|
50034
|
-
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.
|
|
51710
|
+
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.132.0");
|
|
50035
51711
|
program.addCommand(createBootstrapCommand());
|
|
50036
51712
|
program.addCommand(createSynthCommand());
|
|
50037
51713
|
program.addCommand(createListCommand());
|