Skip to content

Commit a243417

Browse files
Generate shorthand throwing APIs for providing inputs and handling outputs (#308)
### Motivation The review period for SOAR-0007 (Shorthand APIs for inputs and outputs) has now concluded. This pull request adds the required SPIs to the runtime library to throw a runtime error when the response and/or body does not match that of the shorthand API being used. For further context, please review the proposal itself.[^1] [^1]: #291 ### Modifications - Add support for computed properties with effects (e.g. throwing getters). - Generate a protocol extension to `APIProtocol` with an overload for each operation that lifts each of the parameters of `Input.init` as function parameters. - Generate a throwing computed property for each enum case related to a documented outcome, which will return the associated value for the expected case, or throw a runtime error if the value is a different enum case. ### Result Code that used to be written like this ```swift // before switch try await client.getGreeting(.init()) { case .ok(let response): switch response.body { case .json(let body): print(body.message) } case .undocumented(statusCode: _, _): throw UnexpectedResponseError() } // after print(try await client.getGreeting().ok.body.json.message) // ^ ^ ^ ^ // | | | `- (New) Throws if body did not conform to documented JSON. // | | | // | | `- (New) Throws if HTTP response is not 200 (OK). // | | // | `- (New) No need to wrap parameters in input value. // | // `- (Existing) Throws if there is an error making the API call. ``` ### Test Plan This PR includes updates to the various tests: - `SnippetBasedReferenceTests` - `FileBasedReferenceTests` - `PetstoreConsumerTests` ### Related Issues - Resolves #22 - Resolves #104 - Resolves #145 --------- Signed-off-by: Si Beaumont <[email protected]>
1 parent da2d596 commit a243417

File tree

15 files changed

+706
-35
lines changed

15 files changed

+706
-35
lines changed

Examples/GreetingServiceClient/Sources/GreetingServiceClient/GreetingServiceClient.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,5 +42,14 @@ struct GreetingServiceClient {
4242
case .undocumented(statusCode: let statusCode, let undocumentedPayload):
4343
print("Undocumented response \(statusCode) from server: \(undocumentedPayload).")
4444
}
45+
46+
// Use shorthand APIs to get an expected response or otherwise throw a runtime error.
47+
print(try await client.getGreeting().ok.body.json.message)
48+
// ^ ^ ^
49+
// | | `- Throws if body did not parse as documented JSON.
50+
// | |
51+
// | `- Throws if HTTP response is not 200 (OK).
52+
// |
53+
// `- Throws if there is an error making the API call.
4554
}
4655
}

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.3.0")),
92+
.package(url: "https://github.com/apple/swift-openapi-runtime", .upToNextMinor(from: "0.3.1")),
9393

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

Sources/_OpenAPIGeneratorCore/Layers/StructuredSwiftRepresentation.swift

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -232,10 +232,15 @@ struct VariableDescription: Equatable, Codable {
232232
/// For example, in `let foo = 42`, `right` represents `42`.
233233
var right: Expression? = nil
234234

235-
/// Body code blocks of the variable.
235+
/// Body code for the getter.
236236
///
237-
/// For example, in `let foo: Int { 42 }`, `body` represents `{ 42 }`.
238-
var body: [CodeBlock]? = nil
237+
/// For example, in `var foo: Int { 42 }`, `body` represents `{ 42 }`.
238+
var getter: [CodeBlock]? = nil
239+
240+
/// Effects for the getter.
241+
///
242+
/// For example, in `var foo: Int { get throws { 42 } }`, effects are `[.throws]`.
243+
var getterEffects: [FunctionKeyword] = []
239244
}
240245

