diff --git a/swift-blog/Sources/Generated/AdapterFactory.swift b/swift-blog/Sources/Generated/AdapterFactory.swift index a05b354..d9cc258 100644 --- a/swift-blog/Sources/Generated/AdapterFactory.swift +++ b/swift-blog/Sources/Generated/AdapterFactory.swift @@ -12,5 +12,8 @@ public class AdapterFactory { public func getUserAdapter() throws -> UserAdapter { return UserMemoryAdapter() } + public func getPostAdapter() throws -> PostAdapter { + return PostMemoryAdapter() + } } diff --git a/swift-blog/Sources/Generated/CRUDResources.swift b/swift-blog/Sources/Generated/CRUDResources.swift index 198595a..732735e 100644 --- a/swift-blog/Sources/Generated/CRUDResources.swift +++ b/swift-blog/Sources/Generated/CRUDResources.swift @@ -4,4 +4,5 @@ import Configuration public func initializeCRUDResources(manager: ConfigurationManager, router: Router) throws { let factory = AdapterFactory(manager: manager) try UserResource(factory: factory).setupRoutes(router: router) + try PostResource(factory: factory).setupRoutes(router: router) } diff --git a/swift-blog/Sources/Generated/Post.swift b/swift-blog/Sources/Generated/Post.swift new file mode 100644 index 0000000..ea924c5 --- /dev/null +++ b/swift-blog/Sources/Generated/Post.swift @@ -0,0 +1,86 @@ +import SwiftyJSON + +public struct Post { + public let id: String? + public let title: String + public let body: String + + public init(id: String?, title: String, body: String) { + self.id = id + self.title = title + self.body = body + } + + public init(json: JSON) throws { + // Required properties + guard json["title"].exists() else { + throw ModelError.requiredPropertyMissing(name: "title") + } + guard let title = json["title"].string else { + throw ModelError.propertyTypeMismatch(name: "title", type: "string", value: json["title"].description, valueType: String(describing: json["title"].type)) + } + self.title = title + guard json["body"].exists() else { + throw ModelError.requiredPropertyMissing(name: "body") + } + guard let body = json["body"].string else { + throw ModelError.propertyTypeMismatch(name: "body", type: "string", value: json["body"].description, valueType: String(describing: json["body"].type)) + } + self.body = body + + // Optional properties + if json["id"].exists() && + json["id"].type != .string { + throw ModelError.propertyTypeMismatch(name: "id", type: "string", value: json["id"].description, valueType: String(describing: json["id"].type)) + } + self.id = json["id"].string + + // Check for extraneous properties + if let jsonProperties = json.dictionary?.keys { + let properties: [String] = ["id", "title", "body"] + for jsonPropertyName in jsonProperties { + if !properties.contains(where: { $0 == jsonPropertyName }) { + throw ModelError.extraneousProperty(name: jsonPropertyName) + } + } + } + } + + public func settingID(_ newId: String?) -> Post { + return Post(id: newId, title: title, body: body) + } + + public func updatingWith(json: JSON) throws -> Post { + if json["id"].exists() && + json["id"].type != .string { + throw ModelError.propertyTypeMismatch(name: "id", type: "string", value: json["id"].description, valueType: String(describing: json["id"].type)) + } + let id = json["id"].string ?? self.id + + if json["title"].exists() && + json["title"].type != .string { + throw ModelError.propertyTypeMismatch(name: "title", type: "string", value: json["title"].description, valueType: String(describing: json["title"].type)) + } + let title = json["title"].string ?? self.title + + if json["body"].exists() && + json["body"].type != .string { + throw ModelError.propertyTypeMismatch(name: "body", type: "string", value: json["body"].description, valueType: String(describing: json["body"].type)) + } + let body = json["body"].string ?? self.body + + return Post(id: id, title: title, body: body) + } + + public func toJSON() -> JSON { + var result = JSON([ + "title": JSON(title), + "body": JSON(body), + ]) + if let id = id { + result["id"] = JSON(id) + } + + return result + } +} diff --git a/swift-blog/Sources/Generated/PostAdapter.swift b/swift-blog/Sources/Generated/PostAdapter.swift new file mode 100644 index 0000000..1057e27 --- /dev/null +++ b/swift-blog/Sources/Generated/PostAdapter.swift @@ -0,0 +1,9 @@ +public protocol PostAdapter { + func findAll(onCompletion: @escaping ([Post], Error?) -> Void) + func create(_ model: Post, onCompletion: @escaping (Post?, Error?) -> Void) + func deleteAll(onCompletion: @escaping (Error?) -> Void) + + func findOne(_ maybeID: String?, onCompletion: @escaping (Post?, Error?) -> Void) + func update(_ maybeID: String?, with model: Post, onCompletion: @escaping (Post?, Error?) -> Void) + func delete(_ maybeID: String?, onCompletion: @escaping (Post?, Error?) -> Void) +} diff --git a/swift-blog/Sources/Generated/PostMemoryAdapter.swift b/swift-blog/Sources/Generated/PostMemoryAdapter.swift new file mode 100644 index 0000000..4314d7b --- /dev/null +++ b/swift-blog/Sources/Generated/PostMemoryAdapter.swift @@ -0,0 +1,57 @@ +import Foundation + +public class PostMemoryAdapter: PostAdapter { + var items: [String:Post] = [:] + + public func findAll(onCompletion: @escaping ([Post], Error?) -> Void) { + onCompletion(items.map { $1 }, nil) + } + + public func create(_ model: Post, onCompletion: @escaping (Post?, Error?) -> Void) { + let id = model.id ?? UUID().uuidString + // TODO: Don't overwrite if id already exists + let storedModel = model.settingID(id) + items[id] = storedModel + onCompletion(storedModel, nil) + } + + public func deleteAll(onCompletion: @escaping (Error?) -> Void) { + items.removeAll() + onCompletion(nil) + } + + public func findOne(_ maybeID: String?, onCompletion: @escaping (Post?, Error?) -> Void) { + guard let id = maybeID else { + return onCompletion(nil, AdapterError.invalidId(maybeID)) + } + guard let retrievedModel = items[id] else { + return onCompletion(nil, AdapterError.notFound(id)) + } + onCompletion(retrievedModel, nil) + } + + public func update(_ maybeID: String?, with model: Post, onCompletion: @escaping (Post?, Error?) -> Void) { + delete(maybeID) { _, error in + if let error = error { + onCompletion(nil, error) + } else { + // NOTE: delete() guarantees maybeID non-nil if error is nil + let id = maybeID! + let model = (model.id == nil) ? model.settingID(id) : model + self.create(model) { storedModel, error in + onCompletion(storedModel, error) + } + } + } + } + + public func delete(_ maybeID: String?, onCompletion: @escaping (Post?, Error?) -> Void) { + guard let id = maybeID else { + return onCompletion(nil, AdapterError.invalidId(maybeID)) + } + guard let removedModel = items.removeValue(forKey: id) else { + return onCompletion(nil, AdapterError.notFound(id)) + } + onCompletion(removedModel, nil) + } +} diff --git a/swift-blog/Sources/Generated/PostResource.swift b/swift-blog/Sources/Generated/PostResource.swift new file mode 100644 index 0000000..f02b23b --- /dev/null +++ b/swift-blog/Sources/Generated/PostResource.swift @@ -0,0 +1,221 @@ +import Kitura +import LoggerAPI +import SwiftyJSON + +public class PostResource { + private let adapter: PostAdapter + private let path = "/api/Posts" + private let pathWithID = "/api/Posts/:id" + + init(factory: AdapterFactory) throws { + adapter = try factory.getPostAdapter() + } + + func setupRoutes(router: Router) { + router.all("/*", middleware: BodyParser()) + + router.get(path, handler: handleIndex) + router.post(path, handler: handleCreate) + router.delete(path, handler: handleDeleteAll) + + router.get(pathWithID, handler: handleRead) + router.put(pathWithID, handler: handleReplace) + router.patch(pathWithID, handler: handleUpdate) + router.delete(pathWithID, handler: handleDelete) + } + + private func handleIndex(request: RouterRequest, response: RouterResponse, next: @escaping () -> Void) { + Log.debug("GET \(path)") + // TODO: offset and limit + adapter.findAll() { models, error in + if let _ = error { + // TODO: Send error object? + Log.error("InternalServerError during handleIndex: \(error)") + response.status(.internalServerError) + } else { + response.send(json: JSON(models.map { $0.toJSON() })) + } + next() + } + } + + private func handleCreate(request: RouterRequest, response: RouterResponse, next: @escaping () -> Void) { + Log.debug("POST \(path)") + guard let contentType = request.headers["Content-Type"], + contentType.hasPrefix("application/json") else { + response.status(.unsupportedMediaType) + response.send(json: JSON([ "error": "Request Content-Type must be application/json" ])) + return next() + } + guard case .json(let json)? = request.body else { + response.status(.badRequest) + response.send(json: JSON([ "error": "Request body could not be parsed as JSON" ])) + return next() + } + do { + let model = try Post(json: json) + adapter.create(model) { storedModel, error in + if let _ = error { + // TODO: Handle model errors (eg id conflict) + Log.error("InternalServerError during handleCreate: \(error)") + response.status(.internalServerError) + } else { + response.send(json: storedModel!.toJSON()) + } + next() + } + } catch let error as ModelError { + response.status(.unprocessableEntity) + response.send(json: JSON([ "error": error.defaultMessage() ])) + next() + } catch { + Log.error("InternalServerError during handleCreate: \(error)") + response.status(.internalServerError) + next() + } + } + + private func handleDeleteAll(request: RouterRequest, response: RouterResponse, next: @escaping () -> Void) { + Log.debug("DELETE \(path)") + adapter.deleteAll() { error in + if let _ = error { + response.status(.internalServerError) + } else { + let result = JSON([]) + response.send(json: result) + } + next() + } + } + + private func handleRead(request: RouterRequest, response: RouterResponse, next: @escaping () -> Void) { + Log.debug("GET \(pathWithID)") + adapter.findOne(request.parameters["id"]) { model, error in + if let error = error { + switch error { + case AdapterError.notFound: + response.status(.notFound) + default: + response.status(.internalServerError) + } + } else { + response.send(json: model!.toJSON()) + } + next() + } + } + + private func handleReplace(request: RouterRequest, response: RouterResponse, next: @escaping () -> Void) { + Log.debug("PUT \(pathWithID)") + guard let contentType = request.headers["Content-Type"], + contentType.hasPrefix("application/json") else { + response.status(.unsupportedMediaType) + response.send(json: JSON([ "error": "Request Content-Type must be application/json" ])) + return next() + } + guard case .json(let json)? = request.body else { + response.status(.badRequest) + response.send(json: JSON([ "error": "Request body could not be parsed as JSON" ])) + return next() + } + do { + let model = try Post(json: json) + adapter.update(request.parameters["id"], with: model) { storedModel, error in + if let error = error { + switch error { + case AdapterError.notFound: + response.status(.notFound) + case AdapterError.idConflict(let id): + response.status(.conflict) + response.send(json: JSON([ "error": "Cannot update id to a value that already exists (\(id))" ])) + default: + Log.error("InternalServerError during handleCreate: \(error)") + response.status(.internalServerError) + } + } else { + response.send(json: storedModel!.toJSON()) + } + next() + } + } catch let error as ModelError { + response.status(.unprocessableEntity) + response.send(json: JSON([ "error": error.defaultMessage() ])) + next() + } catch { + Log.error("InternalServerError during handleReplace: \(error)") + response.status(.internalServerError) + next() + } + } + + private func handleUpdate(request: RouterRequest, response: RouterResponse, next: @escaping () -> Void) { + Log.debug("PATCH \(pathWithID)") + guard let contentType = request.headers["Content-Type"], + contentType.hasPrefix("application/json") else { + response.status(.unsupportedMediaType) + response.send(json: JSON([ "error": "Request Content-Type must be application/json" ])) + return next() + } + guard case .json(let json)? = request.body else { + response.status(.badRequest) + response.send(json: JSON([ "error": "Request body could not be parsed as JSON" ])) + return next() + } + adapter.findOne(request.parameters["id"]) { model, error in + if let error = error { + switch error { + case AdapterError.notFound: + response.status(.notFound) + default: + response.status(.internalServerError) + } + return next() + } + do { + let updatedModel = try model!.updatingWith(json: json) + self.adapter.update(request.parameters["id"], with: updatedModel) { storedModel, error in + if let error = error { + switch error { + case AdapterError.notFound: + response.status(.notFound) + case AdapterError.idConflict(let id): + response.status(.conflict) + response.send(json: JSON([ "error": "Cannot update id to a value that already exists (\(id))" ])) + default: + Log.error("InternalServerError during handleUpdate: \(error)") + response.status(.internalServerError) + } + } else { + response.send(json: storedModel!.toJSON()) + } + next() + } + } catch let error as ModelError { + response.status(.unprocessableEntity) + response.send(json: JSON([ "error": error.defaultMessage() ])) + next() + } catch { + Log.error("InternalServerError during handleUpdate: \(error)") + response.status(.internalServerError) + next() + } + } + } + + private func handleDelete(request: RouterRequest, response: RouterResponse, next: @escaping () -> Void) { + Log.debug("DELETE \(pathWithID)") + adapter.delete(request.parameters["id"]) { model, error in + if let error = error { + switch error { + case AdapterError.notFound: + response.send(json: JSON([ "count": 0 ])) + default: + response.status(.internalServerError) + } + } else { + response.send(json: JSON([ "count": 1] )) + } + next() + } + } +} diff --git a/swift-blog/definitions/swift-blog.yaml b/swift-blog/definitions/swift-blog.yaml index a5381c5..8c1b095 100644 --- a/swift-blog/definitions/swift-blog.yaml +++ b/swift-blog/definitions/swift-blog.yaml @@ -136,6 +136,132 @@ paths: '200': description: Request was successful deprecated: false + '/Posts/{id}': + get: + tags: + - Post + summary: 'Find a model instance by {{id}}' + operationId: Post.findOne + parameters: + - name: id + in: path + description: Model id + required: true + type: string + format: JSON + responses: + '200': + description: Request was successful + schema: + $ref: '#/definitions/Post' + deprecated: false + put: + tags: + - Post + summary: Put attributes for a model instance and persist it + operationId: Post.replace + parameters: + - name: data + in: body + description: An object of model property name/value pairs + required: false + schema: + $ref: '#/definitions/Post' + - name: id + in: path + description: Model id + required: true + type: string + format: JSON + responses: + '200': + description: Request was successful + schema: + $ref: '#/definitions/Post' + deprecated: false + patch: + tags: + - Post + summary: Patch attributes for a model instance and persist it + operationId: Post.update + parameters: + - name: data + in: body + description: An object of model property name/value pairs + required: false + schema: + $ref: '#/definitions/Post' + - name: id + in: path + description: Model id + required: true + type: string + format: JSON + responses: + '200': + description: Request was successful + schema: + $ref: '#/definitions/Post' + deprecated: false + delete: + tags: + - Post + summary: 'Delete a model instance by {{id}}' + operationId: Post.delete + parameters: + - name: id + in: path + description: Model id + required: true + type: string + format: JSON + responses: + '200': + description: Request was successful + schema: + type: object + deprecated: false + /Posts: + post: + tags: + - Post + summary: Create a new instance of the model and persist it + operationId: Post.create + parameters: + - name: data + in: body + description: Model instance data + required: false + schema: + $ref: '#/definitions/Post' + responses: + '200': + description: Request was successful + schema: + $ref: '#/definitions/Post' + deprecated: false + get: + tags: + - Post + summary: Find all instances of the model + operationId: Post.findAll + responses: + '200': + description: Request was successful + schema: + type: array + items: + $ref: '#/definitions/Post' + deprecated: false + delete: + tags: + - Post + summary: Delete all instances of the model + operationId: Post.deleteAll + responses: + '200': + description: Request was successful + deprecated: false definitions: User: properties: @@ -149,3 +275,15 @@ definitions: required: - name - email + Post: + properties: + id: + type: string + title: + type: string + body: + type: string + additionalProperties: false + required: + - title + - body diff --git a/swift-blog/models/Post.json b/swift-blog/models/Post.json new file mode 100644 index 0000000..dc4554c --- /dev/null +++ b/swift-blog/models/Post.json @@ -0,0 +1,19 @@ +{ + "name": "Post", + "plural": "Posts", + "classname": "Post", + "properties": { + "id": { + "type": "string", + "id": true + }, + "title": { + "type": "string", + "required": true + }, + "body": { + "type": "string", + "required": true + } + } +}