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
12 changes: 12 additions & 0 deletions Sources/SmithyHTTPAPI/URLEncodingUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,16 @@ public enum URLEncodingUtils {
public static func urlPercentEncodedForQuery(_ string: String) -> String {
string.addingPercentEncoding(withAllowedCharacters: allowedForQuery) ?? string
}

public static func encodeNumber<FP: FloatingPoint>(_ value: FP) -> String {
guard !value.isNaN else { return "NaN" }
switch value {
case .infinity:
return "Infinity"
case -.infinity:
return "-Infinity"
default:
return "\(value)"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ extension HttpRequestTestBase {
return []
}
let name: String = keyValueArray[0]
let value = keyValueArray.count >= 2 ? sanitizeStringForNonConformingValues(keyValueArray[1]) : nil
let value = keyValueArray.count >= 2 ? keyValueArray[1] : nil
queryItems.append(URIQueryItem(name: name, value: value))
}
return queryItems
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,7 @@ open class HttpRequestTestBase: XCTestCase {

if let headers = headers {
for (headerName, headerValue) in headers {
let value = sanitizeStringForNonConformingValues(headerValue)
builder.withHeader(name: headerName, value: value)
builder.withHeader(name: headerName, value: headerValue)
}
}

Expand Down Expand Up @@ -123,7 +122,7 @@ open class HttpRequestTestBase: XCTestCase {
for queryParam in queryParams {
let queryParamComponents = queryParam.components(separatedBy: "=")
if queryParamComponents.count > 1 {
let value = sanitizeStringForNonConformingValues(queryParamComponents[1])
let value = queryParamComponents[1]

builder.withQueryItem(URIQueryItem(name: queryParamComponents[0],
value: value))
Expand All @@ -137,7 +136,7 @@ open class HttpRequestTestBase: XCTestCase {
for queryParam in queryParams {
let queryParamComponents = queryParam.components(separatedBy: "=")
if queryParamComponents.count > 1 {
let value = sanitizeStringForNonConformingValues(queryParamComponents[1])
let value = queryParamComponents[1]

builder.withForbiddenQueryItem(URIQueryItem(name: queryParamComponents[0],
value: value))
Expand All @@ -151,7 +150,7 @@ open class HttpRequestTestBase: XCTestCase {
for queryParam in queryParams {
let queryParamComponents = queryParam.components(separatedBy: "=")
if queryParamComponents.count > 1 {
let value = sanitizeStringForNonConformingValues(queryParamComponents[1])
let value = queryParamComponents[1]

builder.withRequiredQueryItem(URIQueryItem(name: queryParamComponents[0],
value: value))
Expand All @@ -161,16 +160,6 @@ open class HttpRequestTestBase: XCTestCase {
}
}

func sanitizeStringForNonConformingValues(_ input: String) -> String {
switch input {
case "Infinity": return "inf"
case "-Infinity": return "-inf"
case "NaN": return "nan"
default:
return input
}
}

/**
Check if a Query Item with given name exists in array of `URLQueryItem`
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ import software.amazon.smithy.model.knowledge.HttpBindingIndex
import software.amazon.smithy.model.knowledge.TopDownIndex
import software.amazon.smithy.model.neighbor.RelationshipType
import software.amazon.smithy.model.neighbor.Walker
import software.amazon.smithy.model.shapes.BigDecimalShape
import software.amazon.smithy.model.shapes.BlobShape
import software.amazon.smithy.model.shapes.CollectionShape
import software.amazon.smithy.model.shapes.DoubleShape
import software.amazon.smithy.model.shapes.FloatShape
import software.amazon.smithy.model.shapes.IntEnumShape
import software.amazon.smithy.model.shapes.MemberShape
import software.amazon.smithy.model.shapes.OperationShape
Expand Down Expand Up @@ -71,6 +74,8 @@ import software.amazon.smithy.swift.codegen.model.isInputEventStream
import software.amazon.smithy.swift.codegen.model.isOutputEventStream
import software.amazon.smithy.swift.codegen.supportsStreamingAndIsRPC
import software.amazon.smithy.swift.codegen.swiftmodules.ClientRuntimeTypes
import software.amazon.smithy.swift.codegen.swiftmodules.SmithyHTTPAPITypes
import software.amazon.smithy.swift.codegen.swiftmodules.SwiftTypes
import software.amazon.smithy.swift.codegen.utils.SDKFileUtils
import software.amazon.smithy.utils.OptionalUtils
import java.util.Optional
Expand Down Expand Up @@ -126,6 +131,26 @@ fun formatHeaderOrQueryValue(
else -> Pair(memberName, false)
}

/**
* Provides the Swift expression that renders a header or query value as a `String`.
*
* Floating-point values are rendered by `URLEncodingUtils.encodeNumber(_:)`, which uses the
* Smithy-defined tokens for the non-finite values NaN, Infinity, and -Infinity. Swift's own
* string interpolation would render those as `nan`, `inf`, and `-inf`, which are not valid on
* the wire. All other values are rendered by `String.init`.
*/
fun renderCreateValueCall(
ctx: ProtocolGenerator.GenerationContext,
writer: SwiftWriter,
member: MemberShape,
): String =
when (ctx.model.expectShape(member.target)) {
is DoubleShape, is FloatShape, is BigDecimalShape ->
writer.format("\$N.encodeNumber", SmithyHTTPAPITypes.URLEncodingUtils)
else ->
writer.format("\$N", SwiftTypes.String)
}

/**
* Abstract implementation useful for all HTTP protocols
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import software.amazon.smithy.swift.codegen.integration.HttpBindingResolver
import software.amazon.smithy.swift.codegen.integration.ProtocolGenerator
import software.amazon.smithy.swift.codegen.integration.formatHeaderOrQueryValue
import software.amazon.smithy.swift.codegen.integration.middlewares.handlers.MiddlewareShapeUtils
import software.amazon.smithy.swift.codegen.integration.renderCreateValueCall
import software.amazon.smithy.swift.codegen.model.defaultValue
import software.amazon.smithy.swift.codegen.model.isBoxed
import software.amazon.smithy.swift.codegen.model.needsDefaultValueCheck
Expand Down Expand Up @@ -94,7 +95,7 @@ class HttpHeaderProvider(

private fun generateHeaders() {
headerBindings.forEach {
var memberName = ctx.symbolProvider.toMemberName(it.member)
val memberName = ctx.symbolProvider.toMemberName(it.member)
val memberTarget = ctx.model.expectShape(it.member.target)
val paramName = it.locationName
val isBoxed = ctx.symbolProvider.toSymbol(it.member).isBoxed()
Expand Down Expand Up @@ -148,20 +149,22 @@ class HttpHeaderProvider(
)
}
} else if (inCollection && ctx.model.expectShape(member.target) !is TimestampShape) {
val createValueCall = renderCreateValueCall(ctx, writer, member)
writer.write(
"items.add(\$N(name: \"\$L\", value: \$N(\$N(\$L))))",
"items.add(\$N(name: \"\$L\", value: \$N(\$L(\$L))))",
SmithyHTTPAPITypes.Header,
paramName,
ClientRuntimeTypes.Core.quoteHeaderValue,
SwiftTypes.String,
createValueCall,
memberNameWithExtension,
)
} else {
val createValueCall = renderCreateValueCall(ctx, writer, member)
writer.write(
"items.add(\$N(name: \"\$L\", value: \$N(\$L)))",
"items.add(\$N(name: \"\$L\", value: \$L(\$L)))",
SmithyHTTPAPITypes.Header,
paramName,
SwiftTypes.String,
createValueCall,
memberNameWithExtension,
)
}
Expand Down Expand Up @@ -207,6 +210,9 @@ class HttpHeaderProvider(
}
}

// `String.init` is used to render the value here, rather than `renderCreateValueCall`, because
// only base64-encoded values reach this method. `requiresDoCatch` is set only for blobs and
// media-typed strings, so a floating-point value is never rendered here.
private fun renderDoCatch(
headerValueWithExtension: String,
headerName: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import software.amazon.smithy.swift.codegen.integration.HttpBindingResolver
import software.amazon.smithy.swift.codegen.integration.ProtocolGenerator
import software.amazon.smithy.swift.codegen.integration.formatHeaderOrQueryValue
import software.amazon.smithy.swift.codegen.integration.middlewares.handlers.MiddlewareShapeUtils
import software.amazon.smithy.swift.codegen.integration.renderCreateValueCall
import software.amazon.smithy.swift.codegen.model.defaultValue
import software.amazon.smithy.swift.codegen.model.hasTrait
import software.amazon.smithy.swift.codegen.model.isBoxed
Expand Down Expand Up @@ -103,7 +104,7 @@ class HttpQueryItemProvider(

var httpQueryParamBinding: HttpBindingDescriptor? = null
queryBindings.forEach {
var memberName = ctx.symbolProvider.toMemberName(it.member)
val memberName = ctx.symbolProvider.toMemberName(it.member)
val memberTarget = ctx.model.expectShape(it.member.target)
val paramName = it.locationName
val bindingIndex = HttpBindingIndex.of(ctx.model)
Expand All @@ -117,7 +118,7 @@ class HttpQueryItemProvider(
}
httpQueryParamBinding?.let {
val memberTarget = ctx.model.expectShape(it.member.target)
var memberName = ctx.symbolProvider.toMemberName(it.member)
val memberName = ctx.symbolProvider.toMemberName(it.member)
if (memberTarget is MapShape) {
renderHttpQueryParamMap(memberTarget, memberName)
}
Expand Down Expand Up @@ -205,7 +206,7 @@ class HttpQueryItemProvider(
paramName: String,
unwrapped: Boolean,
) {
var (memberName, requiresDoCatch) =
val (memberName, requiresDoCatch) =
formatHeaderOrQueryValue(
ctx,
writer,
Expand All @@ -226,41 +227,41 @@ class HttpQueryItemProvider(
memberName,
member.defaultValue(ctx.symbolProvider),
) {
val queryItemName = "${ctx.symbolProvider.toMemberNames(member).second}QueryItem"
writer.write(
"let \$L = \$N(name: \$S.urlPercentEncoding(), value: \$N(\$L\$L).urlPercentEncoding())",
queryItemName,
SmithyTypes.URIQueryItem,
paramName,
SwiftTypes.String,
prefix,
memberName,
)
writer.write("items.append($queryItemName)")
renderConstruction(member, paramName, prefix, memberName)
}
} else {
val queryItemName = "${ctx.symbolProvider.toMemberNames(member).second}QueryItem"
writer.write(
"let \$L = \$N(name: \$S.urlPercentEncoding(), value: \$N(\$L\$L).urlPercentEncoding())",
queryItemName,
SmithyTypes.URIQueryItem,
paramName,
SwiftTypes.String,
prefix,
memberName,
)
writer.write("items.append($queryItemName)")
renderConstruction(member, paramName, prefix, memberName)
}
}
}

private fun renderConstruction(
member: MemberShape,
paramName: String,
prefix: String,
memberName: String,
) {
val queryItemName = "${ctx.symbolProvider.toMemberNames(member).second}QueryItem"
val createValueCall = renderCreateValueCall(ctx, writer, member)
writer.write(
"let \$L = \$N(name: \$S.urlPercentEncoding(), value: \$L(\$L\$L).urlPercentEncoding())",
queryItemName,
SmithyTypes.URIQueryItem,
paramName,
createValueCall,
prefix,
memberName,
)
writer.write("items.append($queryItemName)")
}

private fun renderListOrSet(
memberTarget: CollectionShape,
bindingIndex: HttpBindingIndex,
memberName: String,
paramName: String,
) {
var (queryItemValue, requiresDoCatch) =
val (queryItemValue, requiresDoCatch) =
formatHeaderOrQueryValue(
ctx,
writer,
Expand All @@ -275,16 +276,20 @@ class HttpQueryItemProvider(
if (requiresDoCatch) {
renderDoCatch(queryItemValue, paramName)
} else {
val createValueCall = renderCreateValueCall(ctx, writer, memberTarget.member)
writer.write(
"let queryItem = \$N(name: \"$paramName\".urlPercentEncoding(), value: \$N($queryItemValue).urlPercentEncoding())",
"let queryItem = \$N(name: \"$paramName\".urlPercentEncoding(), value: \$L($queryItemValue).urlPercentEncoding())",
SmithyTypes.URIQueryItem,
SwiftTypes.String,
createValueCall,
)
writer.write("items.append(queryItem)")
}
}
}

// `String.init` is used to render the value here, rather than `renderCreateValueCall`, because
// only base64-encoded values reach this method. `requiresDoCatch` is set only for blobs and
// media-typed strings, so a floating-point value is never rendered here.
private fun renderDoCatch(
queryItemValueWithExtension: String,
paramName: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ object SmithyHTTPAPITypes {
val HTTPRequest = runtimeSymbol("HTTPRequest", SwiftDeclaration.CLASS)
val HTTPResponse = runtimeSymbol("HTTPResponse", SwiftDeclaration.CLASS)
val HTTPStatusCode = runtimeSymbol("HTTPStatusCode", SwiftDeclaration.ENUM)
val URLEncodingUtils = runtimeSymbol("URLEncodingUtils", SwiftDeclaration.ENUM)
}

private fun runtimeSymbol(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,45 @@ extension TimestampInputInput {
contents.shouldContainOnlyOnce(expectedContents)
}

@Test
fun `it encodes float and double headers with encodeNumber`() {
// Floats & doubles must be rendered by URLEncodingUtils.encodeNumber so that the non-finite
// values are rendered as the Smithy-defined tokens NaN, Infinity, and -Infinity. Swift's own
// string interpolation would render them as nan, inf, and -inf, which are invalid on the wire.
val context = TestContext.initContextFrom("http-float-bindings.smithy", "com.test#Example")
context.generator.generateSerializers(context.generationCtx)
context.generationCtx.delegator.flushWriters()
val contents = getModelFileContents("example/Sources/example", "FloatBindingsInput+HeaderProvider.swift", context.manifest)
contents.shouldSyntacticSanityCheck()
val expectedContents = """
extension FloatBindingsInput {

static func headerProvider(_ value: FloatBindingsInput) -> SmithyHTTPAPI.Headers {
var items = SmithyHTTPAPI.Headers()
if let headerDouble = value.headerDouble {
items.add(SmithyHTTPAPI.Header(name: "X-Double", value: SmithyHTTPAPI.URLEncodingUtils.encodeNumber(headerDouble)))
}
if let headerFloat = value.headerFloat {
items.add(SmithyHTTPAPI.Header(name: "X-Float", value: SmithyHTTPAPI.URLEncodingUtils.encodeNumber(headerFloat)))
}
if let headerFloatList = value.headerFloatList {
if headerFloatList.isEmpty {
items.add(name: "X-FloatList", value: "")
}
headerFloatList.forEach { headerValue in
items.add(SmithyHTTPAPI.Header(name: "X-FloatList", value: ClientRuntime.quoteHeaderValue(SmithyHTTPAPI.URLEncodingUtils.encodeNumber(headerValue))))
}
}
if let headerString = value.headerString {
items.add(SmithyHTTPAPI.Header(name: "X-String", value: Swift.String(headerString)))
}
return items
}
}
"""
contents.shouldContainOnlyOnce(expectedContents)
}

private fun newTestContext(): TestContext {
val settings = model.defaultSettings()
model = AddOperationShapes.execute(model, settings.getService(model), settings.moduleName)
Expand Down
Loading
Loading