Codable implementation of OpenAPI Spec v3.x
- Proposal: SSWG-0011
- Authors: Matt Polzin
- Sponsors: TBD
- Review Manager: Tanner Nelson
- Status: Implemented
- Pitch: OpenAPIKit
- Implementation: mattpolzin/OpenAPIKit
Package Description
A library containing Swift types that encode to- and decode from OpenAPI Documents and their components.
Package name | OpenAPIKit |
Proposed Maturity Level | Sandbox |
License | MIT |
Dependencies |
|
Test-only Dependencies | Yams (MIT), FineJSON (MIT) |
Introduction
OpenAPIKit provides types that parse and serialize OpenAPI documentation using Swift's Codable
protocols. It aims to stay structurally close enough to the Spec to be easy to understand given familiarity with OpenAPI, but it also takes advantage of Swift's type system to provide a "Swifty" interface to an OpenAPI AST and it additionally enforces the OpenAPI spec by making illegal things largely impossible to represent. The Project Status can be viewed as a glossary of OpenAPI terminology that references the corresponding OpenAPIKit types.
Motivation
OpenAPI is a broadly used specification for writing API documentation. OpenAPI documents can be used to generate interactive documentation , automate testing, generate code , or just provide a solid source of truth and a contract between a client and a server.
As linked above, a lot of great tooling already exists around the OpenAPI specification. The aforementioned code generator even supports Swift with improvements being actively discussed.
OpenAPIKit fits into the existing ecosystem as a relatively low level library, with the intention of supporting other libraries and tools on top of it. It currently captures nearly all of the specification in Swift Codable
types. Thanks to Swift's type system, OpenAPIKit validates OpenAPI documentation simply by decoding it and it guarantees that OpenAPI documentation it encodes meets the spec as well.
In short, OpenAPIKit is a foundation for any Swift code that aims to read or write OpenAPI documentation. My hope is that this spec implementation saves time for others interested in writing tooling or frameworks with a higher level of abstraction.
There is a more compelling answer to "why?" though: Any Swift developer can harness the substantial power of an OpenAPI document without leaving the comfort and familiarity of the Swift language. This is the benefit I personally take away from it on a regular basis, having authored a handful of tools and libraries using OpenAPIKit already. With the parsing, validating, and serializing out of the way, someone authoring a library or implementing a dev tool or integrating OpenAPI into their SAAS app can jump straight to working with the AST.
Proposed Solution
Codable
OpenAPIKit takes advantage of Codable
at every turn to create a single implementation that easily parses from- and serializes to JSON or YAML and takes advantage of independent advances by any Encoder
s or Decoder
s available.
AST with a Swifty Interface
"Swifty" is an admittedly vague descriptor. OpenAPIKit uses struct
s and enum
s to build out a nested structure of types that both encourages discoverability and ensures validity.
One of the primary goals was to produce a structure that was as easy to use declaratively as it was to traverse post-creation. OpenAPIKit almost reads like YAML (almost).
Writing OpenAPI documentation in Swift
// OpenAPI Info Object
// https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#info-object
let info = OpenAPI.Document.Info(title: "Demo API", version: "1.0")
// OpenAPI Server Object
// https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#server-object
let server = OpenAPI.Server(url: URL(string: "https://demo.server.com")!)
// OpenAPI Components Object
// https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#components-object
let components = OpenAPI.Components(
schemas: [
"hello_string": .string(allowedValues: "hello")
]
)
// OpenAPI Response Object
// https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#response-object
let successfulHelloResponse = OpenAPI.Response(
description: "Hello",
content: [
.txt: .init(schemaReference: .component(named: "hello_string"))
]
)
// OpenAPI Document Object (and several other nested types)
// https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#openapi-object
let _ = OpenAPI.Document(
info: info,
servers: [server],
paths: [
"/hello": .init(
get: OpenAPI.PathItem.Operation(
tags: ["Greetings"],
summary: "Get a greeting",
description: "An endpoint that says hello to you.",
responses: [
200: .init(successfulHelloResponse)
]
)
)
],
components: components
)
More examples can be found in the ease of use tests.
Traversing OpenAPI documentation in Swift
let data = ...
let doc = try decoder.decode(OpenAPI.Document.self, from: data)
// Loop over defined routes and print the path and operations defined for each path.
for (route, pathDefinition) in doc.paths {
print(route.rawValue)
// map GET, PUT, POST, PATCH, etc. to the operations defined for each
let operations = OpenAPI.HttpVerb.allCases.compactMap { verb in
pathDefinition.for(verb).map { (verb, $0) }
}
for (verb, operation) in operations {
print("\(verb) -> \(operation.summary ?? "unknown description")")
}
}
// Results similar to:
// ------------------
// test/api/endpoint/{param}
// get -> Get Test
// post -> Post Test
JSON References [Section updated 03-15-2020 for v0.24.0]
OpenAPI supports the use of JSON References in many locations. Anywhere this is allowed, OpenAPIKit exposes an Either
property with one option being a reference and the other option being to directly specify the component in question. In many cases, Either
has been extended with convenience static constructors allowing you to create either the reference or the component in an ergonomic and concise way.
As an example, OpenAPI.Response.Map
(named the Responses Object in the Spec) maps response status codes to either responses or references to responses. The following code defines a status 200
response inline and then refers to a status 404
response that lives at #/components/responses/notFound
.
let responses: OpenAPI.Response.Map = [
200: .response(
description: "Success!",
content: [
.txt: .init(schema: .string)
]
),
404: .response(reference: try components.reference(named: "notFound", ofType: OpenAPI.Response.self))
]
Improvements to support for references are on the roadmap (#17). Currently, references to components living within the Components Object can be created or used to retrieve components.
let components: OpenAPI.Components = .init(
schemas: [
"example": .string
]
)
// creating references
let schemaReference: JSONReference<JSONSchema>
schemaReference = try components.reference(named: "example", ofType: JSONSchema.self)
// using references to grab components
let schema = components[schemaReference]
Validation through type-safety
The vast majority of the OpenAPI specification can be represented in the Swift type system. This means that we can create structs
and enums
that are incapable of representing ill-formed OpenAPI documentation. This means the structure of OpenAPIKit diverges slightly from that of the OpenAPI specification as documented, but the diversions are generally small and documented (currently part of the "glossary" found in the Project Status).
Human-readable Errors
Swift.DecodingError
packs a lot of information, but it is not always easy to print that information in a very human-readable way. Additionally, in the context of a specification like OpenAPI we can sometimes offer error text that does a better job of pointing to a solution instead of just calling out a generic problem.
Although there's always room for improvement, I have done some work to set OpenAPIKit up for legible error messages.
When you catch an error coming out of OpenAPIKit, you can create an OpenAPI.Error
from it. The reason OpenAPIKit does not just produce an OpenAPI.Error
in the first place is because when you ask a Decoder
to decode something it is going to wrap the result in a DecodingError
anyway requiring someone to do the work of unwrapping it and that is what OpenAPI.Error
is all about.
Example use:
let openAPIDoc: OpenAPI.Document
do {
try openAPIDoc = JSONDecoder().decode(OpenAPI.Document.self, from: ...)
} catch {
let prettyError = OpenAPI.Error(from: error)
print(prettyError.localizedDescription)
print(prettyError.codingPathString)
}
One important note is that the error is not actually localized at the moment. I see this as an area for improvement because these error messages are otherwise a great fit for passing on to the end user of whatever tool is failing to parse the OpenAPI document.
You can see some examples of the error text produced here.
Dependencies
OpenAPIKit currently has a number of dependencies I will mention here because all of them could be brought into the library to reduce the size of the dependency graph but it is worth discussing their utility in the first place and then deciding if the dependency graph is unwieldy as-is.
[2020-03-20] Moved dependencies into OpenAPIKit. I will leave the following sections in place nevertheless, as descriptions of- and justifications for the types.
Library
Poly OpenAPIKit.Either
Poly is an alternative to full type-erasure that I use on a regular basis. It can represent one of a number of types. It can erase the type with its .value
accessor, but you can also write a switch statement over the possible types and it will decode any of its types with a fallback strategy and retain information on each decoding failure (not just tell you "none of the types were decoded").
All of that said, it is a very lightweight library and in fact OpenAPIKit only needs a subset of its functionality at that. OpenAPIKit uses Poly for its Poly2
type (referred to by its more intuitive typealias Either
).
The OpenAPI Spec often defines things as one of two options and because encoding, decoding, and error handling logic are always going to be the same for these situations, an Either
type made a lot of sense.
I would be willing to re-implement (mostly copy) support for Either
from Poly into OpenAPIKit if removing Poly as a dependency is seen as more beneficial than reducing the footprint of the OpenAPIKit codebase.
OrderedDictionary OpenAPIKit.OrderedDictionary
The OrderedDictionary library just offers up one type and I bet you can guess what that is. Ordering of hashes is important to OpenAPIKit for two reasons:
- Users of OpenAPI may attribute meaning to the ordering of things like Path Items.
- When fed into a UI like Redoc or SwaggerUI, the output of OpenAPIKit should produce a stable view of documentation. It is disconcerting at best and confusing/frustrating at worst if the information you read yesterday is in a different part of the documentation today for no reason other than non-determinism.
I would be willing to being OrderedDictionary
into OpenAPIKit to reduce the number of dependencies. Another alternative that I find less appealing is to refactor OpenAPIKit so it uses a new existential type for hashes and let the code integrating with OpenAPIKit decide whether to use the standard Foundation Dictionary
or a type like OrderedDictionary
.
AnyCodable OpenAPIKit.AnyCodable
OpenAPIKit uses AnyCodable anywhere that OpenAPI offers no structural restrictions on the JSON/YAML. This occurs most often where examples are allowed. Examples can be anything from a simple String
value to a large nested structure containing Dictionary
s and Array
s.
This is yet another dependency I would be willing to pull into OpenAPIKit -- I know it is not uncommon to roll-your-own support for this anyway.
Test-only
Yams and FineJSON are not used by any targets that are not test targets. FineJSON and Yams both support testing of ordered encoding/decoding. Yams is additionally used to run OpenAPIKit against third party OpenAPI Documents in the Compatibility Suite test target.
Example and Prototype Uses
Largely out of a combination of curiosity and utility at my "real job," I've developed a handful of libraries or showcases of OpenAPIKit integrations into other libraries and tools. I hope that this serves to show the breadth of applications of such a library even though this is by no means a comprehensive list of uses.
- Vapor API documentation generation (library, example app)
- JSON:API schema generation (library)
- Writing OpenAPI documentation (example)
- Scripting (tool)
- Generating "API Changes" deliverables (library/tool)
Next Steps
I am largely done adding to the surface area of the implementation for now. There are holes in what is supported, but I would like to motivate filling them with requests from the community because the remaining holes are increasingly remote corners of the specification.
I do plan to work on the following in the foreseeable future:
- Adding support for Specification Extensions to more types (#24).
- Improving support for
$ref
s (#3, #17). - Improving the flexibility of decoding (#23).
- Improving the ergonomics of using OpenAPIKit types, including adding accessors that retrieve "canonical" information on an API (suggestions appreciated on this one, see original pitch for more on this topic).
Maturity Justification
This package meets the following criteria according to the SSWG Incubation Process :
- Follows semantic versioning
- Uses SwiftPM
- Code Style is up to date
- Errors implemented
- Unit testing for Mac OS and Linux in addition to a compatibility suite of tests.
- Swift Code of Conduct
It notably does not meet the requirement of having a team of 2+ developers, although I believe that rule has been a bit flexible in the past. I do have a track record for maintaining open source libraries once they reach a level of maturity where others can begin taking advantage of them (e.g. JSONAPI, Poly).
Alternatives Considered
Existing Solutions
- SwaggerParser (OpenAPI 2.0 support)
- SwagGen (OpenAPI 3.0 parsing built-in but primarily a code gen. tool, no serializing of OpenAPI specs)
- Kitura-OpenAPI (OpenAPI 2.0 serializing support)
- Swiftgger (OpenAPI 3.0 serializing, no parsing)