diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2b8341f..d6f5a73 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,4 +19,4 @@ jobs: name: Build and Test uses: Apodini/.github/.github/workflows/build-and-test.yml@v1 with: - packagename: ApodiniTemplate + packagename: ApodiniDocumentExport diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 079b634..29205dc 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -17,7 +17,7 @@ jobs: name: Build and Test uses: Apodini/.github/.github/workflows/build-and-test.yml@v1 with: - packagename: ApodiniTemplate + packagename: ApodiniDocumentExport reuse_action: name: REUSE Compliance Check uses: Apodini/.github/.github/workflows/reuse.yml@v1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f407e3f..86c865c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,4 +18,4 @@ jobs: name: Generate Docs uses: Apodini/.github/.github/workflows/docs.yml@v1 with: - packagename: ApodiniTemplate + packagename: ApodiniDocumentExport diff --git a/Package.swift b/Package.swift index f6524a4..7cca396 100644 --- a/Package.swift +++ b/Package.swift @@ -1,31 +1,42 @@ // swift-tools-version:5.5 - -// -// This source file is part of the Apodini open source project -// -// SPDX-FileCopyrightText: 2021 Paul Schmiedmayer and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// +// The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription - let package = Package( - name: "ApodiniTemplate", - platforms: [ - .macOS(.v11) - ], + name: "ApodiniDocumentExport", products: [ - .library(name: "ApodiniTemplate", targets: ["ApodiniTemplate"]) + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "ApodiniDocumentExport", + targets: ["ApodiniDocumentExport"]), + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + // .package(url: /* package url */, from: "1.0.0"), + .package(url: "https://github.com/apple/swift-argument-parser", .upToNextMinor(from: "0.4.4")), + .package(url: "https://github.com/kylef/PathKit.git", from: "1.0.1"), + .package(url: "https://github.com/omochi/FineJSON.git", from: "1.14.0"), + .package(url: "https://github.com/jpsim/Yams.git", from: "4.0.0") ], targets: [ - .target(name: "ApodiniTemplate"), + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "ApodiniDocumentExport", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "PathKit", package: "PathKit"), + .product(name: "FineJSON", package: "FineJSON"), + .product(name: "Yams", package: "Yams") + ]), .testTarget( - name: "ApodiniTemplateTests", + name: "ApodiniDocumentExportTests", dependencies: [ - .target(name: "ApodiniTemplate") - ] - ) + "ApodiniDocumentExport" + ], + resources: [ + .process("document.json") + ]), ] ) diff --git a/README.md b/README.md index bddd7e9..8ffef00 100644 --- a/README.md +++ b/README.md @@ -8,36 +8,158 @@ SPDX-License-Identifier: MIT --> -## How to use this repository -### Template - -When creating a new repository, make sure to select this repository as a repository template. - -### Customize the repository - -Enter your repository-specific configuration -- Replace the "Package.swift", "Sources" and "Tests" folder with your Swift Package -- Enter the correct Swift Package name (currently "ApodiniTemplate") in the build.yml, pull_request.yml and release.yml files. -- Update the DocC documentation to reflect the name of the new Swift package and adapt the docs and build and test GitHub Actions where the documentation is generated to the updated names to be sure the DocC generation works as expected -- Update the README with your information and replace the links to the license with the new repository. -- Update the status badges to point to the GitHub actions of your repository. -- If you create a new repository in the Apodini organization, you do not need to add a personal access token named "ACCESS_TOKEN". If you create the repo outside the Apodini organization, you need to create such a token with write access to the repo for all GitHub Actions to work. You will need to give the `ApodiniBot` user write access to the repository. - -### ⬆️ Remove everything up to here ⬆️ - -# Project Name - -[![Build](https://github.com/Apodini/Template-Repository/actions/workflows/build.yml/badge.svg)](https://github.com/Apodini/Template-Repository/actions/workflows/build.yml) -[![codecov](https://codecov.io/gh/Apodini/Template-Repository/branch/develop/graph/badge.svg?token=5MMKMPO5NR)](https://codecov.io/gh/Apodini/Template-Repository) - -## Requirements - -## Installation/Setup/Integration - -## Usage +# Apodini Document Export + +[![Build](https://github.com/Apodini/ApodiniDocumentExport/actions/workflows/build.yml/badge.svg)](https://github.com/Apodini/ApodiniDocumentExport/actions/workflows/build.yml) +[![codecov](https://codecov.io/gh/Apodini/ApodiniDocumentExport/branch/develop/graph/badge.svg?token=5MMKMPO5NR)](https://codecov.io/gh/Apodini/ApodiniDocumentExport) + +Create a document to store knowledge on your Apodini web service and export it in a local directory or expose a new endpoint. + +> Tip: See `ApodiniSustainability` and `ApodiniMigration` as references to start with your implementation of this use case. + +## Getting Started + +### Dependency + +Add `ApodiniDocumentExport` product to your target dependencies in `package.swift`: +```swift +.product(name: "ApodiniDocumentExport", package: "Apodini") +``` + +### Documents + +The structure of your document is unique to your use case. You may use the `Value` protocol to require conformance to `Codable` and `Hashable`. + +```swift +/// A document that describes an Apodini Web Service +public struct Document: Value { + <#code#> +} +``` + +### Export Options + +`ExportOptions` provides a protocol to specify the document's `format` and optional `directory` and `endpoint` properties. `ApodiniDocumentExport` supports `.json` and `.yaml` format. You may use `ArgumentParser` to enable command line arguments. + +```swift +struct DocumentExportOptions: ExportOptions { + /// A path to a local directory used to export document + @Option(name: .customLong("directory"), help: "A path to a local directory to export document") + public var directory: String? + /// An endpoint path of the web service used to expose document + @Option(name: .customLong("endpoint"), help: "A path to an endpoint of the web service to expose document") + public var endpoint: String? + /// Format of the document export + /// + /// Supports `json` or `yaml` format. + /// - Note: Defaults to `json` + @Option(name: .customLong("format"), help: "Format of the document, either `json` or `yaml`") + public var format: FileFormat = .json + + /// Creates an instance of this parsable type using the definitions given by each property’s wrapper. + public init() {} +} +``` + +### Interface Exporter + +Apodini enables you to build a new ``InterfaceExporter`` to collect information on your web service and initialize a `document` instance. This implementation of ``InterfaceExporter/finishedExporting(_:)`` shows how to use `ApodiniDocumentExport` to write a document to a local directory or expose a new endpoint. + +```swift +final class DocumentInterfaceExporter: InterfaceExporter { + + private let app: Application + private let configuration: DocumentConfiguration + private let logger = Logger(label: <#String#>) + + init(_ app: Application, configuration: DocumentConfiguration) { + self.app = app + self.configuration = configuration + } + + func export(_ endpoint: Apodini.Endpoint) -> () where H : Handler { + <#code#> + } + + func export(blob endpoint: Apodini.Endpoint) -> () where H : Handler, H.Response.Content == Blob { + <#code#> + } + + func finishedExporting(_ webService: WebServiceModel) { + + app.storage.set(DocumentStorageKey.self, to: document) + + guard let options = configuration.exportOptions else { + return logger.notice("No configuration provided to handle document") + } + + if let directory = options.directory { + do { + let filePath = try document.write(at: directory, outputFormat: options.format) + logger.info("Document exported at \(filePath) in \(options.format.rawValue)") + } catch { + logger.error("Document export at \(directory) failed with error: \(error)") + } + } + + if let endpoint = options.endpoint { + app.httpServer.registerRoute(.GET, endpoint.httpPathComponents) { _ -> String in + options.format.string(of: document) + } + logger.info("Document served at \(endpoint) in \(options.format.rawValue) format") + } + } +} +``` + +### Document Configuration + +Create a ``Apodini/Configuration`` and use ``Application/registerExporter(exporter:)`` to register your ``InterfaceExporter`` implementation with the ``Application``. + +```swift +public class DocumentConfiguration: Configuration { + + let exportOptions: DocumentExportOptions? + + /// Initializer for a ``DocumentConfiguration`` instance + /// - Parameter exportOptions: Export options of the document + public init(_ exportOptions: DocumentExportOptions? = nil) { + self.exportOptions = exportOptions + } + + /// Configures `app` by registering the ``InterfaceExporter`` that handles document export + /// - Parameter app: Application instance to register the configuration in Apodini + public func configure(_ app: Application) { + app.registerExporter(exporter: DocumentInterfaceExporter(app, configuration: self)) + } +} + +public extension WebService { + /// A typealias for ``DocumentConfiguration`` + typealias Document = DocumentConfiguration +} +``` + +This example shows how to use the implementation in your Apodini web service `configuration`. + +```swift +struct HelloWorld: WebService { + + @OptionGroup + var options: DocumentExportOptions + + var configuration: Configuration { + Document(options) + } + + var content: some Component { + Greeter() + } +} +``` ## Contributing Contributions to this project are welcome. Please make sure to read the [contribution guidelines](https://github.com/Apodini/.github/blob/main/CONTRIBUTING.md) and the [contributor covenant code of conduct](https://github.com/Apodini/.github/blob/main/CODE_OF_CONDUCT.md) first. ## License -This project is licensed under the MIT License. See [Licenses](https://github.com/Apodini/Template-Repository/tree/develop/LICENSES) for more information. +This project is licensed under the MIT License. See [Licenses](https://github.com/Apodini/ApodiniDocumentExport/tree/develop/LICENSES) for more information. diff --git a/Sources/ApodiniDocumentExport.docc/ApodiniDocumentExport.md b/Sources/ApodiniDocumentExport.docc/ApodiniDocumentExport.md new file mode 100644 index 0000000..5e9c3e4 --- /dev/null +++ b/Sources/ApodiniDocumentExport.docc/ApodiniDocumentExport.md @@ -0,0 +1 @@ +# ``ApodiniDocumentExport`` diff --git a/Sources/ApodiniDocumentExport/ExportOptions.swift b/Sources/ApodiniDocumentExport/ExportOptions.swift new file mode 100644 index 0000000..f36839b --- /dev/null +++ b/Sources/ApodiniDocumentExport/ExportOptions.swift @@ -0,0 +1,54 @@ +import Foundation +import ArgumentParser + +/// A typealias for ``OutputFormat`` +public typealias FileFormat = OutputFormat + +extension OutputFormat: ExpressibleByArgument {} + +// MARK: - ExportOptions +/// A protocol that defines export options for `ApodiniMigrator` items +public protocol ExportOptions: ParsableArguments { + /// Optional directory path to export an item + var directory: String? { get set } + /// Optional endpoint path to expose an item + var endpoint: String? { get set } + /// Format of the item to be exported / exposed, either `json` or `yaml` + var format: FileFormat { get set } +} + +extension ExportOptions { + init(directory: String? = nil, endpoint: String? = nil, format: FileFormat) { + self.init() + self.directory = directory + self.endpoint = endpoint + self.format = format + } +} + +public extension ExportOptions { + /// A convenient static function for initializing an ``ExportOptions`` instance + /// - Parameters: + /// - path: A path to a local directory used to export an item + /// - format: Format of the item to be exported, either `json` or `yaml`. Defaults to `.json` + static func directory(_ path: String, format: FileFormat = .json) -> Self { + .init(directory: path, format: format) + } + + /// A convenient static function for initializing an ``ExportOptions`` instance + /// - Parameters: + /// - path: An endpoint path of the web service used to expose an item + /// - format: Format of the item to be exposed, either `json` or `yaml`. Defaults to `.json` + static func endpoint(_ path: String, format: FileFormat = .json) -> Self { + .init(endpoint: path, format: format) + } + + /// A convenient static function for initializing an ``ExportOptions`` instance + /// - Parameters: + /// - directory: A path to a local directory used to export an item + /// - endpoint: An endpoint path of the web service used to expose an item + /// - format: Format of the item to be exposed, either `json` or `yaml`. Defaults to `.json` + static func paths(directory: String, endpoint: String, format: FileFormat = .json) -> Self { + .init(directory: directory, endpoint: endpoint, format: format) + } +} diff --git a/Sources/ApodiniDocumentExport/Extensions/Decodable+Extensions.swift b/Sources/ApodiniDocumentExport/Extensions/Decodable+Extensions.swift new file mode 100644 index 0000000..3ff49dc --- /dev/null +++ b/Sources/ApodiniDocumentExport/Extensions/Decodable+Extensions.swift @@ -0,0 +1,32 @@ +import Foundation +import PathKit +@_implementationOnly import Yams + +public extension Decodable { + /// Initializes self from data + static func decode(from data: Data) throws -> Self { + try JSONDecoder().decode(Self.self, from: data) + } + + /// Initializes self from string + static func decode(from string: String) throws -> Self { + try decode(from: string.data()) + } + + /// Initializes self from the content of path + static func decode(from path: Path) throws -> Self { + guard path.is(.json) || path.is(.yaml) else { + throw DecodingError.dataCorrupted( + .init( + codingPath: [], + debugDescription: "`ApodiniMigrator` only supports decoding of files in either json or yaml format" + ) + ) + } + let data = try path.read() as Data + if path.is(.yaml) { + return try YAMLDecoder().decode(from: data) + } + return try decode(from: data) + } +} diff --git a/Sources/ApodiniDocumentExport/Extensions/Encodable+Extensions.swift b/Sources/ApodiniDocumentExport/Extensions/Encodable+Extensions.swift new file mode 100644 index 0000000..439c33b --- /dev/null +++ b/Sources/ApodiniDocumentExport/Extensions/Encodable+Extensions.swift @@ -0,0 +1,42 @@ +import Foundation +@_implementationOnly import FineJSON +@_implementationOnly import RichJSONParser +@_implementationOnly import Yams +import PathKit + +// MARK: - Encodable extensions +public extension Encodable { + /// JSON String of this encodable + var json: String { + json() + } + + /// YAML String of this encodable + var yaml: String { + (try? YAMLEncoder().encode(self)) ?? "" + } + + /// JSON String of this encodable + /// - Parameters: + /// - prettyPrinted: Pretty printed format, true by default + /// - indentation: Indentation, by default 4 + func json(prettyPrinted: Bool = true, indentation: UInt = 4) -> String { + let encoder = FineJSONEncoder() + encoder.jsonSerializeOptions = JSONSerializeOptions( + isPrettyPrint: prettyPrinted, + indentString: String(repeating: " ", count: Int(indentation)) + ) + let data = (try? encoder.encode(self)) ?? Data() + return String(decoding: data, as: UTF8.self) + } + + /// Writes self at the specified path with the defined format + @discardableResult + func write(at path: String, outputFormat: OutputFormat = .json, fileName: String? = nil) throws -> String { + let location = Path(path) + try location.mkpath() + let filePath = location + "\(fileName ?? String(describing: Self.self)).\(outputFormat.rawValue)" + try filePath.write(outputFormat.string(of: self)) + return filePath.absolute().string + } +} diff --git a/Sources/ApodiniDocumentExport/Extensions/String+Extensions.swift b/Sources/ApodiniDocumentExport/Extensions/String+Extensions.swift new file mode 100644 index 0000000..0c72821 --- /dev/null +++ b/Sources/ApodiniDocumentExport/Extensions/String+Extensions.swift @@ -0,0 +1,10 @@ +import Foundation +import PathKit + +public extension String { + + /// Returns encoded data of `self` + func data(_ encoding: Encoding = .utf8) -> Data { + data(using: encoding) ?? .init() + } +} diff --git a/Sources/ApodiniDocumentExport/FileExtension.swift b/Sources/ApodiniDocumentExport/FileExtension.swift new file mode 100644 index 0000000..1d5eeaa --- /dev/null +++ b/Sources/ApodiniDocumentExport/FileExtension.swift @@ -0,0 +1,38 @@ +import Foundation +import PathKit + +/// Represent different cases of file extensions +public enum FileExtension: CustomStringConvertible { + /// JSON + case json + /// YAML + case yaml + /// Swift + case swift + /// Other + case other(String) + + /// String representation this extension + public var description: String { + switch self { + case .json: return "json" + case .yaml: return "yaml" + case .swift: return "swift" + case let .other(value): return value + } + } +} + +public extension String { + /// Returns lhs after appending `.` and `description` of `rhs` + static func + (lhs: Self, rhs: FileExtension) -> Self { + lhs + "." + rhs.description + } +} + +public extension Path { + /// Indicates whether the path corresponds to a file with the corresponding extension + func `is`(_ fileExtension: FileExtension) -> Bool { + `extension` == fileExtension.description + } +} diff --git a/Sources/ApodiniDocumentExport/OutputFormat.swift b/Sources/ApodiniDocumentExport/OutputFormat.swift new file mode 100644 index 0000000..d343e0f --- /dev/null +++ b/Sources/ApodiniDocumentExport/OutputFormat.swift @@ -0,0 +1,17 @@ +import Foundation + +/// OutputFormat cases for encodable instances +public enum OutputFormat: String { + /// JSON output format + case json + /// YAML output format + case yaml + + /// Returns the string representation of `encodable` + public func string(of encodable: E) -> String { + switch self { + case .json: return encodable.json + case .yaml: return encodable.yaml + } + } +} diff --git a/Sources/ApodiniDocumentExport/Value.swift b/Sources/ApodiniDocumentExport/Value.swift new file mode 100644 index 0000000..484adf7 --- /dev/null +++ b/Sources/ApodiniDocumentExport/Value.swift @@ -0,0 +1,6 @@ +import Foundation + +/// A protocol that requires conformance to `Codable` and `Hashable` (also `Equatable`) +public protocol Value: Codable, Hashable {} + +extension Array: Value where Element: Value {} diff --git a/Sources/ApodiniTemplate/ApodiniTemplate.docc/ApodiniTemplate.md b/Sources/ApodiniTemplate/ApodiniTemplate.docc/ApodiniTemplate.md deleted file mode 100644 index d285c87..0000000 --- a/Sources/ApodiniTemplate/ApodiniTemplate.docc/ApodiniTemplate.md +++ /dev/null @@ -1,17 +0,0 @@ -# ``ApodiniTemplate`` - -A template for projects in the Apodini organization. - - - -## Overview - -This is an example for a DocC documentation for the ApodiniTemplate package. diff --git a/Sources/ApodiniTemplate/ApodiniTemplate.swift b/Sources/ApodiniTemplate/ApodiniTemplate.swift deleted file mode 100644 index 300dbd4..0000000 --- a/Sources/ApodiniTemplate/ApodiniTemplate.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// This source file is part of the Apodini open source project -// -// SPDX-FileCopyrightText: 2021 Paul Schmiedmayer and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - - -/// Contains a nice text to say hello -public struct ApodiniTemplate { - /// Generates a greeting from the Apodini Template - /// - Parameter name: The name that should be greeted, the default value is `"Apodini Template"` - /// - Returns: The greeting created by the Apodini Template - public func greet(_ name: String = "Apodini Template") async throws -> String { - try await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds - return "Hello, \(name)!" - } -} diff --git a/Tests/ApodiniDocumentExportTests/ApodiniDocumentExportTests.swift b/Tests/ApodiniDocumentExportTests/ApodiniDocumentExportTests.swift new file mode 100644 index 0000000..5db7ca3 --- /dev/null +++ b/Tests/ApodiniDocumentExportTests/ApodiniDocumentExportTests.swift @@ -0,0 +1,72 @@ +// +// ApodiniDocumentExportTests.swift +// +// +// Created by Valentin Bootz on 29.01.22. +// + +import Foundation +import XCTest +import PathKit +@testable import ApodiniDocumentExport + +class ApodiniDocumentExportTests: XCTestCase { + + let dir = "./\(UUID().uuidString)" + var path: Path { + Path(dir) + } + + override func setUpWithError() throws { + try super.setUpWithError() + + if !path.exists { + try path.mkpath() + } + } + + override func tearDownWithError() throws { + try super.tearDownWithError() + + try path.delete() + } + + func XCTAssertNoThrowWithResult(_ expression: @autoclosure () throws -> T, file: StaticString = #file, line: UInt = #line) -> T { + XCTAssertNoThrow(try expression(), file: file, line: line) + do { + return try expression() + } catch { + XCTFail(error.localizedDescription, file: file, line: line) + } + preconditionFailure("Expression threw an error") + } + + func XCTAssertThrows(_ expression: @autoclosure () throws -> T) { + let expectation = XCTestExpectation(description: "Expression did throw") + do { + try _ = expression() + XCTFail("Expression did not throw") + } catch { + expectation.fulfill() + } + } + + func testYAMLandJSON() throws { + for format in [OutputFormat.json, .yaml] { + guard let url = Bundle.module.url(forResource: "document", withExtension: "json") else { + fatalError("Resource not found!") + } + guard let content = try? String(contentsOf: url, encoding: .utf8) else { + fatalError("Failed to read the resource") + } + let document: Document = XCTAssertNoThrowWithResult(try Document.decode(from: content)) + let path = Path(XCTAssertNoThrowWithResult(try document.write(at: dir, outputFormat: format))) + XCTAssertThrows(try Document.decode(from: path + "invalid")) + let stringContent = XCTAssertNoThrowWithResult(try path.read() as String) + let documentFromPath = XCTAssertNoThrowWithResult(try Document.decode(from: path)) + XCTAssert(document == documentFromPath) + XCTAssertEqual(format.string(of: document).isEmpty, false) + XCTAssertEqual(stringContent.isEmpty, false) + } + } +} diff --git a/Tests/ApodiniDocumentExportTests/Document.swift b/Tests/ApodiniDocumentExportTests/Document.swift new file mode 100644 index 0000000..087d225 --- /dev/null +++ b/Tests/ApodiniDocumentExportTests/Document.swift @@ -0,0 +1,18 @@ +// +// File.swift +// +// +// Created by Valentin Bootz on 29.01.22. +// + +import ApodiniDocumentExport + +struct Document: Value { + let id: String + let name: String + let content: [Content] + + struct Content: Value { + let value: Int + } +} diff --git a/Tests/ApodiniDocumentExportTests/document.json b/Tests/ApodiniDocumentExportTests/document.json new file mode 100644 index 0000000..651a0f5 --- /dev/null +++ b/Tests/ApodiniDocumentExportTests/document.json @@ -0,0 +1,9 @@ +{ + "id": "123", + "name": "abc", + "content": [ + { + "value": 1 + } + ] +} diff --git a/Tests/ApodiniTemplateTests/ApodiniTests.swift b/Tests/ApodiniTemplateTests/ApodiniTests.swift deleted file mode 100644 index 434f8d5..0000000 --- a/Tests/ApodiniTemplateTests/ApodiniTests.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// This source file is part of the Apodini open source project -// -// SPDX-FileCopyrightText: 2021 Paul Schmiedmayer and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import XCTest -@testable import ApodiniTemplate - - -final class ApodiniTemplateTests: XCTestCase { - // Unfortunately, Swift on Linux does not support async tests at the moment. Therefore we use the - // workaround creating a Task and an expectation to wait for the completion of the async functions: - func testExample() throws { - let template = ApodiniTemplate() - - let expectation = XCTestExpectation(description: "Async Task completion") - - Task { - defer { expectation.fulfill() } - - let firstGreeting = try await template.greet() - XCTAssertEqual(firstGreeting, "Hello, Apodini Template!") - - let secondGreeting = try await template.greet("Paul") - XCTAssertEqual(secondGreeting, "Hello, Paul!") - } - - wait(for: [expectation], timeout: 1.25) - } -}