Skip to content

Commit

Permalink
Merge branch 'Swaggen Templates' into v1.0.0
Browse files Browse the repository at this point in the history
  • Loading branch information
leogdion committed May 24, 2023
2 parents c8e3316 + 876418b commit 2a6dd91
Show file tree
Hide file tree
Showing 11 changed files with 574 additions and 0 deletions.
17 changes: 17 additions & 0 deletions Template/Swaggen/Includes/Enum.stencil
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{% if description %}
/** {{ description }} */
{% endif %}
public enum {{ enumName }}: {{ type }}, Codable, Equatable, CaseIterable {
{% for enumCase in enums %}
case {{ enumCase.name }} = {% if type == "String" %}"{% endif %}{{enumCase.value}}{% if type == "String" %}"{% endif %}
{% endfor %}
{% if options.enumUndecodableCase %}
case undecodable

public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let rawValue = try container.decode({{ type }}.self)
self = {{ enumName }}(rawValue: rawValue) ?? .undecodable
}
{% endif %}
}
4 changes: 4 additions & 0 deletions Template/Swaggen/Includes/Header.stencil
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
//
// Generated by SwagGen
// https://github.com/yonaskolb/SwagGen
//
101 changes: 101 additions & 0 deletions Template/Swaggen/Includes/Model.stencil
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
{% if description %}
/** {{ description }} */
{% endif %}
{% if enum %}
{% include "Includes/Enum.stencil" enum %}
{% elif aliasType %}
public typealias {{ type }} = {{ aliasType }}
{% elif additionalPropertiesType and allProperties.count == 0 %}
public typealias {{ type }} = [String: {{ additionalPropertiesType }}]
{% elif discriminatorType %}
public enum {{ type }}: Codable, Equatable, Content {
{% for subType in discriminatorType.subTypes %}
case {{ subType.name}}({{ subType.type }})
{% endfor %}
{% if options.enumUndecodableCase %}
case undecodable
{% endif %}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: StringCodingKey.self)
let discriminator: String = try container.decode("{{ discriminatorType.discriminatorProperty }}")
switch discriminator {
{% for name, subType in discriminatorType.mapping %}
case "{{ name }}":
self = .{{ subType.name}}(try {{ subType.type }}(from: decoder))
{% endfor %}
default:
{% if options.enumUndecodableCase %}
self = .undecodable
{% else %}
throw DecodingError.dataCorrupted(DecodingError.Context.init(codingPath: decoder.codingPath, debugDescription: "Couldn't find type to decode with discriminator \(discriminator)"))
{% endif %}
}
}

public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
{% for subType in discriminatorType.subTypes %}
case .{{ subType.name}}(let content):
try container.encode(content)
{% endfor %}
{% if options.enumUndecodableCase %}
case .undecodable:
try container.encode("undecodable")
{% endif %}
}
}
}
{% else %}
public {{ options.modelType }} {{ type }}: {% if parent %}{{ parent.type }}{% else %}{% if options.modelProtocol %}{{ options.modelProtocol }}{% else %}Codable, Equatable, Content{% endif %}{% endif %} {
{% for enum in enums %}
{% if not enum.isGlobal %}

{% filter indent:4 %}{% include "Includes/Enum.stencil" enum %}{% endfilter %}
{% endif %}
{% endfor %}
{% for property in properties %}

{% if property.description %}
/** {{ property.description }} */
{% endif %}
public {% if options.mutableModels %}var{% else %}let{% endif %} {{ property.name }}: {{ property.optionalType }}
{% endfor %}
{% if additionalPropertiesType %}

public {% if options.mutableModels %}var{% else %}let{% endif %} additionalProperties: [String: {{ additionalPropertiesType }}] = [:]
{% endif %}
{% for schema in schemas %}

{% filter indent:4 %}{% include "Includes/Model.stencil" schema %}{% endfilter %}
{% endfor %}

public {% if parent %}{% if properties.count == 0 %}override {% endif %}{% endif %}init({% for property in allProperties %}{{ property.name }}: {{ property.optionalType }}{% ifnot property.required %} = nil{% endif %}{% ifnot forloop.last %}, {% endif %}{% endfor %}) {
{% for property in properties %}
self.{{ property.name }} = {{ property.name }}
{% endfor %}
{% if parent %}
super.init({% for property in parent.allProperties %}{{ property.name }}: {{ property.name }}{% ifnot forloop.last %}, {% endif %}{% endfor %})
{% endif %}
}

{% if additionalPropertiesType %}

public subscript(key: String) -> {{ additionalPropertiesType }}? {
get {
return additionalProperties[key]
}
set {
additionalProperties[key] = newValue
}
}
{% endif %}

public enum CodingKeys: String, CodingKey {
{% for property in properties %}
case {{ property.name }} = "{{ property.value }}"
{% endfor %}
}
}
{% endif %}
30 changes: 30 additions & 0 deletions Template/Swaggen/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// swift-tools-version:5.7
// swiftlint:disable explicit_top_level_acl
import PackageDescription

