Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion packages/configorama/src/utils/encoders/js-fixes.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,40 @@ function hasEncodedJson(value) {
* @param {string} [varPrefix] - the variable opening, e.g. `${` or `#{`
* @returns {string}
*/
/**
* Whether index sits inside a variable expression. Inside one every { } counts, so a JSON object
* argument (${merge({"a":1})}) doesn't end it early; outside, only varPrefix opens one.
* @param {string} str
* @param {number} index
* @param {string} varPrefix - the variable opening, ending in `{`
* @returns {boolean}
*/
function isInsideVariable(str, index, varPrefix) {
let depth = 0
for (let i = 0; i < index; i++) {
if (depth === 0) {
if (str.startsWith(varPrefix, i)) {
depth = 1
i += varPrefix.length - 1
}
} else if (str[i] === '{') {
depth++
} else if (str[i] === '}') {
depth--
}
}
return depth > 0
}

function encodeJsonArgObjects(str, varPrefix = '${') {
if (!str || typeof str !== 'string' || str.indexOf('{') === -1) return str
if (!varPrefix.endsWith('{')) return str // non-brace syntaxes (e.g. `$[`) don't conflict with JSON braces
const preBrace = varPrefix.slice(0, -1).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const guard = preBrace ? `(?<!${preBrace})` : ''
const pattern = new RegExp(`(\\w+\\([^{}()]*)(${guard}\\{[^{}]*\\})([^{}()]*\\))`, 'g')
return str.replace(pattern, (match, open, obj, close) => {
return str.replace(pattern, (match, open, obj, close, offset) => {
// Only a call inside a variable is configorama syntax; elsewhere (VTL #set($m = {...})) it's text
if (!isInsideVariable(str, offset, varPrefix)) return match
// file()/text() take a path, where { } are plain characters
if (/^(?:file|text)\(/.test(open)) return match
// A { inside a quoted string argument ("{" in eval, '{a},{b}' in split) is text, not JSON
Expand Down
11 changes: 11 additions & 0 deletions packages/configorama/src/utils/encoders/js-fixes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ test('encodeJsonArgObjects - braces inside a quoted string argument are not JSON
assert.is(encodeJsonArgObjects("${split('{a},{b}', ',')}"), "${split('{a},{b}', ',')}")
})

test('encodeJsonArgObjects - a call outside any variable is plain text (VTL #set($m = {...}))', () => {
assert.is(encodeJsonArgObjects('#set($m = { "a": "b" })'), '#set($m = { "a": "b" })')
const vtl = '#set($inputRoot = $input.path(\'$\'))\n#set($mfaMap = {\n "SMS_MFA": "SMS"\n})\n'
assert.is(encodeJsonArgObjects(vtl), vtl)
})

test('encodeJsonArgObjects - only the call inside a variable is encoded when both appear', () => {
const out = encodeJsonArgObjects('#set($m = {"a":1}) ${self:x | help({"k":2})}')
assert.ok(out.startsWith('#set($m = {"a":1}) ${self:x | help(__JSON_B64__'), out)
})

test('encodeJsonArgObjects - file() and text() paths are not JSON', () => {
assert.is(encodeJsonArgObjects('${file(./br/{x}.json):k}'), '${file(./br/{x}.json):k}')
assert.is(encodeJsonArgObjects('${text(./{x}.txt)}'), '${text(./{x}.txt)}')
Expand Down
18 changes: 12 additions & 6 deletions packages/configorama/tests/conformance/harness.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@ const assert = require('uvu/assert')
const ROOT = path.resolve(__dirname, '../..')
const GOLDEN_DIR = path.join(__dirname, 'goldens')

function canonicalize(value) {
/**
* Stable text for a golden: sorted JSON keys, LF endings, machine paths masked
* @param {any} value
* @param {{ keepBackslashes?: boolean }} [options] - keepBackslashes: leave \ as is (config values,
* where \n or \" is data) instead of turning Windows path separators into /
* @returns {string}
*/
function canonicalize(value, options = {}) {
if (typeof value !== 'string') {
value = JSON.stringify(value, stableJsonReplacer, 2)
}
return String(value)
.replace(/\r\n/g, '\n')
.replace(/\\/g, '/')
const text = String(value).replace(/\r\n/g, '\n')
return (options.keepBackslashes ? text : text.replace(/\\/g, '/'))
.replaceAll(ROOT, '<ROOT>')
.replace(/\/Users\/[^/\n]+/g, '/Users/<USER>')
.trimEnd() + '\n'
Expand All @@ -30,9 +36,9 @@ function goldenPath(name, dir = GOLDEN_DIR) {
return path.join(dir, `${name}.golden`)
}

function assertGolden(name, actual, dir = GOLDEN_DIR) {
function assertGolden(name, actual, dir = GOLDEN_DIR, options = {}) {
const expectedPath = goldenPath(name, dir)
const output = canonicalize(actual)
const output = canonicalize(actual, options)

if (process.env.UPDATE_GOLDENS) {
fs.mkdirSync(path.dirname(expectedPath), { recursive: true })
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# API Gateway mapping templates (Velocity): $vars, #set maps, #foreach, and JSON bodies are
# template text, not configorama syntax. Only ${self:...} refs inside are resolved.
service: proxy
provider:
name: aws
stage: ${opt:stage, 'dev'}
resources:
Resources:
GetUser:
Type: AWS::ApiGateway::Method
Properties:
Integration:
RequestTemplates:
application/json: |
#set($token = $input.params('Authorization').replace('Bearer ', ''))
{
"AccessToken": "$token"
}
IntegrationResponses:
- StatusCode: '200'
ResponseTemplates:
application/json:
Fn::If:
- CustomMapping
- |
#set($inputRoot = $input.path('$'))
#set($mfaMap = {
"SMS_MFA": "SMS",
"SOFTWARE_TOKEN_MFA": "TOKEN"
})
#set($flags = { "stage": "${self:provider.stage}" })
{
"username": "$inputRoot.Username",
"attributes": {
#foreach($attr in $inputRoot.UserAttributes)
"$attr.Name": "$attr.Value"#if($foreach.hasNext),#end
#end
},
"mfaSetting": "$mfaMap.get($inputRoot.PreferredMfaSetting)",
"stage": "${self:provider.stage}"
}
- $input.json('$')
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"provider": {
"name": "aws",
"stage": "acme-prod"
},
"resources": {
"Resources": {
"GetUser": {
"Properties": {
"Integration": {
"IntegrationResponses": [
{
"ResponseTemplates": {
"application/json": {
"Fn::If": [
"CustomMapping",
"#set($inputRoot = $input.path('$'))\n#set($mfaMap = {\n \"SMS_MFA\": \"SMS\",\n \"SOFTWARE_TOKEN_MFA\": \"TOKEN\"\n})\n#set($flags = { \"stage\": \"acme-prod\" })\n{\n \"username\": \"$inputRoot.Username\",\n \"attributes\": {\n #foreach($attr in $inputRoot.UserAttributes)\n \"$attr.Name\": \"$attr.Value\"#if($foreach.hasNext),#end\n #end\n },\n \"mfaSetting\": \"$mfaMap.get($inputRoot.PreferredMfaSetting)\",\n \"stage\": \"acme-prod\"\n}\n",
"$input.json('$')"
]
}
},
"StatusCode": "200"
}
],
"RequestTemplates": {
"application/json": "#set($token = $input.params('Authorization').replace('Bearer ', ''))\n{\n \"AccessToken\": \"$token\"\n}\n"
}
}
},
"Type": "AWS::ApiGateway::Method"
}
}
},
"service": "proxy"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"provider": {
"name": "aws",
"stage": "qa"
},
"resources": {
"Resources": {
"GetUser": {
"Properties": {
"Integration": {
"IntegrationResponses": [
{
"ResponseTemplates": {
"application/json": {
"Fn::If": [
"CustomMapping",
"#set($inputRoot = $input.path('$'))\n#set($mfaMap = {\n \"SMS_MFA\": \"SMS\",\n \"SOFTWARE_TOKEN_MFA\": \"TOKEN\"\n})\n#set($flags = { \"stage\": \"qa\" })\n{\n \"username\": \"$inputRoot.Username\",\n \"attributes\": {\n #foreach($attr in $inputRoot.UserAttributes)\n \"$attr.Name\": \"$attr.Value\"#if($foreach.hasNext),#end\n #end\n },\n \"mfaSetting\": \"$mfaMap.get($inputRoot.PreferredMfaSetting)\",\n \"stage\": \"qa\"\n}\n",
"$input.json('$')"
]
}
},
"StatusCode": "200"
}
],
"RequestTemplates": {
"application/json": "#set($token = $input.params('Authorization').replace('Bearer ', ''))\n{\n \"AccessToken\": \"$token\"\n}\n"
}
}
},
"Type": "AWS::ApiGateway::Method"
}
}
},
"service": "proxy"
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"Properties": {
"Name": "builder-acme-prod",
"Source": {
"BuildSpec": "version: 0.2/nphases:/n build:/n commands:/n - test -n /"$REPOSITORY_URL/"/n - if [ -n /"$TOKEN/" ]; then git clone /"https://x-access-token:${TOKEN}@${REPOSITORY_URL#https://}/" repo; else git clone /"$REPOSITORY_URL/" repo; fi/n - export ELAPSED=$(( $(date +%s) - START ))/n - echo /"stage acme-prod/"/n",
"BuildSpec": "version: 0.2\nphases:\n build:\n commands:\n - test -n \"$REPOSITORY_URL\"\n - if [ -n \"$TOKEN\" ]; then git clone \"https://x-access-token:${TOKEN}@${REPOSITORY_URL#https://}\" repo; else git clone \"$REPOSITORY_URL\" repo; fi\n - export ELAPSED=$(( $(date +%s) - START ))\n - echo \"stage acme-prod\"\n",
"Type": "NO_SOURCE"
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"Properties": {
"Name": "builder-qa",
"Source": {
"BuildSpec": "version: 0.2/nphases:/n build:/n commands:/n - test -n /"$REPOSITORY_URL/"/n - if [ -n /"$TOKEN/" ]; then git clone /"https://x-access-token:${TOKEN}@${REPOSITORY_URL#https://}/" repo; else git clone /"$REPOSITORY_URL/" repo; fi/n - export ELAPSED=$(( $(date +%s) - START ))/n - echo /"stage qa/"/n",
"BuildSpec": "version: 0.2\nphases:\n build:\n commands:\n - test -n \"$REPOSITORY_URL\"\n - if [ -n \"$TOKEN\" ]; then git clone \"https://x-access-token:${TOKEN}@${REPOSITORY_URL#https://}\" repo; else git clone \"$REPOSITORY_URL\" repo; fi\n - export ELAPSED=$(( $(date +%s) - START ))\n - echo \"stage qa\"\n",
"Type": "NO_SOURCE"
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ for (const file of fixtures) {
}
}
assert.equal(resolvedSync, resolved, 'sync and async APIs resolve the same')
assertGolden(`${name}.${mode}`, resolved, GOLDENS)
assertGolden(`${name}.${mode}`, resolved, GOLDENS, { keepBackslashes: true })
})
}
}
Expand Down
Loading