241246
/// A requirement of a where clause.
@@ -1014,7 +1019,8 @@ extension Declaration {
10141019
/// - left: The name of the variable.
10151020
/// - type: The type of the variable.
10161021
/// - right: The expression to be assigned to the variable.
1017-
/// - body: Body code blocks of the variable.
1022+
/// - getter: Body code for the getter of the variable.
1023+
/// - getterEffects: Effects of the getter.
10181024
/// - Returns: Variable declaration.
10191025
static func variable(
10201026
accessModifier: AccessModifier? = nil,
@@ -1023,7 +1029,8 @@ extension Declaration {
10231029
left: String,
10241030
type: String? = nil,
10251031
right: Expression? = nil,
1026-
body: [CodeBlock]? = nil
1032+
getter: [CodeBlock]? = nil,
1033+
getterEffects: [FunctionKeyword] = []
10271034
) -> Self {
10281035
.variable(
10291036
.init(
@@ -1033,7 +1040,8 @@ extension Declaration {
10331040
left: left,
10341041
type: type,
10351042
right: right,
1036-
body: body
1043+
getter: getter,
1044+
getterEffects: getterEffects
10371045
)
10381046
)
10391047
}

Sources/_OpenAPIGeneratorCore/Renderer/TextBasedRenderer.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -430,9 +430,15 @@ struct TextBasedRenderer: RendererProtocol {
430430
}
431431

432432
var lines: [String] = [words.joinedWords()]
433-
if let body = variable.body {
433+
if let body = variable.getter {
434434
lines.append("{")
435+
if !variable.getterEffects.isEmpty {
436+
lines.append("get \(variable.getterEffects.map(renderedFunctionKeyword).joined(separator: " ")) {")
437+
}
435438
lines.append(renderedCodeBlocks(body))
439+
if !variable.getterEffects.isEmpty {
440+
lines.append("}")
441+
}
436442
lines.append("}")
437443
}
438444
return lines.joinedLines()

Sources/_OpenAPIGeneratorCore/Translator/ClientTranslator/ClientTranslator.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ struct ClientFileTranslator: FileTranslator {
127127
kind: .var,
128128
left: "converter",
129129
type: Constants.Converter.typeName,
130-
body: [
130+
getter: [
131131
.expression(
132132
.identifier(Constants.Client.Universal.propertyName)
133133
.dot("converter")

Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateRawRepresentableEnum.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ extension FileTranslator {
156156
kind: .var,
157157
left: "rawValue",
158158
type: "String",
159-
body: [
159+
getter: [
160160
.expression(
161161
.switch(
162162
switchedExpression: .identifier("self"),
@@ -183,7 +183,7 @@ extension FileTranslator {
183183
kind: .var,
184184
left: "allCases",
185185
type: "[Self]",
186-
body: [
186+
getter: [
187187
.expression(.literal(.array(caseExpressions)))
188188
]
189189
)

Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponse.swift

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,61 @@ extension TypesFileTranslator {
103103
)
104104
)
105105
bodyCases.append(bodyCase)
106+
107+
var throwingGetterSwitchCases = [
108+
SwitchCaseDescription(
109+
kind: .case(.identifier(".\(identifier)"), ["body"]),
110+
body: [.expression(.return(.identifier("body")))]
111+
)
112+
]
113+
// We only generate the default branch if there is more than one case to prevent
114+
// a warning when compiling the generated code.
115+
if typedContents.count > 1 {
116+
throwingGetterSwitchCases.append(
117+
SwitchCaseDescription(
118+
kind: .default,
119+
body: [
120+
.expression(
121+
.try(
122+
.identifier("throwUnexpectedResponseBody")
123+
.call([
124+
.init(
125+
label: "expectedContent",
126+
expression: .literal(.string(contentType.headerValueForValidation))
127+
),
128+
.init(label: "body", expression: .identifier("self")),
129+
])
130+
)
131+
)
132+
]
133+
)
134+
)
135+
}
136+
let throwingGetter = VariableDescription(
137+
accessModifier: config.access,
138+
isStatic: false,
139+
kind: .var,
140+
left: identifier,
141+
type: associatedType.fullyQualifiedSwiftName,
142+
getter: [
143+
.expression(
144+
.switch(
145+
switchedExpression: .identifier("self"),
146+
cases: throwingGetterSwitchCases
147+
)
148+
)
149+
],
150+
getterEffects: [.throws]
151+
)
152+
let throwingGetterComment = Comment.doc(
153+
"""
154+
The associated value of the enum case if `self` is `.\(identifier)`.
155+
156+
- Throws: An error if `self` is not `.\(identifier)`.
157+
- SeeAlso: `.\(identifier)`.
158+
"""
159+
)
160+
bodyCases.append(.commentable(throwingGetterComment, .variable(throwingGetter)))
106161
}
107162
let hasNoContent: Bool = bodyCases.isEmpty
108163
let contentEnumDecl: Declaration = .commentable(

Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponseOutcome.swift

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,15 @@ extension TypesFileTranslator {
3030
_ outcome: OpenAPI.Operation.ResponseOutcome,
3131
operation: OperationDescription,
3232
operationJSONPath: String
33-
) throws -> (payloadStruct: Declaration?, enumCase: Declaration) {
33+
) throws -> (payloadStruct: Declaration?, enumCase: Declaration, throwingGetter: Declaration) {
3434

3535
let typedResponse = try typedResponse(
3636
from: outcome,
3737
operation: operation
3838
)
3939
let responseStructTypeName = typedResponse.typeUsage.typeName
4040
let responseKind = outcome.status.value.asKind
41+
let enumCaseName = responseKind.identifier
4142

4243
let responseStructDecl: Declaration?
4344
if typedResponse.isInlined {
@@ -49,22 +50,15 @@ extension TypesFileTranslator {
4950
responseStructDecl = nil
5051
}
5152

52-
let optionalStatusCode: [EnumCaseAssociatedValueDescription]
53+
var associatedValues: [EnumCaseAssociatedValueDescription] = []
5354
if responseKind.wantsStatusCode {
54-
optionalStatusCode = [
55-
.init(label: "statusCode", type: TypeName.int.shortSwiftName)
56-
]
57-
} else {
58-
optionalStatusCode = []
55+
associatedValues.append(.init(label: "statusCode", type: TypeName.int.shortSwiftName))
5956
}
57+
associatedValues.append(.init(type: responseStructTypeName.fullyQualifiedSwiftName))
6058

6159
let enumCaseDesc = EnumCaseDescription(
62-
name: responseKind.identifier,
63-
kind: .nameWithAssociatedValues(
64-
optionalStatusCode + [
65-
.init(type: responseStructTypeName.fullyQualifiedSwiftName)
66-
]
67-
)
60+
name: enumCaseName,
61+
kind: .nameWithAssociatedValues(associatedValues)
6862
)
6963
let enumCaseDecl: Declaration = .commentable(
7064
responseKind.docComment(
@@ -73,7 +67,61 @@ extension TypesFileTranslator {
7367
),
7468
.enumCase(enumCaseDesc)
7569
)
76-
return (responseStructDecl, enumCaseDecl)
70+
71+
let throwingGetterDesc = VariableDescription(
72+
accessModifier: config.access,
73+
kind: .var,
74+
left: enumCaseName,
75+
type: responseStructTypeName.fullyQualifiedSwiftName,
76+
getter: [
77+
.expression(
78+
.switch(
79+
switchedExpression: .identifier("self"),
80+
cases: [
81+
SwitchCaseDescription(
82+
kind: .case(
83+
.identifier(".\(responseKind.identifier)"),
84+
responseKind.wantsStatusCode ? ["_", "response"] : ["response"]
85+
),
86+
body: [.expression(.return(.identifier("response")))]
87+
),
88+
SwitchCaseDescription(
89+
kind: .default,
90+
body: [
91+
.expression(
92+
.try(
93+
.identifier("throwUnexpectedResponseStatus")
94+
.call([
95+
.init(
96+
label: "expectedStatus",
97+
expression: .literal(.string(responseKind.prettyName))
98+
),
99+
.init(label: "response", expression: .identifier("self")),
100+
])
101+
)
102+
)
103+
]
104+
),
105+
]
106+
)
107+
)
108+
],
109+
getterEffects: [.throws]
110+
)
111+
let throwingGetterComment = Comment.doc(
112+
"""
113+
The associated value of the enum case if `self` is `.\(enumCaseName)`.
114+
115+
- Throws: An error if `self` is not `.\(enumCaseName)`.
116+
- SeeAlso: `.\(enumCaseName)`.
117+
"""
118+
)
119+
let throwingGetterDecl = Declaration.commentable(
120+
throwingGetterComment,
121+
.variable(throwingGetterDesc)
122+
)
123+
124+
return (responseStructDecl, enumCaseDecl, throwingGetterDecl)
77125
}
78126
}
79127

Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/TypesFileTranslator.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ struct TypesFileTranslator: FileTranslator {
4343

4444
let apiProtocol = try translateAPIProtocol(doc.paths)
4545

46+
let apiProtocolExtension = try translateAPIProtocolExtension(doc.paths)
47+
4648
let serversDecl = translateServers(doc.servers)
4749

4850
let components = try translateComponents(doc.components)
@@ -59,6 +61,7 @@ struct TypesFileTranslator: FileTranslator {
5961
imports: imports,
6062
codeBlocks: [
6163
.declaration(apiProtocol),
64+
.declaration(apiProtocolExtension),
6265
.declaration(serversDecl),
6366
components,
6467
operations,

Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateAPIProtocol.swift

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,71 @@ extension TypesFileTranslator {
4545
)
4646
}
4747

48+
/// Returns an extension to the `APIProtocol` protocol, with some syntactic sugar APIs.
49+
func translateAPIProtocolExtension(_ paths: OpenAPI.PathItem.Map) throws -> Declaration {
50+
let operations = try OperationDescription.all(
51+
from: paths,
52+
in: components,
53+
asSwiftSafeName: swiftSafeName
54+
)
55+
56+
// This looks for all initializers in the operation input struct and creates a flattened function.
57+
let flattenedOperations = try operations.flatMap { operation in
58+
guard case let .commentable(_, .struct(input)) = try translateOperationInput(operation) else {
59+
fatalError()
60+
}
61+
return input.members.compactMap { member -> Declaration? in
62+
guard case let .commentable(_, .function(initializer)) = member,
63+
case .initializer = initializer.signature.kind
64+
else {
65+
return nil
66+
}
67+
let function = FunctionDescription(
68+
accessModifier: config.access,
69+
kind: .function(name: operation.methodName),
70+
parameters: initializer.signature.parameters,
71+
keywords: [.async, .throws],
72+
returnType: .identifier(operation.outputTypeName.fullyQualifiedSwiftName),
73+
body: [
74+
.try(
75+
.await(
76+
.identifier(operation.methodName)
77+
.call([
78+
FunctionArgumentDescription(
79+
label: nil,
80+
expression: .identifier(operation.inputTypeName.fullyQualifiedSwiftName)
81+
.call(
82+
initializer.signature.parameters.map { parameter in
83+
guard let label = parameter.label else {
84+
preconditionFailure()
85+
}
86+
return FunctionArgumentDescription(
87+
label: label,
88+
expression: .identifier(label)
89+
)
90+
}
91+
)
92+
)
93+
])
94+
)
95+
)
96+
]
97+
)
98+
return .commentable(operation.comment, .function(function))
99+
}
100+
}
101+
102+
return .commentable(
103+
.doc("Convenience overloads for operation inputs."),
104+
.extension(
105+
ExtensionDescription(
106+
onType: Constants.APIProtocol.typeName,
107+
declarations: flattenedOperations
108+
)
109+
)
110+
)
111+
}
112+
48113
/// Returns a declaration of a single method in the API protocol.
49114
///
50115
/// Each method represents one HTTP operation defined in the OpenAPI

Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateOperations.swift

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -154,14 +154,12 @@ extension TypesFileTranslator {
154154
operationJSONPath: description.jsonPathComponent
155155
)
156156
}
157-
let documentedMembers: [Declaration] =
158-
documentedOutcomes
159-
.flatMap { inlineResponseDecl, caseDecl in
160-
guard let inlineResponseDecl else {
161-
return [caseDecl]
162-
}
163-
return [inlineResponseDecl, caseDecl]
164-
}
157+
let documentedMembers: [Declaration] = documentedOutcomes.flatMap {
158+
inlineResponseDecl,
159+
caseDecl,
160+
throwingGetter in
161+
[inlineResponseDecl, caseDecl, throwingGetter].compactMap { $0 }
162+
}
165163

166164
let allMembers: [Declaration]
167165
if description.containsDefaultResponse {

0 commit comments

Comments
 (0)