Skip to content

Commit 904e847

Browse files
bfrearsonbenfrearsonczechboy0
authored
Add url form encoder & decoder (#283)
### Motivation #182 Currently, the generated interface for request bodies that use URLEncodedForms accepts a `Data` object. This PR uses the URIEncoder/Decoder to instead provide an interface using codable types in the same way that a JSON body would. ### Modifications Adds a new coding strategy that works with the corresponding [runtime converter updates](apple/swift-openapi-runtime#53). **Note:** This PR does not support encoding collections of primitives (i.e. an array of string values for a single key). This should be addressed in a future update, but it raised questions about the correct use of URIEncoder, so I didn't include it in this PR. Further discussion of the issue is in the [runtime PR](apple/swift-openapi-runtime#53). ### Result Generated interfaces now directly accept URLEncodedForm Codable types rather than untyped `Data`. ### Test Plan Updated reference tests to include a request using a form body. --------- Co-authored-by: benfrearson <[email protected]> Co-authored-by: bfrearson <> Co-authored-by: Honza Dvorsky <[email protected]>
1 parent 0de1799 commit 904e847

File tree

19 files changed

+346
-6
lines changed

19 files changed

+346
-6
lines changed

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ let package = Package(
8989
// Tests-only: Runtime library linked by generated code, and also
9090
// helps keep the runtime library new enough to work with the generated
9191
// code.
92-
.package(url: "https://github.com/apple/swift-openapi-runtime", .upToNextMinor(from: "0.2.2")),
92+
.package(url: "https://github.com/apple/swift-openapi-runtime", .upToNextMinor(from: "0.2.4")),
9393

9494
// Build and preview docs
9595
.package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"),

Sources/_OpenAPIGeneratorCore/FeatureFlags.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ public enum FeatureFlag: String, Hashable, Codable, CaseIterable, Sendable {
3232
/// A dedicated field in OpenAPI 3.0, a `null` value present in
3333
/// the `types` array in OpenAPI 3.1.
3434
case nullableSchemas
35+
36+
/// Support for `application/x-www-form-urlencoded` request bodies as
37+
/// structured payloads.
38+
case urlEncodedForm
3539
}
3640

3741
/// A set of enabled feature flags.

Sources/_OpenAPIGeneratorCore/Translator/CommonTypes/Constants.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,9 @@ enum Constants {
374374

375375
/// The substring used in method names for the binary coding strategy.
376376
static let binary: String = "Binary"
377+
378+
/// The substring used in method names for the url encoded form coding strategy.
379+
static let urlEncodedForm: String = "URLEncodedForm"
377380
}
378381

379382
/// Constants related to types used in many components.

Sources/_OpenAPIGeneratorCore/Translator/Content/CodingStrategy.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ enum CodingStrategy: String, Hashable, Sendable {
2727
/// A strategy that passes through the data unmodified.
2828
case binary
2929

30+
/// A strategy using x-www-form-urlencoded.
31+
case urlEncodedForm
32+
3033
/// The name of the coding strategy in the runtime library.
3134
var runtimeName: String {
3235
switch self {
@@ -38,6 +41,8 @@ enum CodingStrategy: String, Hashable, Sendable {
3841
return Constants.CodingStrategy.string
3942
case .binary:
4043
return Constants.CodingStrategy.binary
44+
case .urlEncodedForm:
45+
return Constants.CodingStrategy.urlEncodedForm
4146
}
4247
}
4348
}

Sources/_OpenAPIGeneratorCore/Translator/Content/ContentInspector.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,15 @@ extension FileTranslator {
247247
schema: .b(.string)
248248
)
249249
}
250-
if !excludeBinary, contentKey.isBinary {
250+
let urlEncodedFormsSupported = config.featureFlags.contains(.urlEncodedForm)
251+
if urlEncodedFormsSupported && contentKey.isUrlEncodedForm {
252+
let contentType = ContentType(contentKey.typeAndSubtype)
253+
return .init(
254+
contentType: contentType,
255+
schema: contentValue.schema
256+
)
257+
}
258+
if !excludeBinary, contentKey.isBinary || !urlEncodedFormsSupported {
251259
let contentType = contentKey.asGeneratorContentType
252260
return .init(
253261
contentType: contentType,

Sources/_OpenAPIGeneratorCore/Translator/Content/ContentType.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ struct ContentType: Hashable {
4545
/// either to the network (requests) or to the caller (responses).
4646
case binary
4747

48+
/// A content type for x-www-form-urlencoded.
49+
///
50+
/// The top level properties of a Codable data model are encoded
51+
/// as key-value pairs in the form:
52+
///
53+
/// `key1=value1&key2=value2`
54+
///
55+
/// The type is encoded as a binary UTF-8 data packet.
56+
case urlEncodedForm
57+
4858
/// Creates a category from the provided type and subtype.
4959
///
5060
/// First checks if the provided content type is a JSON, then text,
@@ -59,6 +69,8 @@ struct ContentType: Hashable {
5969
self = .json
6070
} else if lowercasedType == "text" {
6171
self = .text
72+
} else if lowercasedType == "application" && lowercasedSubtype == "x-www-form-urlencoded" {
73+
self = .urlEncodedForm
6274
} else {
6375
self = .binary
6476
}
@@ -73,6 +85,8 @@ struct ContentType: Hashable {
7385
return .string
7486
case .binary:
7587
return .binary
88+
case .urlEncodedForm:
89+
return .urlEncodedForm
7690
}
7791
}
7892
}
@@ -236,6 +250,10 @@ struct ContentType: Hashable {
236250
category == .binary
237251
}
238252

253+
var isUrlEncodedForm: Bool {
254+
category == .urlEncodedForm
255+
}
256+
239257
static func == (lhs: Self, rhs: Self) -> Bool {
240258
// MIME type equality is case-insensitive.
241259
lhs.lowercasedTypeAndSubtype == rhs.lowercasedTypeAndSubtype
@@ -254,6 +272,12 @@ extension OpenAPI.ContentType {
254272
asGeneratorContentType.isJSON
255273
}
256274

275+
/// A Boolean value that indicates whether the content type
276+
/// is a URL-encoded form.
277+
var isUrlEncodedForm: Bool {
278+
asGeneratorContentType.isUrlEncodedForm
279+
}
280+
257281
/// A Boolean value that indicates whether the content type
258282
/// is a type of plain text.
259283
var isText: Bool {

Sources/swift-openapi-generator/Documentation.docc/Articles/Supported-OpenAPI-features.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,20 @@ Supported features are always provided on _both_ client and server.
1212

1313
> Tip: If a feature you need isn't currently supported, let us know by filing an issue, or even contribute a pull request. For more information, check out <doc:Contributing-to-Swift-OpenAPI-Generator>.
1414
15+
### Structured content types
16+
17+
For the checked serialization formats below, the generator emits types conforming to `Codable`, structured based on the provided JSON Schema.
18+
19+
For any other formats, the payload is provided as raw bytes, leaving it up to the adopter to decode as needed.
20+
21+
- [x] JSON
22+
- when content type is `application/json` or ends with `+json`
23+
- [x] URL-encoded form request bodies
24+
- when content type is `application/x-www-form-urlencoded`
25+
- [ ] multipart
26+
- tracked by [#36](https://github.com/apple/swift-openapi-generator/issues/36)
27+
- [ ] XML
28+
1529
### OpenAPI specification features
1630

1731
#### OpenAPI Object

Sources/swift-openapi-generator/Documentation.docc/Development/Converting-between-data-and-Swift-types.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ Below is a list of the "dimensions" across which the helper methods differ:
5353
- `URI`
5454
- example: query, path, header parameters
5555
- `color=red&power=24`
56+
- `urlEncodedForm`
57+
- example: request body with the `application/x-www-form-urlencoded` content type
58+
- `greeting=Hello+world`
5659
- `string`
5760
- example: `text/plain`, and any other `text/*` content type
5861
- `"red color and power of 24"`
@@ -94,6 +97,8 @@ method parameters: value or type of value
9497
| client | set | request body | JSON | required | setRequiredRequestBodyAsJSON |
9598
| client | set | request body | binary | optional | setOptionalRequestBodyAsBinary |
9699
| client | set | request body | binary | required | setRequiredRequestBodyAsBinary |
100+
| client | set | request body | urlEncodedForm | optional | setOptionalRequestBodyAsURLEncodedForm |
101+
| client | set | request body | urlEncodedForm | required | setRequiredRequestBodyAsURLEncodedForm |
97102
| client | get | response body | string | required | getResponseBodyAsString |
98103
| client | get | response body | JSON | required | getResponseBodyAsJSON |
99104
| client | get | response body | binary | required | getResponseBodyAsBinary |
@@ -106,6 +111,8 @@ method parameters: value or type of value
106111
| server | get | request body | JSON | required | getRequiredRequestBodyAsJSON |
107112
| server | get | request body | binary | optional | getOptionalRequestBodyAsBinary |
108113
| server | get | request body | binary | required | getRequiredRequestBodyAsBinary |
114+
| server | get | request body | urlEncodedForm | optional | getOptionalRequestBodyAsURLEncodedForm |
115+
| server | get | request body | urlEncodedForm | required | getRequiredRequestBodyAsURLEncodedForm |
109116
| server | set | response body | string | required | setResponseBodyAsString |
110117
| server | set | response body | JSON | required | setResponseBodyAsJSON |
111118
| server | set | response body | binary | required | setResponseBodyAsBinary |

Tests/OpenAPIGeneratorCoreTests/Translator/Content/Test_ContentType.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ final class Test_ContentType: Test_Core {
6161
),
6262
(
6363
"application/x-www-form-urlencoded",
64-
.binary,
64+
.urlEncodedForm,
6565
"application",
6666
"x-www-form-urlencoded",
6767
"",

Tests/OpenAPIGeneratorReferenceTests/FileBasedReferenceTests.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,10 @@ class FileBasedReferenceTests: XCTestCase {
4646

4747
func testPetstore() throws {
4848
try _test(
49-
referenceProject: .init(name: .petstore)
49+
referenceProject: .init(name: .petstore),
50+
featureFlags: [
51+
.urlEncodedForm
52+
]
5053
)
5154
}
5255

Tests/OpenAPIGeneratorReferenceTests/Resources/Docs/petstore.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,25 @@ paths:
113113
$ref: '#/components/schemas/Pet'
114114
'4XX':
115115
$ref: '#/components/responses/ErrorBadRequest'
116+
/pets/create:
117+
summary: Work with pets
118+
description: "Create a pet with a URL form"
119+
post:
120+
summary: Create a pet using a url form
121+
operationId: createPetWithForm
122+
tags:
123+
- pets
124+
parameters:
125+
requestBody:
126+
required: true
127+
description: "Create a pet with these properties"
128+
content:
129+
application/x-www-form-urlencoded:
130+
schema:
131+
$ref: '#/components/schemas/CreatePetRequest'
132+
responses:
133+
'204':
134+
description: Successfully created pet using a url form
116135
/pets/stats:
117136
get:
118137
operationId: getStats

Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Client.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,37 @@ public struct Client: APIProtocol {
213213
}
214214
)
215215
}
216+
/// Create a pet using a url form
217+
///
218+
/// - Remark: HTTP `POST /pets/create`.
219+
/// - Remark: Generated from `#/paths//pets/create/post(createPetWithForm)`.
220+
public func createPetWithForm(_ input: Operations.createPetWithForm.Input) async throws
221+
-> Operations.createPetWithForm.Output
222+
{
223+
try await client.send(
224+
input: input,
225+
forOperation: Operations.createPetWithForm.id,
226+
serializer: { input in let path = try converter.renderedPath(template: "/pets/create", parameters: [])
227+
var request: OpenAPIRuntime.Request = .init(path: path, method: .post)
228+
suppressMutabilityWarning(&request)
229+
switch input.body {
230+
case let .urlEncodedForm(value):
231+
request.body = try converter.setRequiredRequestBodyAsURLEncodedForm(
232+
value,
233+
headerFields: &request.headerFields,
234+
contentType: "application/x-www-form-urlencoded"
235+
)
236+
}
237+
return request
238+
},
239+
deserializer: { response in
240+
switch response.statusCode {
241+
case 204: return .noContent(.init())
242+
default: return .undocumented(statusCode: response.statusCode, .init())
243+
}
244+
}
245+
)
246+
}
216247
/// - Remark: HTTP `GET /pets/stats`.
217248
/// - Remark: Generated from `#/paths//pets/stats/get(getStats)`.
218249
public func getStats(_ input: Operations.getStats.Input) async throws -> Operations.getStats.Output {

Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Server.swift

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ extension APIProtocol {
4141
path: server.apiPathComponentsWithServerPrefix(["pets"]),
4242
queryItemNames: []
4343
)
44+
try transport.register(
45+
{ try await server.createPetWithForm(request: $0, metadata: $1) },
46+
method: .post,
47+
path: server.apiPathComponentsWithServerPrefix(["pets", "create"]),
48+
queryItemNames: []
49+
)
4450
try transport.register(
4551
{ try await server.getStats(request: $0, metadata: $1) },
4652
method: .get,
@@ -251,6 +257,47 @@ fileprivate extension UniversalServer where APIHandler: APIProtocol {
251257
}
252258
)
253259
}
260+
/// Create a pet using a url form
261+
///
262+
/// - Remark: HTTP `POST /pets/create`.
263+
/// - Remark: Generated from `#/paths//pets/create/post(createPetWithForm)`.
264+
func createPetWithForm(request: Request, metadata: ServerRequestMetadata) async throws -> Response {
265+
try await handle(
266+
request: request,
267+
with: metadata,
268+
forOperation: Operations.createPetWithForm.id,
269+
using: { APIHandler.createPetWithForm($0) },
270+
deserializer: { request, metadata in
271+
let contentType = converter.extractContentTypeIfPresent(in: request.headerFields)
272+
let body: Operations.createPetWithForm.Input.Body
273+
if try contentType == nil
274+
|| converter.isMatchingContentType(
275+
received: contentType,
276+
expectedRaw: "application/x-www-form-urlencoded"
277+
)
278+
{
279+
body = try converter.getRequiredRequestBodyAsURLEncodedForm(
280+
Components.Schemas.CreatePetRequest.self,
281+
from: request.body,
282+
transforming: { value in .urlEncodedForm(value) }
283+
)
284+
} else {
285+
throw converter.makeUnexpectedContentTypeError(contentType: contentType)
286+
}
287+
return Operations.createPetWithForm.Input(body: body)
288+
},
289+
serializer: { output, request in
290+
switch output {
291+
case let .noContent(value):
292+
suppressUnusedWarning(value)
293+
var response = Response(statusCode: 204)
294+
suppressMutabilityWarning(&response)
295+
return response
296+
case let .undocumented(statusCode, _): return .init(statusCode: statusCode)
297+
}
298+
}
299+
)
300+
}
254301
/// - Remark: HTTP `GET /pets/stats`.
255302
/// - Remark: Generated from `#/paths//pets/stats/get(getStats)`.
256303
func getStats(request: Request, metadata: ServerRequestMetadata) async throws -> Response {

Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Types.swift

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ public protocol APIProtocol: Sendable {
2424
/// - Remark: HTTP `POST /pets`.
2525
/// - Remark: Generated from `#/paths//pets/post(createPet)`.
2626
func createPet(_ input: Operations.createPet.Input) async throws -> Operations.createPet.Output
27+
/// Create a pet using a url form
28+
///
29+
/// - Remark: HTTP `POST /pets/create`.
30+
/// - Remark: Generated from `#/paths//pets/create/post(createPetWithForm)`.
31+
func createPetWithForm(_ input: Operations.createPetWithForm.Input) async throws
32+
-> Operations.createPetWithForm.Output
2733
/// - Remark: HTTP `GET /pets/stats`.
2834
/// - Remark: Generated from `#/paths//pets/stats/get(getStats)`.
2935
func getStats(_ input: Operations.getStats.Input) async throws -> Operations.getStats.Output
@@ -1014,6 +1020,42 @@ public enum Operations {
10141020
public static var allCases: [Self] { [.json] }
10151021
}
10161022
}
1023+
/// Create a pet using a url form
1024+
///
1025+
/// - Remark: HTTP `POST /pets/create`.
1026+
/// - Remark: Generated from `#/paths//pets/create/post(createPetWithForm)`.
1027+
public enum createPetWithForm {
1028+
public static let id: String = "createPetWithForm"
1029+
public struct Input: Sendable, Hashable {
1030+
/// - Remark: Generated from `#/paths/pets/create/POST/requestBody`.
1031+
@frozen public enum Body: Sendable, Hashable {
1032+
/// - Remark: Generated from `#/paths/pets/create/POST/requestBody/content/application\/x-www-form-urlencoded`.
1033+
case urlEncodedForm(Components.Schemas.CreatePetRequest)
1034+
}
1035+
public var body: Operations.createPetWithForm.Input.Body
1036+
/// Creates a new `Input`.
1037+
///
1038+
/// - Parameters:
1039+
/// - body:
1040+
public init(body: Operations.createPetWithForm.Input.Body) { self.body = body }
1041+
}
1042+
@frozen public enum Output: Sendable, Hashable {
1043+
public struct NoContent: Sendable, Hashable {
1044+
/// Creates a new `NoContent`.
1045+
public init() {}
1046+
}
1047+
/// Successfully created pet using a url form
1048+
///
1049+
/// - Remark: Generated from `#/paths//pets/create/post(createPetWithForm)/responses/204`.
1050+
///
1051+
/// HTTP response code: `204 noContent`.
1052+
case noContent(Operations.createPetWithForm.Output.NoContent)
1053+
/// Undocumented response.
1054+
///
1055+
/// A response with a code that is not documented in the OpenAPI document.
1056+
case undocumented(statusCode: Int, OpenAPIRuntime.UndocumentedPayload)
1057+
}
1058+
}
10171059
/// - Remark: HTTP `GET /pets/stats`.
10181060
/// - Remark: Generated from `#/paths//pets/stats/get(getStats)`.
10191061
public enum getStats {

0 commit comments

Comments
 (0)