let package = Package(
name: "{{ options.name }}",
platforms: [
.macOS(.v13),
.iOS(.v16),
.tvOS(.v16),
.watchOS(.v9)
],
products: [
.library(name: "{{ options.name }}", targets: ["{{ options.name }}"])
],
dependencies: [
.package(url: "https://github.com/brightdigit/Prch.git", from: "1.0.0-alpha.1"),
{% for dependency in options.dependencies %}
.package(url: "https://github.com/{{ dependency.github }}.git", from: "{{ dependency.version }}"),
{% endfor %}
],
targets: [
.target(name: "{{ options.name }}", dependencies: [
.product(name: "PrchModel", package: "Prch")
{% for dependency in options.dependencies %}
"{{ dependency.name }}",
{% endfor %}
])
]
)
170 changes: 170 additions & 0 deletions Template/Swaggen/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# {{ options.name }}

This is an api generated from a OpenAPI 3.0 spec with [SwagGen](https://github.com/yonaskolb/SwagGen)

## Operation

Each operation lives under the `{{ options.name }}` namespace and within an optional tag: `{{ options.name }}(.tagName).operationId`. If an operation doesn't have an operationId one will be generated from the path and method.

Each operation has a nested `Request` and a `Response`, as well as a static `service` property

#### Service

This is the struct that contains the static information about an operation including it's id, tag, method, pre-modified path, and authorization requirements. It has a generic `ResponseType` type which maps to the `Response` type.
You shouldn't really need to interact with this service type.

#### Request

Each request is a subclass of `APIRequest` and has an `init` with a body param if it has a body, and a `options` struct for other url and path parameters. There is also a convenience init for passing parameters directly.
The `options` and `body` structs are both mutable so they can be modified before actually sending the request.

#### Response

The response is an enum of all the possible responses the request can return. it also contains getters for the `statusCode`, whether it was `successful`, and the actual decoded optional `success` response. If the operation only has one type of failure type there is also an optional `failure` type.

## Model
Models that are sent and returned from the API are {% if options.mutableModels %}mutable{% else %}immutable{% endif %} classes. Each model is `Equatable` and `Codable`.

`Required` properties are non optional and non-required are optional

All properties can be passed into the initializer, with `required` properties being mandatory.

If a model has `additionalProperties` it will have a subscript to access these by string

## APIClient
The `APIClient` is used to encode, authorize, send, monitor, and decode the requests. There is a `APIClient.default` that uses the default `baseURL` otherwise a custom one can be initialized:

```swift
public init(baseURL: String, sessionManager: SessionManager = .default, defaultHeaders: [String: String] = [:], behaviours: [RequestBehaviour] = [])
```

#### APIClient properties

- `baseURL`: The base url that every request `path` will be appended to
- `behaviours`: A list of [Request Behaviours](#requestbehaviour) to add to every request
- `sessionManager`: An `Alamofire.SessionManager` that can be customized
- `defaultHeaders`: Headers that will be applied to every request
- `decodingQueue`: The `DispatchQueue` to decode responses on

#### Making a request
To make a request first initialize a [Request](#request) and then pass it to `makeRequest`. The `complete` closure will be called with an `APIResponse`

```swift
func makeRequest<T>(_ request: APIRequest<T>, behaviours: [RequestBehaviour] = [], queue: DispatchQueue = DispatchQueue.main, complete: @escaping (APIResponse<T>) -> Void) -> Request? {
```

Example request (that is not neccessarily in this api):

```swift

let getUserRequest = {{ options.name }}.User.GetUser.Request(id: 123)
let apiClient = APIClient.default

apiClient.makeRequest(getUserRequest) { apiResponse in
switch apiResponse {
case .result(let apiResponseValue):
if let user = apiResponseValue.success {
print("GetUser returned user \(user)")
} else {
print("GetUser returned \(apiResponseValue)")
}
case .error(let apiError):
print("GetUser failed with \(apiError)")
}
}
```

Each [Request](#request) also has a `makeRequest` convenience function that uses `{{ options.name }}.default`.

#### APIResponse
The `APIResponse` that gets passed to the completion closure contains the following properties:

- `request`: The original request
- `result`: A `Result` type either containing an `APIClientError` or the [Response](#response) of the request
- `urlRequest`: The `URLRequest` used to send the request
- `urlResponse`: The `HTTPURLResponse` that was returned by the request
- `data`: The `Data` returned by the request.
- `timeline`: The `Alamofire.Timeline` of the request which contains timing information.

#### Encoding and Decoding
Only JSON requests and responses are supported. These are encoded and decoded by `JSONEncoder` and `JSONDecoder` respectively, using Swift's `Codable` apis.
There are some options to control how invalid JSON is handled when decoding and these are available as static properties on `{{ options.name }}`:

- `safeOptionalDecoding`: Whether to discard any errors when decoding optional properties. Defaults to `true`.
- `safeArrayDecoding`: Whether to remove invalid elements instead of throwing when decoding arrays. Defaults to `true`.

Dates are encoded and decoded differently according to the swagger date format. They use different `DateFormatter`'s that you can set.
- `date-time`
- `DateTime.dateEncodingFormatter`: defaults to `yyyy-MM-dd'T'HH:mm:ss.Z`
- `DateTime.dateDecodingFormatters`: an array of date formatters. The first one to decode successfully will be used
- `date`
- `DateDay.dateFormatter`: defaults to `yyyy-MM-dd`

#### APIClientError
This is error enum that `APIResponse.result` may contain:

```swift
public enum APIClientError: Error {
case unexpectedStatusCode(statusCode: Int, data: Data)
case decodingError(DecodingError)
case requestEncodingError(String)
case validationError(String)
case networkError(Error)
case unknownError(Error)
}
```

#### RequestBehaviour
Request behaviours are used to modify, authorize, monitor or respond to requests. They can be added to the `APIClient.behaviours` for all requests, or they can passed into `makeRequest` for just that single request.

`RequestBehaviour` is a protocol you can conform to with each function being optional. As the behaviours must work across multiple different request types, they only have access to a typed erased `AnyRequest`.

```swift
public protocol RequestBehaviour {

/// runs first and allows the requests to be modified. If modifying asynchronously use validate
func modifyRequest(request: AnyRequest, urlRequest: URLRequest) -> URLRequest

/// validates and modifies the request. complete must be called with either .success or .fail
func validate(request: AnyRequest, urlRequest: URLRequest, complete: @escaping (RequestValidationResult) -> Void)

/// called before request is sent
func beforeSend(request: AnyRequest)

/// called when request successfuly returns a 200 range response
func onSuccess(request: AnyRequest, result: Any)

/// called when request fails with an error. This will not be called if the request returns a known response even if the a status code is out of the 200 range
func onFailure(request: AnyRequest, error: APIClientError)

/// called if the request recieves a network response. This is not called if request fails validation or encoding
func onResponse(request: AnyRequest, response: AnyResponse)
}
```

### Authorization
Each request has an optional `securityRequirement`. You can create a `RequestBehaviour` that checks this requirement and adds some form of authorization (usually via headers) in `validate` or `modifyRequest`. An alternative way is to set the `APIClient.defaultHeaders` which applies to all requests.

#### Reactive and Promises
To add support for a specific asynchronous library, just add an extension on `APIClient` and add a function that wraps the `makeRequest` function and converts from a closure based syntax to returning the object of choice (stream, future...ect)

## Models

{% for model in schemas %}
- **{{ model.type }}**
{% endfor %}

## Requests

{% for operationTag in operationsByTag %}
{% if operationTag.name != "" %}
- **{{ options.name }}.{{ options.tagPrefix }}{{ operationTag.name|upperCamelCase }}{{ options.tagSuffix }}**
{% for operation in operationTag.operations %}
- **{{ operation.type }}**: {{ operation.method }} `{{ operation.path | lowercase }}`
{% endfor %}
{% else %}
{% for operation in operationTag.operations %}
- **{{ options.name }}.{{ operation.type }}**: {{ operation.method }} `{{ operation.path | lowercase }}`
{% endfor %}
{% endif %}
{% endfor %}
66 changes: 66 additions & 0 deletions Template/Swaggen/Sources/API.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
{% include "Includes/Header.stencil" %}

import Foundation
import PrchModel

{% if options.modelProtocol %}
public protocol {{ options.modelProtocol }}: Codable, Equatable {}
{% endif %}

{% for type, typealias in options.typeAliases %}
public typealias {{ type }} = {{ typealias }}
{% endfor %}

{% for tag in tags %}
public enum {{ options.tagPrefix }}{{ tag | upperCamelCase }} {{ options.tagSuffix }} {}
{% endfor %}

public class {{ options.name }}API: API {
public init(baseURLComponents: URLComponents) {
self.baseURLComponents = baseURLComponents
}

public let baseURLComponents: URLComponents

public var headers: [String: String] = [:]

public var encoder: any Encoder<Data> {
Defaults.encoder
}

public var decoder: any Decoder<Data> {
Defaults.decoder
}

public typealias RequestDataType = Data

public typealias ResponseDataType = Data


{% if servers %}

public enum Server {
{% for server in servers %}

{% if server.description %}
/** {{ server.description }} **/
{% endif %}
{% if server.variables %}
public static func {{ server.name }}({% for variable in server.variables %}{{ variable.name }}: String = "{{ variable.defaultValue }}"{% ifnot forloop.last %}, {% endif %}{% endfor %}) -> String {
var url = "{{ server.url }}"
{% for variable in server.variables %}
url = url.replacingOccurrences(of: {{'"{'}}{{variable.name}}{{'}"'}}, with: {{variable.name}})
{% endfor %}
return url
}
{% else %}
public static let {{ server.name }} = "{{ server.url }}"
{% endif %}
{% endfor %}
}
{% else %}

// No servers defined in swagger. Documentation for adding them: https://swagger.io/specification/#schema
{% endif %}

}
Loading

0 comments on commit 2a6dd91

Please sign in to comment.