Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Generator] Multiple content types support #146

Merged
merged 11 commits into from
Aug 4, 2023
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion .swift-format
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"OnlyOneTrailingClosureArgument" : true,
"OrderedImports" : false,
"ReturnVoidInsteadOfEmptyTuple" : true,
"UseEarlyExits" : true,
"UseEarlyExits" : false,
czechboy0 marked this conversation as resolved.
Show resolved Hide resolved
"UseLetInEveryBoundCaseVariable" : false,
"UseShorthandTypeNames" : true,
"UseSingleLinePropertyGetter" : false,
Expand Down
12 changes: 12 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,18 @@ let package = Package(
swiftSettings: swiftSettings
),

// PetstoreConsumerTestsFFMultipleContentTypes
// Builds and tests the reference code from GeneratorReferenceTests
// to ensure it actually works correctly at runtime.
// Enabled feature flag: multipleContentTypes
.testTarget(
name: "PetstoreConsumerTestsFFMultipleContentTypes",
dependencies: [
"PetstoreConsumerTestCore"
],
swiftSettings: swiftSettings
),

// Generator CLI
.executableTarget(
name: "swift-openapi-generator",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,38 @@ struct SwitchDescription: Equatable, Codable {
var cases: [SwitchCaseDescription]
}

/// A description of an if condition and the corresponding code block.
///
/// For example: in `if foo { bar }`, the condition pair represents
/// `foo` + `bar`.
struct IfConditionPair: Equatable, Codable {
czechboy0 marked this conversation as resolved.
Show resolved Hide resolved

/// The expressions evaluated by the if statement and their corresponding
/// body blocks. If more than one is provided, an `else if` branch is added.
///
/// For example, in `if foo { bar }`, `condition` is `foo`.
var condition: Expression

/// The body executed if the `condition` evaluates to true.
///
/// For example, in `if foo { bar }`, `body` is `bar`.
var body: [CodeBlock]
}

/// A description of an if[[/elseif]/else] statement expression.
///
/// For example: `if foo { } else if bar { } else { }`.
struct IfStatementDescription: Equatable, Codable {

/// The conditional branches.
var branches: [IfConditionPair]

/// The body of an else block.
///
/// No `else` statement is added when `elseBody` is nil.
var elseBody: [CodeBlock]?
}

/// A description of a do statement.
///
/// For example: `do { try foo() } catch { return bar }`.
Expand Down Expand Up @@ -709,6 +741,9 @@ enum KeywordKind: Equatable, Codable {

/// The await keyword.
case `await`

/// The throw keyword.
case `throw`
}

/// A description of an expression that places a keyword before an expression.
Expand Down Expand Up @@ -751,8 +786,14 @@ enum BinaryOperator: String, Equatable, Codable {
/// The += operator, adds and then assigns another value.
case plusEquals = "+="

/// The == operator, checks equality between two values.
case equals = "=="

/// The ... operator, creates an end-inclusive range between two numbers.
case rangeInclusive = "..."

/// The || operator, used between two Boolean values.
case booleanOr = "||"
}

/// A description of a binary operation expression.
Expand Down Expand Up @@ -832,6 +873,11 @@ indirect enum Expression: Equatable, Codable {
/// For example: `switch foo {`.
case `switch`(SwitchDescription)

/// An if statement, with optional else if's and an else statement attached.
///
/// For example: `if foo { bar } else if baz { boo } else { bam }`.
case ifStatement(IfStatementDescription)

/// A do statement.
///
/// For example: `do { try foo() } catch { return bar }`.
Expand Down Expand Up @@ -1202,6 +1248,23 @@ extension Expression {
)
}

/// Returns an if statement, with optional else if's and an else
/// statement attached.
/// - Parameters:
/// - branches: The conditional branches.
/// - elseBody: The body of an else block.
static func ifStatement(
branches: [IfConditionPair],
elseBody: [CodeBlock]? = nil
) -> Self {
czechboy0 marked this conversation as resolved.
Show resolved Hide resolved
.ifStatement(
.init(
branches: branches,
elseBody: elseBody
)
)
}

/// Returns a new function call expression.
///
/// For example `foo(bar: 42)`.
Expand Down
26 changes: 26 additions & 0 deletions Sources/_OpenAPIGeneratorCore/Renderer/TextBasedRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,28 @@ struct TextBasedRenderer: RendererProtocol {
return lines.joinedLines()
}

/// Renders the specified if statement.
func renderedIf(_ ifDesc: IfStatementDescription) -> String {
var branches = ifDesc.branches
precondition(!branches.isEmpty, "Cannot have an if statement with no branches.")
var lines: [String] = []
let firstCondition = branches.removeFirst()
lines.append("if \(renderedExpression(firstCondition.condition)) {")
lines.append(renderedCodeBlocks(firstCondition.body))
lines.append("}")
for branch in branches {
lines.append("else if \(renderedExpression(branch.condition)) {")
lines.append(renderedCodeBlocks(branch.body))
lines.append("}")
}
if let elseBody = ifDesc.elseBody {
lines.append("else {")
lines.append(renderedCodeBlocks(elseBody))
lines.append("}")
}
return lines.joinedLines()
}

/// Renders the specified switch expression.
func renderedDoStatement(_ description: DoStatementDescription) -> String {
var lines: [String] = ["do {"]
Expand All @@ -199,6 +221,8 @@ struct TextBasedRenderer: RendererProtocol {
return "try\(hasPostfixQuestionMark ? "?" : "")"
case .await:
return "await"
case .throw:
return "throw"
}
}

Expand Down Expand Up @@ -266,6 +290,8 @@ struct TextBasedRenderer: RendererProtocol {
return renderedAssignment(assignment)
case .switch(let switchDesc):
return renderedSwitch(switchDesc)
case .ifStatement(let ifDesc):
return renderedIf(ifDesc)
case .doStatement(let doStmt):
return renderedDoStatement(doStmt)
case .valueBinding(let valueBinding):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,78 @@ extension FileTranslator {
return .init(content: content, typeUsage: associatedType)
}

/// Extract the supported content types.
/// - Parameters:
/// - map: The content map from the OpenAPI document.
/// - excludeBinary: A Boolean value controlling whether binary content
/// type should be skipped, for example used when encoding headers.
/// - parent: The parent type of the chosen typed schema.
/// - Returns: The supported content type + schema + type names.
func supportedTypedContents(
_ map: OpenAPI.Content.Map,
excludeBinary: Bool = false,
inParent parent: TypeName
) throws -> [TypedSchemaContent] {
let contents = supportedContents(
map,
excludeBinary: excludeBinary,
foundIn: parent.description
)
return try contents.compactMap { content in
guard
try validateSchemaIsSupported(
content.schema,
foundIn: parent.description
)
else {
return nil
}
simonjbeaumont marked this conversation as resolved.
Show resolved Hide resolved
let identifier = contentSwiftName(content.contentType)
let associatedType = try typeAssigner.typeUsage(
usingNamingHint: identifier,
withSchema: content.schema,
inParent: parent
)
return .init(content: content, typeUsage: associatedType)
}
}

/// Extract the supported content types.
/// - Parameters:
/// - contents: The content map from the OpenAPI document.
/// - excludeBinary: A Boolean value controlling whether binary content
/// type should be skipped, for example used when encoding headers.
/// - foundIn: The location where this content is parsed.
/// - Returns: the detected content type + schema, nil if no supported
/// schema found or if empty.
func supportedContents(
_ contents: OpenAPI.Content.Map,
excludeBinary: Bool = false,
foundIn: String
) -> [SchemaContent] {
guard !contents.isEmpty else {
return []
}
guard config.featureFlags.contains(.multipleContentTypes) else {
return bestSingleContent(
contents,
excludeBinary: excludeBinary,
foundIn: foundIn
)
.flatMap { [$0] } ?? []
}
return
contents
.compactMap { key, value in
parseContentIfSupported(
contentKey: key,
contentValue: value,
excludeBinary: excludeBinary,
foundIn: foundIn + "/\(key.rawValue)"
)
}
}

/// While we only support a single content at a time, choose the best one.
///
/// Priority:
Expand All @@ -72,6 +144,7 @@ extension FileTranslator {
/// - map: The content map from the OpenAPI document.
/// - excludeBinary: A Boolean value controlling whether binary content
/// type should be skipped, for example used when encoding headers.
/// - foundIn: The location where this content is parsed.
/// - Returns: the detected content type + schema, nil if no supported
/// schema found or if empty.
func bestSingleContent(
czechboy0 marked this conversation as resolved.
Show resolved Hide resolved
Expand Down Expand Up @@ -123,4 +196,62 @@ extension FileTranslator {
return nil
}
}

/// Returns a wrapped version of the provided content if supported, returns
/// nil otherwise.
///
/// Priority of checking for known MIME types:
/// 1. JSON
/// 2. text
/// 3. binary
///
/// - Parameters:
/// - contentKey: The content key from the OpenAPI document.
/// - contentValue: The content value from the OpenAPI document.
/// - excludeBinary: A Boolean value controlling whether binary content
/// type should be skipped, for example used when encoding headers.
/// - foundIn: The location where this content is parsed.
/// - Returns: The detected content type + schema, nil if unsupported.
func parseContentIfSupported(
contentKey: OpenAPI.ContentType,
contentValue: OpenAPI.Content,
excludeBinary: Bool = false,
foundIn: String
) -> SchemaContent? {
if contentKey.isJSON,
let contentType = ContentType(contentKey.typeAndSubtype)
{
diagnostics.emitUnsupportedIfNotNil(
contentValue.encoding,
"Custom encoding for JSON content",
foundIn: "\(foundIn), content \(contentKey.rawValue)"
)
czechboy0 marked this conversation as resolved.
Show resolved Hide resolved
return .init(
contentType: contentType,
schema: contentValue.schema
)
}
if contentKey.isText,
let contentType = ContentType(contentKey.typeAndSubtype)
{
return .init(
contentType: contentType,
schema: .b(.string)
)
}
if !excludeBinary,
contentKey.isBinary,
let contentType = ContentType(contentKey.typeAndSubtype)
{
return .init(
contentType: contentType,
schema: .b(.string(format: .binary))
)
}
diagnostics.emitUnsupported(
"Unsupported content",
foundIn: foundIn
)
return nil
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,25 @@ extension FileTranslator {
/// - Parameter contentType: The content type for which to compute the name.
func contentSwiftName(_ contentType: ContentType) -> String {
if config.featureFlags.contains(.multipleContentTypes) {
return "unsupported"
let rawMIMEType = contentType.rawMIMEType
switch rawMIMEType {
case "application/json":
return "json"
case "application/x-www-form-urlencoded":
return "form"
case "multipart/form-data":
return "multipart"
case "text/plain":
return "text"
case "*/*":
return "any"
case "application/xml":
return "xml"
case "application/octet-stream":
return "binary"
default:
return swiftSafeName(for: rawMIMEType)
}
czechboy0 marked this conversation as resolved.
Show resolved Hide resolved
} else {
switch contentType {
case .json:
Expand Down
21 changes: 8 additions & 13 deletions Sources/_OpenAPIGeneratorCore/Translator/Content/ContentType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ enum ContentType: Hashable {
self = .binary(rawValue)
}

/// Returns the original raw MIME type.
var rawMIMEType: String {
switch self {
case .json(let string), .text(let string), .binary(let string):
return string
}
}

/// The header value used when sending a content-type header.
var headerValueForSending: String {
switch self {
Expand Down Expand Up @@ -107,23 +115,10 @@ enum ContentType: Hashable {
}
return false
}

/// Returns a new content type representing an octet stream.
static var octetStream: Self {
.binary("application/octet-stream")
}

/// Returns a new content type representing JSON.
static var applicationJSON: Self {
.json("application/json")
}
}

extension OpenAPI.ContentType {

/// Returns a new content type representing an octet stream.
static let octetStream: Self = .other(ContentType.octetStream.headerValueForValidation)

/// A Boolean value that indicates whether the content type
/// is a type of JSON.
var isJSON: Bool {
Expand Down
Loading