diff --git a/Package.swift b/Package.swift index 3f32c3a..c0caed9 100644 --- a/Package.swift +++ b/Package.swift @@ -7,11 +7,14 @@ let package = Package( name: "Swiftgger", products: [ .library(name: "Swiftgger",targets: ["Swiftgger"]), + .executable(name: "swiftggerapp", targets: ["SwiftggerApp"]) ], dependencies: [ + .package(url: "https://github.com/Flight-School/AnyCodable", from: "0.4.0") ], targets: [ - .target(name: "Swiftgger", dependencies: []), + .target(name: "Swiftgger", dependencies: ["AnyCodable"]), + .target(name: "SwiftggerApp", dependencies: ["Swiftgger"]), .testTarget(name: "SwiftggerTests", dependencies: ["Swiftgger"], resources: [ diff --git a/README.md b/README.md index c1122af..9dd0389 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Swift Package Manager](https://img.shields.io/badge/SPM-compatible-4BC51D.svg?style=flat)](https://swift.org/package-manager/) [![Platforms macOS | Linux](https://img.shields.io/badge/Platforms-macOS%20%7C%20Linux%20-lightgray.svg?style=flat)](https://developer.apple.com/swift/) -Swiftgger is simple library which generate output compatible with [OpenAPI version 3.0.1](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#securitySchemeObject). Library is generating objects tree which you can serialize to JSON and return by your API endpoint. URL to that endpoint can be used in [Swagger UI](https://swagger.io/swagger-ui/). Thanks to this you have GUI which shows you exactly how your API looks like and you can use that GUI to execute actions (requests). It's especially helpful during API testing. +Swiftgger is simple library which generate output compatible with [OpenAPI version 3.0.1](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.mdt). Library is generating objects tree which you can serialize to JSON and return by your API endpoint. URL to that endpoint can be used in [Swagger UI](https://swagger.io/swagger-ui/). Thanks to this you have GUI which shows you exactly how your API looks like and you can use that GUI to execute actions (requests). It's especially helpful during API testing. ![swagger](Images/screen-02.png) diff --git a/Sources/Swiftgger/Builder/OpenAPISchemasBuilder.swift b/Sources/Swiftgger/Builder/OpenAPISchemasBuilder.swift index 7b65b02..a2a5daa 100644 --- a/Sources/Swiftgger/Builder/OpenAPISchemasBuilder.swift +++ b/Sources/Swiftgger/Builder/OpenAPISchemasBuilder.swift @@ -6,6 +6,7 @@ // import Foundation +import AnyCodable /// Builder for object information stored in `components/schemas` part of OpenAPI. class OpenAPISchemasBuilder { @@ -48,15 +49,34 @@ class OpenAPISchemasBuilder { var array: [(name: String, type: OpenAPIObjectProperty)] = [] for property in properties { + // Simple value type (also unwrapped optionals). let unwrapped = unwrap(property.value) if let dataType = makeAPIDataType(fromSwiftValue: unwrapped) { - self.appendProperties(property: property, unwrapped: unwrapped, dataType: dataType, array: &array) - } else { - if let items = property.value as? Array { - self.appendItems(property: property, items: items, array: &array) - } else { - self.appendReference(property: property, array: &array) - } + self.append(dataType: dataType, property: property, unwrapped: unwrapped, array: &array) + continue + } + + // Non optional or initialized array (array which contains any data). + if let items = property.value as? Array { + self.append(items: items, property: property, array: &array) + continue + } + + // Non optional or initialized object. + if self.isInitialized(object: unwrapped) { + self.append(reference: property, array: &array) + continue + } + + // Optional and not initialized object. + if let typeName = self.getTypeName(from: unwrapped) { + self.append(typeName: typeName, property: property, array: &array) + continue + } + + // Optional and not initialized arrays. + if let typeName = self.getArrayTypeName(from: unwrapped) { + self.append(arrayName: typeName, property: property, array: &array) } } @@ -89,19 +109,20 @@ class OpenAPISchemasBuilder { } } - private func appendProperties(property: Mirror.Child, - unwrapped: Any, - dataType: APIDataType, - array: inout [(name: String, type: OpenAPIObjectProperty)]) { - let example = String(describing: unwrapped) + private func append(dataType: APIDataType, + property: Mirror.Child, + unwrapped: Any, + array: inout [(name: String, type: OpenAPIObjectProperty)] + ) { + let example = AnyCodable(unwrapped) let objectProperty = OpenAPIObjectProperty(type: dataType.type, format: dataType.format, example: example) array.append((name: property.label ?? "", type: objectProperty)) } - private func appendItems(property: Mirror.Child, - items: Array, - array: inout [(name: String, type: OpenAPIObjectProperty)]) { - + private func append(items: Array, + property: Mirror.Child, + array: inout [(name: String, type: OpenAPIObjectProperty)] + ) { guard let item = items.first else { return } @@ -114,15 +135,34 @@ class OpenAPISchemasBuilder { self.nestedObjects.append(item) } - private func appendReference(property: Mirror.Child, - array: inout [(name: String, type: OpenAPIObjectProperty)]) { - let unwrapped = unwrap(property.value) + private func append(reference: Mirror.Child, + array: inout [(name: String, type: OpenAPIObjectProperty)] + ) { + let unwrapped = unwrap(reference.value) let typeName = String(describing: type(of: unwrapped)) let objectProperty = OpenAPIObjectProperty(ref: "#/components/schemas/\(typeName)") - array.append((name: property.label ?? "", type: objectProperty)) + array.append((name: reference.label ?? "", type: objectProperty)) self.nestedObjects.append(unwrapped) } + + private func append(typeName: String, + property: Mirror.Child, + array: inout [(name: String, type: OpenAPIObjectProperty)] + ) { + + let objectProperty = OpenAPIObjectProperty(ref: "#/components/schemas/\(typeName)") + array.append((name: property.label ?? "", type: objectProperty)) + } + + private func append(arrayName: String, + property: Mirror.Child, + array: inout [(name: String, type: OpenAPIObjectProperty)] + ) { + let openApiSchema = OpenAPISchema(ref: "#/components/schemas/\(arrayName)") + let objectProperty = OpenAPIObjectProperty(items: openApiSchema) + array.append((name: property.label ?? "", type: objectProperty)) + } private func getRequiredProperties(properties: Mirror.Children) -> [String] { var array: [String] = [] @@ -142,6 +182,7 @@ class OpenAPISchemasBuilder { guard mirror.displayStyle == .optional, let first = mirror.children.first else { return any } + return first.value } @@ -149,4 +190,42 @@ class OpenAPISchemasBuilder { let mirror = Mirror(reflecting: any) return mirror.displayStyle == .optional } + + private func isInitialized(object any: T) -> Bool { + let mirror = Mirror(reflecting: any) + + return mirror.displayStyle == .struct + || mirror.displayStyle == .class + || mirror.displayStyle == .enum + } + + private func getTypeName(from any: Any) -> String? { + let typeName = String(describing: type(of: any)) + + let pattern = "^Optional<(?\\w+)>$" + return self.match(pattern: pattern, in: typeName) + } + + private func getArrayTypeName(from any: Any) -> String? { + let typeName = String(describing: type(of: any)) + + let pattern = "^Optional\\w+)>>$" + return self.match(pattern: pattern, in: typeName) + } + + private func match(pattern: String, in text: String) -> String? { + let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) + + if let match = regex?.firstMatch(in: text, options: [], range: NSRange(location: 0, length: text.utf8.count)) { + guard match.numberOfRanges == 2 else { + return nil + } + + if let typeRange = Range(match.range(at: 1), in: text) { + return String(text[typeRange]) + } + } + + return nil + } } diff --git a/Sources/Swiftgger/OpenAPIModel/OpenAPIObjectProperty.swift b/Sources/Swiftgger/OpenAPIModel/OpenAPIObjectProperty.swift index 122aedd..d626869 100644 --- a/Sources/Swiftgger/OpenAPIModel/OpenAPIObjectProperty.swift +++ b/Sources/Swiftgger/OpenAPIModel/OpenAPIObjectProperty.swift @@ -6,6 +6,7 @@ // import Foundation +import AnyCodable /// Information about property which exists in schema (input/output) data. public class OpenAPIObjectProperty: Codable { @@ -13,7 +14,7 @@ public class OpenAPIObjectProperty: Codable { public private(set) var ref: String? public private(set) var type: String? public private(set) var format: String? - public private(set) var example: String? + public private(set) var example: AnyCodable? public private(set) var items: OpenAPISchema? init(ref: String) { @@ -24,7 +25,7 @@ public class OpenAPIObjectProperty: Codable { self.items = items } - init(type: String, format: String?, example: String?) { + init(type: String, format: String?, example: AnyCodable?) { self.type = type self.format = format self.example = example diff --git a/Sources/SwiftggerApp/Models/Fuel.swift b/Sources/SwiftggerApp/Models/Fuel.swift new file mode 100644 index 0000000..68fa1b6 --- /dev/null +++ b/Sources/SwiftggerApp/Models/Fuel.swift @@ -0,0 +1,19 @@ +// +// Fuel.swift +// SwiftggerApp +// +// Created by Marcin Czachurski on 21/10/2021. +// Copyright © 2021 Marcin Czachurski. All rights reserved. +// + +import Foundation + +struct Fuel { + var level: Int + var type: String + + init(level: Int, type: String) { + self.level = level + self.type = type + } +} diff --git a/Sources/SwiftggerApp/Models/Vehicle.swift b/Sources/SwiftggerApp/Models/Vehicle.swift new file mode 100644 index 0000000..42d2659 --- /dev/null +++ b/Sources/SwiftggerApp/Models/Vehicle.swift @@ -0,0 +1,21 @@ +// +// Vehicle.swift +// SwiftggerApp +// +// Created by Marcin Czachurski on 21/10/2021. +// Copyright © 2021 Marcin Czachurski. All rights reserved. +// + +import Foundation + +class Vehicle { + var name: String + var age: Int? + var fuels: [Fuel]? + + init(name: String, age: Int, fuels: [Fuel]? = nil) { + self.name = name + self.age = age + self.fuels = fuels + } +} diff --git a/Sources/SwiftggerApp/Program.swift b/Sources/SwiftggerApp/Program.swift new file mode 100644 index 0000000..7cdb582 --- /dev/null +++ b/Sources/SwiftggerApp/Program.swift @@ -0,0 +1,115 @@ +// +// Program.swift +// SwiftggerApp +// +// Created by Marcin Czachurski on 21/10/2021. +// Copyright © 2021 Marcin Czachurski. All rights reserved. +// + +import Foundation +import Swiftgger + +class Program { + func run() -> Bool { + let openAPIBuilder = OpenAPIBuilder( + title: "Swiftgger OpenAPI document", + version: "1.0.0", + description: "OpenAPI documentation for test structure purposes", + authorizations: [ + .basic(description: "Basic authorization"), + .apiKey(description: "Api key authorization"), + .jwt(description: "JWT authorization"), + .oauth2(description: "OAuth authorization", flows: [ + .implicit(APIAuthorizationFlow(authorizationUrl: "https://oauth2.com", tokenUrl: "https://oauth2.com/token", scopes: [:])) + ]), + .openId(description: "OpenIdConnect authorization", openIdConnectUrl: "https//opeind.com") + ] + ) + .add([ + APIObject(object: Vehicle(name: "Ford", age: 21)), + APIObject(object: Fuel(level: 90, type: "GAS")) + ]) + .add(APIController(name: "VehiclesController", description: "Contoller for vehicles", actions: [ + APIAction(method: .get, + route: "/vehicles", + summary: "GET action for downloading list of vehicles.", + description: "List of vehicles", + responses: [ + APIResponse(code: "200", description: "List of vehicles", array: Vehicle.self, contentType: "application/json"), + APIResponse(code: "401", description: "Unauthorized") + ] + ), + APIAction(method: .get, + route: "/vehicles/{id}", + summary: "GET action for downloading specific vehicle.", + description: "Single vehicle", + parameters: [ + APIParameter(name: "id") + ], + responses: [ + APIResponse(code: "200", description: "List of vehicles", object: Vehicle.self, contentType: "application/json"), + APIResponse(code: "401", description: "Unauthorized"), + APIResponse(code: "403", description: "Forbidden"), + APIResponse(code: "404", description: "NotFound") + ] + ), + APIAction(method: .post, + route: "/vehicles", + summary: "POST action for creating new vehicle.", + description: "New vehicle", + request: APIRequest(object: Vehicle.self, description: "New vehicle", contentType: "application/json"), + responses: [ + APIResponse(code: "201", description: "Created vehicles", object: Vehicle.self, contentType: "application/json"), + APIResponse(code: "401", description: "Unauthorized"), + APIResponse(code: "403", description: "Forbidden") + ] + ), + APIAction(method: .put, + route: "/vehicles/{id}", + summary: "PUT action for updating existing vehicle.", + description: "Update vehicle", + parameters: [ + APIParameter(name: "id") + ], + request: APIRequest(object: Vehicle.self, description: "New vehicle", contentType: "application/json"), + responses: [ + APIResponse(code: "200", description: "Updated vehicles", object: Vehicle.self, contentType: "application/json"), + APIResponse(code: "401", description: "Unauthorized"), + APIResponse(code: "403", description: "Forbidden"), + APIResponse(code: "404", description: "NotFound") + ] + ), + APIAction(method: .delete, + route: "/vehicles/{id}", + summary: "DELETE action for deleting specific vehicle.", + description: "Delete vehicle", + parameters: [ + APIParameter(name: "id") + ], + responses: [ + APIResponse(code: "200", description: "Success"), + APIResponse(code: "401", description: "Unauthorized"), + APIResponse(code: "403", description: "Forbidden"), + APIResponse(code: "404", description: "NotFound") + ], + authorization: true + ) + ])) + + let openAPIDocument = openAPIBuilder.built() + + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + + let jsonData = try! encoder.encode(openAPIDocument) + let jsonOptionalString = String(bytes: jsonData, encoding: .utf8) + + guard let jsonString = jsonOptionalString else { + print("Error during converting object into string") + return false + } + + print(jsonString) + return true + } +} diff --git a/Sources/SwiftggerApp/main.swift b/Sources/SwiftggerApp/main.swift new file mode 100644 index 0000000..213de03 --- /dev/null +++ b/Sources/SwiftggerApp/main.swift @@ -0,0 +1,15 @@ +// +// main.swift +// SwiftggerApp +// +// Created by Marcin Czachurski on 21/10/2021. +// Copyright © 2021 Marcin Czachurski. All rights reserved. +// + +import Foundation +import Swiftgger + +let program = Program() +let result = program.run() + +exit(result ? EXIT_SUCCESS : EXIT_FAILURE) diff --git a/Tests/SwiftggerTests/OpenAPISchemasBuilderTests.swift b/Tests/SwiftggerTests/OpenAPISchemasBuilderTests.swift index ff24ad2..368f947 100644 --- a/Tests/SwiftggerTests/OpenAPISchemasBuilderTests.swift +++ b/Tests/SwiftggerTests/OpenAPISchemasBuilderTests.swift @@ -44,7 +44,7 @@ struct Spaceship { class Alien { var spaceship: Spaceship - + init(spaceship: Spaceship) { self.spaceship = spaceship } @@ -167,7 +167,7 @@ class OpenAPISchemasBuilderTests: XCTestCase { XCTAssertNotNil(openAPIDocument.components?.schemas?["Vehicle"]?.properties?["age"], "Integer property not exists in schema") XCTAssertEqual("integer", openAPIDocument.components?.schemas?["Vehicle"]?.properties?["age"]?.type) XCTAssertEqual("int64", openAPIDocument.components?.schemas?["Vehicle"]?.properties?["age"]?.format) - XCTAssertEqual("21", openAPIDocument.components?.schemas?["Vehicle"]?.properties?["age"]?.example) + XCTAssertEqual(21, openAPIDocument.components?.schemas?["Vehicle"]?.properties?["age"]?.example) } func testSchemaRequiredFieldsShouldBeTranslatedToOpenAPIDocument() { @@ -229,7 +229,7 @@ class OpenAPISchemasBuilderTests: XCTestCase { XCTAssertEqual("Star Trek", openAPIDocument.components?.schemas?["Spaceship"]?.properties?["name"]?.example) XCTAssertEqual("number", openAPIDocument.components?.schemas?["Spaceship"]?.properties?["speed"]?.type) XCTAssertEqual("double", openAPIDocument.components?.schemas?["Spaceship"]?.properties?["speed"]?.format) - XCTAssertEqual("923211.0", openAPIDocument.components?.schemas?["Spaceship"]?.properties?["speed"]?.example) + XCTAssertEqual(923211.0, openAPIDocument.components?.schemas?["Spaceship"]?.properties?["speed"]?.example) } func testSchemaWithCustomNameShouldHaveCorrectNameinOenAPIDocument() { @@ -311,8 +311,46 @@ class OpenAPISchemasBuilderTests: XCTestCase { version: "1.0.0", description: "Description" ).add([ - APIObject(object: User(vehicles: [Vehicle(name: "Star Trek", age: 3)])), - APIObject(object: Vehicle(name: "Star Trek", age: 3, fuels: [Fuel(level: 80, type: "GAS")])) + APIObject(object: Vehicle(name: "Star Trek", age: 3, fuels: [Fuel(level: 10, type: "GAS")])) + ]) + + // Act. + let openAPIDocument = openAPIBuilder.built() + + // Assert. + XCTAssertNotNil(openAPIDocument.components?.schemas?["Fuel"], "Fuel schema not exists") + XCTAssertEqual("#/components/schemas/Fuel", openAPIDocument.components?.schemas?["Vehicle"]?.properties?["fuels"]?.items?.ref) + } + + func testSchemaWithNonInitializedOptionalNestedObjectsShouldBeTranslatedToOpenAPIDocument() { + // Arrange. + let openAPIBuilder = OpenAPIBuilder( + title: "Title", + version: "1.0.0", + description: "Description" + ).add([ + APIObject(object: Alien(spaceship: Spaceship(name: "Star Trek", speed: 2122))), + APIObject(object: Spaceship(name: "Star Trek", speed: 2122)), + APIObject(object: Fuel(level: 90, type: "E95")) + ]) + + // Act. + let openAPIDocument = openAPIBuilder.built() + + // Assert. + XCTAssertNotNil(openAPIDocument.components?.schemas?["Fuel"], "Fuel schema not exists") + XCTAssertEqual("#/components/schemas/Fuel", openAPIDocument.components?.schemas?["Spaceship"]?.properties?["fuel"]?.ref) + } + + func testSchemaWithNonInitializedOptionalNestedArrayOfObjectsShouldBeTranslatedToOpenAPIDocument() { + // Arrange. + let openAPIBuilder = OpenAPIBuilder( + title: "Title", + version: "1.0.0", + description: "Description" + ).add([ + APIObject(object: Vehicle(name: "Star Trek", age: 3)), + APIObject(object: Fuel(level: 10, type: "GAS")) ]) // Act.