//// Smithy endpoint rule set evaluator.
////
//// Implements the runtime side of the Smithy rules engine: parses a
//// `endpoint-rule-set.json` document, then walks it against a set of
//// parameter values and produces a concrete endpoint URL.
////
//// Supported features:
////
//// - Rule types: `endpoint`, `error`, `tree`
//// - Parameters: `String`, `Boolean`, with `default` and `required`
//// - Templates: `{Name}` and `{Var#field}` (and nested `{Var#a#b}`)
//// - Built-in functions: `isSet`, `not`, `booleanEquals`, `stringEquals`,
//// `getAttr`, `substring`, `uriEncode`, `parseURL`, `isValidHostLabel`,
//// `aws.partition`, `aws.parseArn`, `aws.isVirtualHostableS3Bucket`
//// - Common partition data hardcoded (`aws`, `aws-cn`, `aws-us-gov`).
//// For a richer table, vendor `partitions.json` and replace
//// `partition_for/1`.
////
//// Not implemented (rare; flagged at evaluation time as `Unsupported`):
////
//// - `StringArray` parameter type (used by a few new services)
////
//// Codegen at milestone 7 will use this evaluator at compile time to emit
//// per-service endpoint resolvers; the runtime fallback path here keeps
//// hand-written services working before any code is generated.
import gleam/bit_array
import gleam/dict.{type Dict}
import gleam/dynamic/decode
import gleam/int
import gleam/json
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/result
import gleam/string
// ---------- public API ----------
pub type Endpoint {
Endpoint(url: String, headers: Dict(String, List(String)))
}
pub type ResolveError {
/// A rule's `error` branch fired with this message.
RuleError(message: String)
/// No rule matched (the rule set is supposed to always end in either an
/// endpoint or an error rule, so this normally indicates a bad rule set).
NoMatch
/// JSON could not be parsed into a `RuleSet`.
InvalidRuleSet(reason: String)
/// Evaluation tripped over an unimplemented function or a parameter type
/// the evaluator doesn't support yet.
Unsupported(reason: String)
/// A parameter referenced by a rule wasn't supplied and has no default.
MissingParameter(name: String)
/// A required parameter is unset.
RequiredParameterMissing(name: String)
}
/// Parameter values supplied to evaluation. Keys are parameter names.
pub type Params =
Dict(String, Value)
/// Runtime values that flow through evaluation: literals plus the record
/// shape that `aws.partition` returns plus the list shape used for
/// `stringArray` parameters and the `resourceId` field of a parsed ARN.
pub type Value {
StringVal(String)
BoolVal(Bool)
RecordVal(Dict(String, Value))
ListVal(List(Value))
EmptyVal
}
/// Convenience constructor: build a `Params` map from string and boolean
/// pairs. The most common shape (Region + UseFips + UseDualStack).
pub fn params_from(
strings strings: List(#(String, String)),
bools bools: List(#(String, Bool)),
) -> Params {
let s_dict =
list.fold(strings, dict.new(), fn(acc, pair) {
dict.insert(acc, pair.0, StringVal(pair.1))
})
list.fold(bools, s_dict, fn(acc, pair) {
dict.insert(acc, pair.0, BoolVal(pair.1))
})
}
/// Parse a rule set JSON document (as a Gleam string) into the internal AST.
/// The result is reusable across many `resolve` calls with different params.
pub fn parse_rule_set(json_text: String) -> Result(RuleSet, ResolveError) {
json.parse(json_text, rule_set_decoder())
|> result.map_error(fn(err) {
InvalidRuleSet(reason: describe_decode_error(err))
})
}
/// Walk the rule set with the given parameters. Returns the first matching
/// endpoint, the first matching error rule (surfaced as `RuleError`), or
/// `NoMatch` if every rule's conditions failed.
pub fn resolve(
rule_set: RuleSet,
params: Params,
) -> Result(Endpoint, ResolveError) {
use prepared <- result.try(apply_defaults_and_check_required(rule_set, params))
evaluate_rules(rule_set.rules, prepared)
}
// ---------- AST ----------
pub type RuleSet {
RuleSet(parameters: Dict(String, Parameter), rules: List(Rule))
}
pub type Parameter {
Parameter(
type_: ParamType,
required: Bool,
default: Option(Value),
builtin: Option(String),
)
}
pub type ParamType {
StringType
BooleanType
}
pub type Rule {
EndpointRule(conditions: List(Condition), endpoint: EndpointSpec)
ErrorRule(conditions: List(Condition), message: Expr)
TreeRule(conditions: List(Condition), rules: List(Rule))
}
pub type EndpointSpec {
EndpointSpec(url: Expr, headers: Dict(String, List(Expr)))
}
pub type Condition {
Condition(expr: Expr, assign: Option(String))
}
pub type Expr {
/// Reference to a parameter or assigned variable.
Ref(name: String)
/// String literal that may contain `{Name}` and `{Var#field}` interpolations.
TemplateExpr(parts: List(TemplatePart))
BoolLit(value: Bool)
/// Integer literal — `substring(input, 0, 3, false)` and similar.
IntLit(value: Int)
/// Builtin function call. The function name is the raw "fn" value
/// (e.g. `"isSet"`, `"aws.partition"`).
FnCall(name: String, args: List(Expr))
}
pub type TemplatePart {
Static(text: String)
Interp(path: List(String))
}
// ---------- decoder ----------
fn rule_set_decoder() -> decode.Decoder(RuleSet) {
use parameters <- decode.field("parameters", parameters_decoder())
use rules <- decode.field("rules", decode.list(rule_decoder()))
decode.success(RuleSet(parameters: parameters, rules: rules))
}
fn parameters_decoder() -> decode.Decoder(Dict(String, Parameter)) {
decode.dict(decode.string, parameter_decoder())
}
fn parameter_decoder() -> decode.Decoder(Parameter) {
use type_str <- decode.field("type", decode.string)
use required <- decode.optional_field("required", False, decode.bool)
use default <- decode.then(decode.optionally_at(
["default"],
None,
decode.map(value_decoder(), Some),
))
use builtin <- decode.then(decode.optionally_at(
["builtIn"],
None,
decode.map(decode.string, Some),
))
let type_ = case string.lowercase(type_str) {
"string" -> StringType
_ -> BooleanType
}
decode.success(Parameter(
type_: type_,
required: required,
default: default,
builtin: builtin,
))
}
fn value_decoder() -> decode.Decoder(Value) {
decode.one_of(decode.map(decode.string, StringVal), [
decode.map(decode.bool, BoolVal),
])
}
fn rule_decoder() -> decode.Decoder(Rule) {
use type_str <- decode.field("type", decode.string)
case type_str {
"endpoint" -> endpoint_rule_decoder()
"error" -> error_rule_decoder()
"tree" -> tree_rule_decoder()
_ -> decode.failure(error_rule_failsafe(), "unknown rule type")
}
}
fn error_rule_failsafe() -> Rule {
ErrorRule(
conditions: [],
message: TemplateExpr(parts: [Static("unknown rule")]),
)
}
fn conditions_decoder() -> decode.Decoder(List(Condition)) {
decode.list(condition_decoder())
}
fn condition_decoder() -> decode.Decoder(Condition) {
use fn_name <- decode.field("fn", decode.string)
use argv <- decode.field("argv", decode.list(expr_decoder()))
use assign <- decode.then(decode.optionally_at(
["assign"],
None,
decode.map(decode.string, Some),
))
decode.success(Condition(
expr: FnCall(name: fn_name, args: argv),
assign: assign,
))
}
/// Decoder for an `Expr` — i.e. anything that can appear in an `argv` slot
/// of a function call or as the value of an endpoint property. Smithy rules
/// allow several shapes here, so we use `one_of` to try each in turn.
fn expr_decoder() -> decode.Decoder(Expr) {
decode.one_of(ref_decoder(), [
bool_decoder(),
int_decoder(),
fn_call_decoder(),
string_expr_decoder(),
])
}
fn int_decoder() -> decode.Decoder(Expr) {
decode.map(decode.int, IntLit)
}
fn ref_decoder() -> decode.Decoder(Expr) {
use name <- decode.field("ref", decode.string)
decode.success(Ref(name: name))
}
fn bool_decoder() -> decode.Decoder(Expr) {
decode.map(decode.bool, BoolLit)
}
fn fn_call_decoder() -> decode.Decoder(Expr) {
use fn_name <- decode.field("fn", decode.string)
use argv <- decode.field("argv", decode.list(expr_decoder()))
decode.success(FnCall(name: fn_name, args: argv))
}
fn string_expr_decoder() -> decode.Decoder(Expr) {
decode.map(decode.string, fn(s) { TemplateExpr(parts: parse_template(s)) })
}
/// Endpoint rule fields: `endpoint = { url, properties, headers }`.
fn endpoint_rule_decoder() -> decode.Decoder(Rule) {
use conditions <- decode.field("conditions", conditions_decoder())
use url <- decode.subfield(["endpoint", "url"], expr_decoder())
use headers <- decode.then(decode.optionally_at(
["endpoint", "headers"],
dict.new(),
decode.dict(decode.string, decode.list(expr_decoder())),
))
decode.success(EndpointRule(
conditions: conditions,
endpoint: EndpointSpec(url: url, headers: headers),
))
}
fn error_rule_decoder() -> decode.Decoder(Rule) {
use conditions <- decode.field("conditions", conditions_decoder())
use message <- decode.field("error", expr_decoder())
decode.success(ErrorRule(conditions: conditions, message: message))
}
fn tree_rule_decoder() -> decode.Decoder(Rule) {
use conditions <- decode.field("conditions", conditions_decoder())
use rules <- decode.field("rules", decode.list(rule_decoder()))
decode.success(TreeRule(conditions: conditions, rules: rules))
}
fn describe_decode_error(err: json.DecodeError) -> String {
case err {
json.UnexpectedByte(b) -> "unexpected byte: " <> b
json.UnexpectedEndOfInput -> "unexpected end of input"
json.UnexpectedSequence(_) -> "unexpected sequence in JSON"
json.UnableToDecode(_) -> "JSON did not match rule set schema"
}
}
// ---------- template parsing ----------
/// Split a raw template string into static + interpolation segments.
/// `{Region}` becomes `Interp(["Region"])`. `{var#field#sub}` becomes
/// `Interp(["var", "field", "sub"])`. Literal `{{` and `}}` are NOT supported
/// — none of the AWS rule sets in scope use escaped braces.
fn parse_template(text: String) -> List(TemplatePart) {
do_parse_template(text, "", [])
}
fn do_parse_template(
remaining: String,
static_buf: String,
acc: List(TemplatePart),
) -> List(TemplatePart) {
case string.split_once(remaining, "{") {
Error(_) -> list.reverse([Static(static_buf <> remaining), ..acc])
Ok(#(before_brace, rest_with_brace)) ->
case string.split_once(rest_with_brace, "}") {
Error(_) ->
// Unbalanced brace — just take the rest as literal.
list.reverse([Static(static_buf <> remaining), ..acc])
Ok(#(interp_body, after_close)) -> {
let new_static = static_buf <> before_brace
let acc = case new_static {
"" -> acc
_ -> [Static(new_static), ..acc]
}
let path = string.split(interp_body, "#")
let acc = [Interp(path: path), ..acc]
do_parse_template(after_close, "", acc)
}
}
}
}
// ---------- evaluation ----------
fn apply_defaults_and_check_required(
rule_set: RuleSet,
params: Params,
) -> Result(Params, ResolveError) {
dict.fold(rule_set.parameters, Ok(params), fn(acc, name, parameter) {
use current <- result.try(acc)
case dict.get(current, name) {
Ok(_) -> Ok(current)
Error(_) ->
case parameter.default {
Some(value) -> Ok(dict.insert(current, name, value))
None ->
case parameter.required {
True -> Error(RequiredParameterMissing(name: name))
False -> Ok(current)
}
}
}
})
}
fn evaluate_rules(
rules: List(Rule),
scope: Params,
) -> Result(Endpoint, ResolveError) {
case rules {
[] -> Error(NoMatch)
[rule, ..rest] ->
case try_rule(rule, scope) {
Ok(Some(endpoint)) -> Ok(endpoint)
Ok(None) -> evaluate_rules(rest, scope)
Error(reason) -> Error(reason)
}
}
}
/// `Ok(Some(_))` — matched + produced an endpoint
/// `Ok(None)` — conditions didn't match; try the next rule
/// `Error(_)` — an error rule fired, or evaluation hit an error
fn try_rule(
rule: Rule,
scope: Params,
) -> Result(Option(Endpoint), ResolveError) {
case rule {
EndpointRule(conditions: conditions, endpoint: endpoint) -> {
case check_conditions(conditions, scope) {
Ok(None) -> Ok(None)
Ok(Some(new_scope)) -> {
use url <- result.try(eval_to_string(endpoint.url, new_scope))
use headers <- result.try(eval_headers(endpoint.headers, new_scope))
Ok(Some(Endpoint(url: url, headers: headers)))
}
Error(reason) -> Error(reason)
}
}
ErrorRule(conditions: conditions, message: message) -> {
case check_conditions(conditions, scope) {
Ok(None) -> Ok(None)
Ok(Some(new_scope)) -> {
use text <- result.try(eval_to_string(message, new_scope))
Error(RuleError(message: text))
}
Error(reason) -> Error(reason)
}
}
TreeRule(conditions: conditions, rules: nested) ->
case check_conditions(conditions, scope) {
Ok(None) -> Ok(None)
Ok(Some(new_scope)) ->
case evaluate_rules(nested, new_scope) {
Ok(endpoint) -> Ok(Some(endpoint))
Error(NoMatch) -> Ok(None)
Error(reason) -> Error(reason)
}
Error(reason) -> Error(reason)
}
}
}
fn check_conditions(
conditions: List(Condition),
scope: Params,
) -> Result(Option(Params), ResolveError) {
do_check(conditions, scope)
}
fn do_check(
conditions: List(Condition),
scope: Params,
) -> Result(Option(Params), ResolveError) {
case conditions {
[] -> Ok(Some(scope))
[Condition(expr: expr, assign: assign), ..rest] -> {
use value <- result.try(eval(expr, scope))
// A condition is true when the value is truthy (non-empty / non-false).
case is_truthy(value) {
False -> Ok(None)
True -> {
let scope = case assign {
Some(name) -> dict.insert(scope, name, value)
None -> scope
}
do_check(rest, scope)
}
}
}
}
}
fn is_truthy(value: Value) -> Bool {
case value {
EmptyVal -> False
BoolVal(b) -> b
// An empty list / empty record is "not set" — matches the Smithy rules
// engine's semantics for `isSet` on stringArray params.
ListVal([]) -> False
RecordVal(fields) ->
case dict.size(fields) {
0 -> False
_ -> True
}
_ -> True
}
}
fn eval_headers(
headers: Dict(String, List(Expr)),
scope: Params,
) -> Result(Dict(String, List(String)), ResolveError) {
dict.fold(headers, Ok(dict.new()), fn(acc, key, exprs) {
use current <- result.try(acc)
use values <- result.try(eval_string_list(exprs, scope))
Ok(dict.insert(current, key, values))
})
}
fn eval_string_list(
exprs: List(Expr),
scope: Params,
) -> Result(List(String), ResolveError) {
case exprs {
[] -> Ok([])
[head, ..tail] -> {
use h <- result.try(eval_to_string(head, scope))
use t <- result.try(eval_string_list(tail, scope))
Ok([h, ..t])
}
}
}
fn eval(expr: Expr, scope: Params) -> Result(Value, ResolveError) {
case expr {
Ref(name: name) ->
case dict.get(scope, name) {
Ok(value) -> Ok(value)
Error(_) -> Ok(EmptyVal)
}
BoolLit(value: value) -> Ok(BoolVal(value))
IntLit(value: n) -> Ok(StringVal(int.to_string(n)))
TemplateExpr(parts: parts) ->
eval_template(parts, scope)
|> result.map(StringVal)
FnCall(name: name, args: args) -> eval_builtin(name, args, scope)
}
}
fn eval_to_string(expr: Expr, scope: Params) -> Result(String, ResolveError) {
use value <- result.try(eval(expr, scope))
case value {
StringVal(s) -> Ok(s)
BoolVal(True) -> Ok("true")
BoolVal(False) -> Ok("false")
EmptyVal -> Ok("")
RecordVal(_) -> Error(Unsupported(reason: "cannot stringify record value"))
ListVal(_) -> Error(Unsupported(reason: "cannot stringify list value"))
}
}
fn eval_template(
parts: List(TemplatePart),
scope: Params,
) -> Result(String, ResolveError) {
do_eval_template(parts, scope, "")
}
fn do_eval_template(
parts: List(TemplatePart),
scope: Params,
acc: String,
) -> Result(String, ResolveError) {
case parts {
[] -> Ok(acc)
[Static(text), ..rest] -> do_eval_template(rest, scope, acc <> text)
[Interp(path: path), ..rest] -> {
use value <- result.try(lookup_path(path, scope))
use text <- result.try(value_to_string(value))
do_eval_template(rest, scope, acc <> text)
}
}
}
fn lookup_path(
path: List(String),
scope: Params,
) -> Result(Value, ResolveError) {
case path {
[] -> Ok(EmptyVal)
[first, ..rest] ->
case dict.get(scope, first) {
Error(_) -> Ok(EmptyVal)
Ok(value) -> traverse_record(value, rest)
}
}
}
fn traverse_record(
value: Value,
path: List(String),
) -> Result(Value, ResolveError) {
case path {
[] -> Ok(value)
[field, ..rest] ->
case value {
RecordVal(fields) ->
case dict.get(fields, field) {
Ok(inner) -> traverse_record(inner, rest)
Error(_) -> Ok(EmptyVal)
}
_ -> Ok(EmptyVal)
}
}
}
fn value_to_string(value: Value) -> Result(String, ResolveError) {
case value {
StringVal(s) -> Ok(s)
BoolVal(True) -> Ok("true")
BoolVal(False) -> Ok("false")
EmptyVal -> Ok("")
RecordVal(_) -> Error(Unsupported(reason: "cannot stringify record"))
ListVal(_) -> Error(Unsupported(reason: "cannot stringify list"))
}
}
fn eval_builtin(
name: String,
args: List(Expr),
scope: Params,
) -> Result(Value, ResolveError) {
case name {
"isSet" -> bi_is_set(args, scope)
"not" -> bi_not(args, scope)
"booleanEquals" -> bi_boolean_equals(args, scope)
"stringEquals" -> bi_string_equals(args, scope)
"getAttr" -> bi_get_attr(args, scope)
"substring" -> bi_substring(args, scope)
"uriEncode" -> bi_uri_encode(args, scope)
"parseURL" -> bi_parse_url(args, scope)
"isValidHostLabel" -> bi_is_valid_host_label(args, scope)
"aws.partition" -> bi_aws_partition(args, scope)
"aws.parseArn" -> bi_aws_parse_arn(args, scope)
"aws.isVirtualHostableS3Bucket" ->
bi_is_virtual_hostable_s3_bucket(args, scope)
_ ->
Error(Unsupported(
reason: "endpoint rule builtin '" <> name <> "' not implemented",
))
}
}
fn bi_is_set(args: List(Expr), scope: Params) -> Result(Value, ResolveError) {
case args {
[arg] -> {
use value <- result.try(eval(arg, scope))
Ok(
BoolVal(case value {
EmptyVal -> False
_ -> True
}),
)
}
_ -> Error(Unsupported(reason: "isSet expects 1 argument"))
}
}
fn bi_not(args: List(Expr), scope: Params) -> Result(Value, ResolveError) {
case args {
[arg] -> {
use value <- result.try(eval(arg, scope))
Ok(BoolVal(!is_truthy(value)))
}
_ -> Error(Unsupported(reason: "not expects 1 argument"))
}
}
fn bi_boolean_equals(
args: List(Expr),
scope: Params,
) -> Result(Value, ResolveError) {
case args {
[a, b] -> {
use va <- result.try(eval(a, scope))
use vb <- result.try(eval(b, scope))
Ok(
BoolVal(case va, vb {
BoolVal(x), BoolVal(y) -> x == y
_, _ -> False
}),
)
}
_ -> Error(Unsupported(reason: "booleanEquals expects 2 arguments"))
}
}
fn bi_string_equals(
args: List(Expr),
scope: Params,
) -> Result(Value, ResolveError) {
case args {
[a, b] -> {
use sa <- result.try(eval_to_string(a, scope))
use sb <- result.try(eval_to_string(b, scope))
Ok(BoolVal(sa == sb))
}
_ -> Error(Unsupported(reason: "stringEquals expects 2 arguments"))
}
}
fn bi_get_attr(args: List(Expr), scope: Params) -> Result(Value, ResolveError) {
case args {
[obj, key] -> {
use record <- result.try(eval(obj, scope))
use key_str <- result.try(eval_to_string(key, scope))
// The path may include array indexing like "resourceId[0]". Parse into
// a sequence of segments and walk.
let path = parse_attr_path(key_str)
Ok(traverse_value(record, path))
}
_ -> Error(Unsupported(reason: "getAttr expects 2 arguments"))
}
}
/// Parse a getAttr path into a list of segments. `"service"` → `["service"]`,
/// `"resourceId[0]"` → `["resourceId", "0"]`, `"a[1][2]"` → `["a","1","2"]`.
fn parse_attr_path(path: String) -> List(String) {
path
|> string.split("[")
|> list.map(fn(segment) { string.replace(segment, "]", "") })
|> list.filter(fn(s) { s != "" })
}
fn traverse_value(value: Value, path: List(String)) -> Value {
case path {
[] -> value
[first, ..rest] ->
case value {
RecordVal(fields) ->
case dict.get(fields, first) {
Ok(inner) -> traverse_value(inner, rest)
Error(_) -> EmptyVal
}
ListVal(items) ->
case int.parse(first) {
Ok(idx) ->
case list_index(items, idx) {
Ok(inner) -> traverse_value(inner, rest)
Error(_) -> EmptyVal
}
Error(_) -> EmptyVal
}
_ -> EmptyVal
}
}
}
fn list_index(items: List(a), idx: Int) -> Result(a, Nil) {
case items, idx {
[], _ -> Error(Nil)
[head, ..], 0 -> Ok(head)
[_, ..rest], n if n > 0 -> list_index(rest, n - 1)
_, _ -> Error(Nil)
}
}
fn bi_aws_partition(
args: List(Expr),
scope: Params,
) -> Result(Value, ResolveError) {
case args {
[region_expr] -> {
use region <- result.try(eval_to_string(region_expr, scope))
Ok(RecordVal(partition_for(region)))
}
_ -> Error(Unsupported(reason: "aws.partition expects 1 argument"))
}
}
/// `substring(input, start, stop, reverse)` — Smithy spec says the result is
/// empty (returns `EmptyVal`) if any character is non-ASCII, or if the range
/// is invalid. With `reverse=true` the indexes count from the end of the
/// string.
fn bi_substring(
args: List(Expr),
scope: Params,
) -> Result(Value, ResolveError) {
case args {
[input_expr, start_expr, stop_expr, reverse_expr] -> {
use input <- result.try(eval_to_string(input_expr, scope))
use start <- result.try(eval_to_int(start_expr, scope))
use stop <- result.try(eval_to_int(stop_expr, scope))
use reverse <- result.try(eval_to_bool(reverse_expr, scope))
Ok(do_substring(input, start, stop, reverse))
}
_ -> Error(Unsupported(reason: "substring expects 4 arguments"))
}
}
fn do_substring(input: String, start: Int, stop: Int, reverse: Bool) -> Value {
let length = string.length(input)
// Spec: empty if any byte is > 127 (non-ASCII). We're stricter than
// necessary: use byte length so multi-byte chars don't sneak in.
let bytes = bit_array.byte_size(bit_array.from_string(input))
case bytes != length {
True -> EmptyVal
False ->
case start < 0 || stop > length || start >= stop {
True -> EmptyVal
False -> {
let #(real_start, real_stop) = case reverse {
False -> #(start, stop)
True -> #(length - stop, length - start)
}
StringVal(string.slice(input, real_start, real_stop - real_start))
}
}
}
}
/// Percent-encode a URI component.
fn bi_uri_encode(
args: List(Expr),
scope: Params,
) -> Result(Value, ResolveError) {
case args {
[arg] -> {
use input <- result.try(eval_to_string(arg, scope))
Ok(StringVal(percent_encode_uri(input)))
}
_ -> Error(Unsupported(reason: "uriEncode expects 1 argument"))
}
}
fn percent_encode_uri(input: String) -> String {
// Re-use the same encoder we use for SigV4 component encoding.
case input {
"" -> ""
_ -> aws_uri_encode(input)
}
}
/// Parse a URL into `{ scheme, authority, path, normalizedPath, isIp }`.
/// Returns `EmptyVal` if the URL doesn't look well-formed. We support the
/// shapes AWS rule sets actually pass us; this is not a full RFC 3986
/// parser.
fn bi_parse_url(
args: List(Expr),
scope: Params,
) -> Result(Value, ResolveError) {
case args {
[arg] -> {
use url <- result.try(eval_to_string(arg, scope))
Ok(parse_url(url))
}
_ -> Error(Unsupported(reason: "parseURL expects 1 argument"))
}
}
fn parse_url(url: String) -> Value {
// Smithy parseURL: scheme must be http or https, no userinfo, no fragment.
case string.split_once(url, "://") {
Error(_) -> EmptyVal
Ok(#(scheme, rest)) ->
case scheme {
"http" | "https" -> do_parse_url_parts(scheme, rest)
_ -> EmptyVal
}
}
}
fn do_parse_url_parts(scheme: String, after_scheme: String) -> Value {
// No userinfo (`@`) and no fragment (`#`) allowed.
case
string.contains(after_scheme, "@") || string.contains(after_scheme, "#")
{
True -> EmptyVal
False -> {
let #(authority, path_and_query) = case
string.split_once(after_scheme, "/")
{
Ok(#(a, rest)) -> #(a, "/" <> rest)
Error(_) -> #(after_scheme, "")
}
let path = case string.split_once(path_and_query, "?") {
Ok(#(p, _)) -> p
Error(_) -> path_and_query
}
// `path` is the literal value (may be "" for hostnames with no path);
// `normalizedPath` always starts AND ends with "/" so callers can
// concatenate suffixes without worrying about doubled slashes.
let normalized = case path {
"" -> "/"
_ ->
case string.ends_with(path, "/") {
True -> path
False -> path <> "/"
}
}
let is_ip = looks_like_ip(authority)
RecordVal(
dict.from_list([
#("scheme", StringVal(scheme)),
#("authority", StringVal(authority)),
#("path", StringVal(path)),
#("normalizedPath", StringVal(normalized)),
#("isIp", BoolVal(is_ip)),
]),
)
}
}
}
fn looks_like_ip(authority: String) -> Bool {
// Strip port if any.
let host = case string.split_once(authority, ":") {
Ok(#(h, _)) -> h
Error(_) -> authority
}
// Pure dotted-decimal IPv4 or bracket-wrapped IPv6.
case string.starts_with(host, "[") && string.ends_with(host, "]") {
True -> True
False -> all_ip4_segments(string.split(host, "."))
}
}
fn all_ip4_segments(parts: List(String)) -> Bool {
case list.length(parts) {
4 -> list.all(parts, fn(p) { is_int_string(p) })
_ -> False
}
}
fn is_int_string(s: String) -> Bool {
case int.parse(s) {
Ok(_) -> True
Error(_) -> False
}
}
/// Parse an AWS ARN: `arn:partition:service:region:accountId:resource`. The
/// resource is whatever comes after the fifth colon. Returns `EmptyVal` if
/// the input doesn't have at least 6 colon-separated parts.
fn bi_aws_parse_arn(
args: List(Expr),
scope: Params,
) -> Result(Value, ResolveError) {
case args {
[arg] -> {
use arn <- result.try(eval_to_string(arg, scope))
Ok(parse_arn(arn))
}
_ -> Error(Unsupported(reason: "aws.parseArn expects 1 argument"))
}
}
/// Port of the Go SDK's `awsrulesfn.ParseARN`
/// (aws-sdk-go-v2/internal/endpoints/awsrulesfn/arn.go). Requires the
/// literal `arn:` prefix, splits into exactly six sections (so colons in
/// the resource section stay intact), requires non-empty partition /
/// service / resource, and splits the resource on either `:` or `/`.
fn parse_arn(arn: String) -> Value {
case string.starts_with(arn, "arn:") {
False -> EmptyVal
True ->
case split_n(arn, ":", 6) {
[_, partition, service, region, account_id, resource]
if partition != "" && service != "" && resource != ""
->
RecordVal(
dict.from_list([
#("partition", StringVal(partition)),
#("service", StringVal(service)),
#("region", StringVal(region)),
#("accountId", StringVal(account_id)),
#("resourceId", split_resource_to_value(resource)),
]),
)
_ -> EmptyVal
}
}
}
/// `strings.SplitN(input, sep, n)` — split into at most `n` parts; the last
/// part keeps any remaining separators as literal characters.
fn split_n(input: String, sep: String, n: Int) -> List(String) {
do_split_n(input, sep, n, [])
}
fn do_split_n(
input: String,
sep: String,
n: Int,
acc: List(String),
) -> List(String) {
case n <= 1 {
True -> list.reverse([input, ..acc])
False ->
case string.split_once(input, sep) {
Ok(#(head, rest)) -> do_split_n(rest, sep, n - 1, [head, ..acc])
Error(_) -> list.reverse([input, ..acc])
}
}
}
/// Resource-section splitter: break on either `/` or `:`, like the Go SDK's
/// `splitResource`.
fn split_resource_to_value(resource: String) -> Value {
resource
|> split_resource_chars
|> list.index_map(fn(part, idx) { #(int.to_string(idx), StringVal(part)) })
|> dict.from_list
|> RecordVal
}
fn split_resource_chars(input: String) -> List(String) {
do_split_resource(input, "", [])
}
fn do_split_resource(
input: String,
buf: String,
acc: List(String),
) -> List(String) {
case string.pop_grapheme(input) {
Error(_) -> list.reverse([buf, ..acc])
Ok(#(c, rest)) ->
case c {
"/" | ":" -> do_split_resource(rest, "", [buf, ..acc])
_ -> do_split_resource(rest, buf <> c, acc)
}
}
}
/// RFC 1123 host label validation. Accepts ASCII letters/digits/hyphens, no
/// leading/trailing hyphen, length 1..63. With `allowSubdomains=true` the
/// label may be `.`-separated and each piece is validated independently.
fn bi_is_valid_host_label(
args: List(Expr),
scope: Params,
) -> Result(Value, ResolveError) {
case args {
[name_expr, allow_subdomains_expr] -> {
use name <- result.try(eval_to_string(name_expr, scope))
use allow_subdomains <- result.try(eval_to_bool(
allow_subdomains_expr,
scope,
))
Ok(BoolVal(is_valid_host_label(name, allow_subdomains)))
}
_ -> Error(Unsupported(reason: "isValidHostLabel expects 2 arguments"))
}
}
fn is_valid_host_label(name: String, allow_subdomains: Bool) -> Bool {
case allow_subdomains {
False -> is_one_host_label(name)
True ->
name
|> string.split(".")
|> list.all(is_one_host_label)
}
}
fn is_one_host_label(label: String) -> Bool {
let length = string.length(label)
let bytes = bit_array.byte_size(bit_array.from_string(label))
case length == bytes && length >= 1 && length <= 63 {
False -> False
True ->
!string.starts_with(label, "-")
&& !string.ends_with(label, "-")
&& all_host_label_chars(string.to_graphemes(label))
}
}
fn all_host_label_chars(chars: List(String)) -> Bool {
list.all(chars, fn(c) {
case c {
"-" -> True
_ -> is_lower_alnum(c) || is_upper_alpha(c) || is_digit(c)
}
})
}
fn is_upper_alpha(c: String) -> Bool {
case string.to_utf_codepoints(c) {
[cp] -> {
let n = string.utf_codepoint_to_int(cp)
n >= 65 && n <= 90
}
_ -> False
}
}
fn is_digit(c: String) -> Bool {
case c {
"0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" -> True
_ -> False
}
}
/// DNS-3-to-63-char rules + S3 bucket constraints: lowercase letters, digits,
/// hyphens; not IP-shaped; doesn't begin or end with a hyphen.
fn bi_is_virtual_hostable_s3_bucket(
args: List(Expr),
scope: Params,
) -> Result(Value, ResolveError) {
case args {
[name_expr, allow_subdomains_expr] -> {
use name <- result.try(eval_to_string(name_expr, scope))
use allow_subdomains <- result.try(eval_to_bool(
allow_subdomains_expr,
scope,
))
Ok(BoolVal(is_virtual_hostable(name, allow_subdomains)))
}
_ ->
Error(Unsupported(reason: "aws.isVirtualHostableS3Bucket expects 2 args"))
}
}
fn is_virtual_hostable(name: String, allow_subdomains: Bool) -> Bool {
case allow_subdomains {
False -> is_valid_bucket_label(name)
True ->
name
|> string.split(".")
|> list.all(is_valid_bucket_label)
}
}
fn is_valid_bucket_label(label: String) -> Bool {
let length = string.length(label)
let bytes = bit_array.byte_size(bit_array.from_string(label))
case length == bytes && length >= 3 && length <= 63 {
False -> False
True ->
case label {
"" -> False
_ ->
!string.starts_with(label, "-")
&& !string.ends_with(label, "-")
&& all_bucket_chars(string.to_graphemes(label))
&& !looks_like_ip(label)
}
}
}
fn all_bucket_chars(chars: List(String)) -> Bool {
list.all(chars, fn(c) {
case c {
"-" -> True
_ -> is_lower_alnum(c)
}
})
}
fn is_lower_alnum(c: String) -> Bool {
case c {
"0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" -> True
_ ->
case string.to_utf_codepoints(c) {
[cp] -> {
let n = string.utf_codepoint_to_int(cp)
n >= 97 && n <= 122
}
_ -> False
}
}
}
// ---------- evaluation helpers ----------
fn eval_to_int(expr: Expr, scope: Params) -> Result(Int, ResolveError) {
case expr {
IntLit(value: n) -> Ok(n)
_ -> {
use value <- result.try(eval(expr, scope))
case value {
StringVal(s) ->
case int.parse(s) {
Ok(n) -> Ok(n)
Error(_) -> Error(Unsupported(reason: "non-integer string: " <> s))
}
_ -> Error(Unsupported(reason: "expected an integer"))
}
}
}
}
fn eval_to_bool(expr: Expr, scope: Params) -> Result(Bool, ResolveError) {
use value <- result.try(eval(expr, scope))
case value {
BoolVal(b) -> Ok(b)
_ -> Error(Unsupported(reason: "expected a boolean"))
}
}
/// Single-component percent-encoding matching `aws/internal/uri.encode_component`.
/// Inlined to avoid a cross-module dependency.
fn aws_uri_encode(input: String) -> String {
do_uri_encode(bit_array.from_string(input), "")
}
fn do_uri_encode(bits: BitArray, acc: String) -> String {
case bits {
<<>> -> acc
<<b, rest:bits>> ->
case is_unreserved_byte(b) {
True ->
case bit_array.to_string(<<b>>) {
Ok(s) -> do_uri_encode(rest, acc <> s)
Error(_) -> do_uri_encode(rest, acc <> "%" <> hex_byte(b))
}
False -> do_uri_encode(rest, acc <> "%" <> hex_byte(b))
}
_ -> acc
}
}
fn is_unreserved_byte(b: Int) -> Bool {
{ b >= 0x41 && b <= 0x5A }
|| { b >= 0x61 && b <= 0x7A }
|| { b >= 0x30 && b <= 0x39 }
|| b == 0x2D
|| b == 0x5F
|| b == 0x2E
|| b == 0x7E
}
fn hex_byte(b: Int) -> String {
let high = b / 16
let low = b % 16
hex_digit(high) <> hex_digit(low)
}
fn hex_digit(n: Int) -> String {
case n {
0 -> "0"
1 -> "1"
2 -> "2"
3 -> "3"
4 -> "4"
5 -> "5"
6 -> "6"
7 -> "7"
8 -> "8"
9 -> "9"
10 -> "A"
11 -> "B"
12 -> "C"
13 -> "D"
14 -> "E"
_ -> "F"
}
}
/// Hardcoded partition data sourced from smithy-rs's `partitions.json`.
/// Refresh when AWS announces a new partition or DNS suffix.
fn partition_for(region: String) -> Dict(String, Value) {
let trimmed = string.trim(region)
case classify_partition(trimmed) {
AwsCommercial ->
dict.from_list([
#("name", StringVal("aws")),
#("dnsSuffix", StringVal("amazonaws.com")),
#("dualStackDnsSuffix", StringVal("api.aws")),
#("supportsFIPS", BoolVal(True)),
#("supportsDualStack", BoolVal(True)),
#("implicitGlobalRegion", StringVal("us-east-1")),
])
AwsCn ->
dict.from_list([
#("name", StringVal("aws-cn")),
#("dnsSuffix", StringVal("amazonaws.com.cn")),
#("dualStackDnsSuffix", StringVal("api.amazonwebservices.com.cn")),
#("supportsFIPS", BoolVal(True)),
#("supportsDualStack", BoolVal(True)),
#("implicitGlobalRegion", StringVal("cn-northwest-1")),
])
AwsUsGov ->
dict.from_list([
#("name", StringVal("aws-us-gov")),
#("dnsSuffix", StringVal("amazonaws.com")),
#("dualStackDnsSuffix", StringVal("api.aws")),
#("supportsFIPS", BoolVal(True)),
#("supportsDualStack", BoolVal(True)),
#("implicitGlobalRegion", StringVal("us-gov-west-1")),
])
AwsEusc ->
dict.from_list([
#("name", StringVal("aws-eusc")),
#("dnsSuffix", StringVal("amazonaws.eu")),
#("dualStackDnsSuffix", StringVal("api.amazonwebservices.eu")),
#("supportsFIPS", BoolVal(True)),
#("supportsDualStack", BoolVal(True)),
#("implicitGlobalRegion", StringVal("eusc-de-east-1")),
])
AwsIso ->
dict.from_list([
#("name", StringVal("aws-iso")),
#("dnsSuffix", StringVal("c2s.ic.gov")),
#("dualStackDnsSuffix", StringVal("api.aws.ic.gov")),
#("supportsFIPS", BoolVal(True)),
#("supportsDualStack", BoolVal(True)),
#("implicitGlobalRegion", StringVal("us-iso-east-1")),
])
AwsIsoB ->
dict.from_list([
#("name", StringVal("aws-iso-b")),
#("dnsSuffix", StringVal("sc2s.sgov.gov")),
#("dualStackDnsSuffix", StringVal("api.aws.scloud")),
#("supportsFIPS", BoolVal(True)),
#("supportsDualStack", BoolVal(True)),
#("implicitGlobalRegion", StringVal("us-isob-east-1")),
])
AwsIsoE ->
dict.from_list([
#("name", StringVal("aws-iso-e")),
#("dnsSuffix", StringVal("cloud.adc-e.uk")),
#("dualStackDnsSuffix", StringVal("api.cloud-aws.adc-e.uk")),
#("supportsFIPS", BoolVal(True)),
#("supportsDualStack", BoolVal(True)),
#("implicitGlobalRegion", StringVal("eu-isoe-west-1")),
])
AwsIsoF ->
dict.from_list([
#("name", StringVal("aws-iso-f")),
#("dnsSuffix", StringVal("csp.hci.ic.gov")),
#("dualStackDnsSuffix", StringVal("api.aws.hci.ic.gov")),
#("supportsFIPS", BoolVal(True)),
#("supportsDualStack", BoolVal(True)),
#("implicitGlobalRegion", StringVal("us-isof-south-1")),
])
}
}
type Partition {
AwsCommercial
AwsCn
AwsUsGov
AwsEusc
AwsIso
AwsIsoB
AwsIsoE
AwsIsoF
}
/// Classify a region into one of the AWS partitions. Mirrors the
/// `regionRegex` patterns from upstream `partitions.json`. Order matters:
/// the longest-prefix matchers (us-isob-, us-isof-) come first, then the
/// generic `us-iso-`.
fn classify_partition(region: String) -> Partition {
case region {
"aws-cn-global" -> AwsCn
"aws-us-gov-global" -> AwsUsGov
"aws-iso-global" -> AwsIso
"aws-iso-b-global" -> AwsIsoB
"aws-iso-e-global" -> AwsIsoE
"aws-iso-f-global" -> AwsIsoF
_ ->
case
string.starts_with(region, "cn-")
|| string.starts_with(region, "aws-cn-")
{
True -> AwsCn
False ->
case string.starts_with(region, "us-gov-") {
True -> AwsUsGov
False ->
case string.starts_with(region, "eusc-") {
True -> AwsEusc
False ->
case string.starts_with(region, "us-isob-") {
True -> AwsIsoB
False ->
case string.starts_with(region, "us-isof-") {
True -> AwsIsoF
False ->
case string.starts_with(region, "us-iso-") {
True -> AwsIso
False ->
case string.starts_with(region, "eu-isoe-") {
True -> AwsIsoE
False -> AwsCommercial
}
}
}
}
}
}
}
}
}