diff --git a/Sources/_OpenAPIGeneratorCore/Config.swift b/Sources/_OpenAPIGeneratorCore/Config.swift index 4f0dd704..49e02ab5 100644 --- a/Sources/_OpenAPIGeneratorCore/Config.swift +++ b/Sources/_OpenAPIGeneratorCore/Config.swift @@ -26,6 +26,9 @@ public struct Config: Sendable { /// Additional imports to add to each generated file. public var additionalImports: [String] + /// Filter to apply to the OpenAPI document before generation. + public var filter: DocumentFilter? + /// Additional pre-release features to enable. public var featureFlags: FeatureFlags @@ -33,14 +36,17 @@ public struct Config: Sendable { /// - Parameters: /// - mode: The mode to use for generation. /// - additionalImports: Additional imports to add to each generated file. + /// - filter: Filter to apply to the OpenAPI document before generation. /// - featureFlags: Additional pre-release features to enable. public init( mode: GeneratorMode, additionalImports: [String] = [], + filter: DocumentFilter? = nil, featureFlags: FeatureFlags = [] ) { self.mode = mode self.additionalImports = additionalImports + self.filter = filter self.featureFlags = featureFlags } } diff --git a/Sources/_OpenAPIGeneratorCore/GeneratorPipeline.swift b/Sources/_OpenAPIGeneratorCore/GeneratorPipeline.swift index 62171cb9..0a0fba5c 100644 --- a/Sources/_OpenAPIGeneratorCore/GeneratorPipeline.swift +++ b/Sources/_OpenAPIGeneratorCore/GeneratorPipeline.swift @@ -126,13 +126,19 @@ func makeGeneratorPipeline( ) }, postTransitionHooks: [ + { document in + guard let documentFilter = config.filter else { + return document + } + return try documentFilter.filter(document) + }, { doc in let validationDiagnostics = try validator(doc, config) for diagnostic in validationDiagnostics { diagnostics.emit(diagnostic) } return doc - } + }, ] ), translateOpenAPIToStructuredSwiftStage: .init( diff --git a/Sources/_OpenAPIGeneratorCore/Hooks/DocumentFilter.swift b/Sources/_OpenAPIGeneratorCore/Hooks/DocumentFilter.swift new file mode 100644 index 00000000..9416ae35 --- /dev/null +++ b/Sources/_OpenAPIGeneratorCore/Hooks/DocumentFilter.swift @@ -0,0 +1,76 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@preconcurrency import OpenAPIKit + +/// Rules used to filter an OpenAPI document. +public struct DocumentFilter: Codable, Sendable { + + /// Operations with these operation IDs will be included in the filter. + public var operations: [String]? + + /// Operations tagged with these tags will be included in the filter. + public var tags: [String]? + + /// These paths will be included in the filter. + public var paths: [OpenAPI.Path]? + + /// These (additional) schemas will be included in the filter. + /// + /// These schemas are included in addition to the transitive closure of schema dependencies of + /// the paths included in the filter. + public var schemas: [String]? + + /// Create a new DocumentFilter. + /// + /// - Parameters: + /// - operations: Operations with these IDs will be included in the filter. + /// - tags: Operations tagged with these tags will be included in the filter. + /// - paths: These paths will be included in the filter. + /// - schemas: These (additional) schemas will be included in the filter. + public init( + operations: [String] = [], + tags: [String] = [], + paths: [OpenAPI.Path] = [], + schemas: [String] = [] + ) { + self.operations = operations + self.tags = tags + self.paths = paths + self.schemas = schemas + } + + /// Filter an OpenAPI document. + /// + /// - Parameter document: The OpenAPI document to filter. + /// - Returns: The filtered document. + /// - Throws: If any requested document components do not exist in the original document. + /// - Throws: If any dependencies of the requested document components cannot be resolved. + public func filter(_ document: OpenAPI.Document) throws -> OpenAPI.Document { + var builder = FilteredDocumentBuilder(document: document) + for tag in tags ?? [] { + try builder.includeOperations(tagged: tag) + } + for operationID in operations ?? [] { + try builder.includeOperation(operationID: operationID) + } + for path in paths ?? [] { + try builder.includePath(path) + } + for schema in schemas ?? [] { + try builder.includeSchema(schema) + } + return try builder.filter() + } +} diff --git a/Sources/_OpenAPIGeneratorCore/Hooks/FilteredDocument.swift b/Sources/_OpenAPIGeneratorCore/Hooks/FilteredDocument.swift new file mode 100644 index 00000000..805dc38d --- /dev/null +++ b/Sources/_OpenAPIGeneratorCore/Hooks/FilteredDocument.swift @@ -0,0 +1,489 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import Foundation +@preconcurrency import OpenAPIKit + +/// Filter the paths and components of an OpenAPI document. +/// +/// The builder starts with an empty filter, which will return the underlying document, but empty +/// paths and components maps. +/// +/// Desired paths and/or named schemas are included by calling the `requireXXX` methods. +/// +/// When adding a path to the filter, the transitive closure of all referenced components are also +/// included in the filtered document. +struct FilteredDocumentBuilder { + + /// The underlying OpenAPI document to filter. + private(set) var document: OpenAPI.Document + + private(set) var requiredPaths: Set + private(set) var requiredPathItemReferences: Set> + private(set) var requiredSchemaReferences: Set> + private(set) var requiredParameterReferences: Set> + private(set) var requiredHeaderReferences: Set> + private(set) var requiredResponseReferences: Set> + private(set) var requiredCallbacksReferences: Set> + private(set) var requiredExampleReferences: Set> + private(set) var requiredLinkReferences: Set> + private(set) var requiredRequestReferences: Set> + private(set) var requiredEndpoints: [OpenAPI.Path: Set] + + /// Create a new FilteredDocumentBuilder. + /// + /// - Parameter document: The underlying OpenAPI document to filter. + init(document: OpenAPI.Document) { + self.document = document + self.requiredPaths = [] + self.requiredPathItemReferences = [] + self.requiredSchemaReferences = [] + self.requiredParameterReferences = [] + self.requiredHeaderReferences = [] + self.requiredResponseReferences = [] + self.requiredCallbacksReferences = [] + self.requiredExampleReferences = [] + self.requiredLinkReferences = [] + self.requiredRequestReferences = [] + self.requiredEndpoints = [:] + } + + /// Filter the underlying document based on the rules provided. + /// + /// - Returns: The filtered OpenAPI document. + /// - Throws: If any dependencies of the requested document components cannot be resolved. + func filter() throws -> OpenAPI.Document { + var components = OpenAPI.Components.noComponents + for reference in requiredSchemaReferences { + components.schemas[try reference.internalComponentKey] = try document.components.lookup(reference) + } + for reference in requiredPathItemReferences { + components.pathItems[try reference.internalComponentKey] = try document.components.lookup(reference) + } + for reference in requiredParameterReferences { + components.parameters[try reference.internalComponentKey] = try document.components.lookup(reference) + } + for reference in requiredHeaderReferences { + components.headers[try reference.internalComponentKey] = try document.components.lookup(reference) + } + for reference in requiredResponseReferences { + components.responses[try reference.internalComponentKey] = try document.components.lookup(reference) + } + for reference in requiredCallbacksReferences { + components.callbacks[try reference.internalComponentKey] = try document.components.lookup(reference) + } + for reference in requiredExampleReferences { + components.examples[try reference.internalComponentKey] = try document.components.lookup(reference) + } + for reference in requiredLinkReferences { + components.links[try reference.internalComponentKey] = try document.components.lookup(reference) + } + for reference in requiredRequestReferences { + components.requestBodies[try reference.internalComponentKey] = try document.components.lookup(reference) + } + var filteredDocument = document.filteringPaths(with: requiredPaths.contains(_:)) + for (path, methods) in requiredEndpoints { + if filteredDocument.paths.contains(key: path) { + continue + } + guard let maybeReference = document.paths[path] else { + continue + } + switch maybeReference { + case .a(let reference): + components.pathItems[try reference.internalComponentKey] = try document.components.lookup(reference) + .filteringEndpoints { methods.contains($0.method) } + case .b(let pathItem): + filteredDocument.paths[path] = .b(pathItem.filteringEndpoints { methods.contains($0.method) }) + } + } + filteredDocument.components = components + return filteredDocument + } + + /// Include a path (and all its component dependencies). + /// + /// The path is added to the filter, along with the transitive closure of all components + /// referenced within the corresponding path item. + /// + /// - Parameter path: The path to be included in the filter. + /// - Throws: If the path does not exist in original OpenAPI document. + mutating func includePath(_ path: OpenAPI.Path) throws { + guard let pathItem = document.paths[path] else { + throw FilteredDocumentBuilderError.pathDoesNotExist(path) + } + guard requiredPaths.insert(path).inserted else { return } + try includePathItem(pathItem) + } + + /// Include operations that have a given tag (and all their component dependencies). + /// + /// Because tags are applied to operations (cf. paths), this may result in paths within filtered + /// document with a subset of the operations defined in the original document. + /// + /// - Parameter tag: The tag to use to include operations (and their paths). + /// - Throws: If the tag does not exist in original OpenAPI document. + mutating func includeOperations(tagged tag: String) throws { + guard document.allTags.contains(tag) else { + throw FilteredDocumentBuilderError.tagDoesNotExist(tag) + } + try includeOperations { endpoint in endpoint.operation.tags?.contains(tag) ?? false } + } + + /// Include operations that have a given tag (and all their component dependencies). + /// + /// Because tags are applied to operations (cf. paths), this may result in paths within filtered + /// document with a subset of the operations defined in the original document. + /// + /// - Parameter tag: The tag by which to include operations (and their paths). + /// - Throws: If the tag does not exist in original OpenAPI document. + mutating func includeOperations(tagged tag: OpenAPI.Tag) throws { + try includeOperations(tagged: tag.name) + } + + /// Include the operation with a given ID (and all its component dependencies). + /// + /// This may result in paths within filtered document with a subset of the operations defined + /// in the original document. + /// + /// - Parameter operationID: The operation to include (and its path). + /// - Throws: If the operation does not exist in original OpenAPI document. + mutating func includeOperation(operationID: String) throws { + guard document.allOperationIds.contains(operationID) else { + throw FilteredDocumentBuilderError.operationDoesNotExist(operationID: operationID) + } + try includeOperations { endpoint in endpoint.operation.operationId == operationID } + } + + /// Include schema (and all its schema dependencies). + /// + /// The schema is added to the filter, along with the transitive closure of all other schemas + /// it references. + /// + /// - Parameter name: The key in the `#/components/schemas` map in the OpenAPI document. + /// - Throws: If the named schema does not exist in original OpenAPI document. + mutating func includeSchema(_ name: String) throws { + try includeSchema(.a(OpenAPI.Reference.component(named: name))) + } +} + +enum FilteredDocumentBuilderError: Error, LocalizedError { + case pathDoesNotExist(OpenAPI.Path) + case tagDoesNotExist(String) + case operationDoesNotExist(operationID: String) + case cannotResolveInternalReference(String) + + var errorDescription: String? { + switch self { + case .pathDoesNotExist(let path): + return "Required path does not exist in OpenAPI document: \(path)" + case .tagDoesNotExist(let tag): + return "Required tag does not exist in OpenAPI document: \(tag)" + case .operationDoesNotExist(let operationID): + return "Required operation does not exist in OpenAPI document: \(operationID)" + case .cannotResolveInternalReference(let reference): + return "Cannot resolve reference; not local reference to component: \(reference)" + } + } +} + +private extension FilteredDocumentBuilder { + mutating func includePathItem(_ maybeReference: Either, OpenAPI.PathItem>) + throws + { + switch maybeReference { + case .a(let reference): + guard requiredPathItemReferences.insert(reference).inserted else { return } + try includeComponentsReferencedBy(try document.components.lookup(reference)) + case .b(let value): + try includeComponentsReferencedBy(value) + } + } + + mutating func includeSchema(_ maybeReference: Either, JSONSchema>) throws { + switch maybeReference { + case .a(let reference): + guard requiredSchemaReferences.insert(reference).inserted else { return } + try includeComponentsReferencedBy(try document.components.lookup(reference)) + case .b(let value): + try includeComponentsReferencedBy(value) + } + } + + mutating func includeParameter(_ maybeReference: Either, OpenAPI.Parameter>) + throws + { + switch maybeReference { + case .a(let reference): + guard requiredParameterReferences.insert(reference).inserted else { return } + try includeComponentsReferencedBy(try document.components.lookup(reference)) + case .b(let value): + try includeComponentsReferencedBy(value) + } + } + + mutating func includeResponse(_ maybeReference: Either, OpenAPI.Response>) + throws + { + switch maybeReference { + case .a(let reference): + guard requiredResponseReferences.insert(reference).inserted else { return } + try includeComponentsReferencedBy(try document.components.lookup(reference)) + case .b(let value): + try includeComponentsReferencedBy(value) + } + } + + mutating func includeHeader(_ maybeReference: Either, OpenAPI.Header>) throws { + switch maybeReference { + case .a(let reference): + guard requiredHeaderReferences.insert(reference).inserted else { return } + try includeComponentsReferencedBy(try document.components.lookup(reference)) + case .b(let value): + try includeComponentsReferencedBy(value) + } + } + + mutating func includeLink(_ maybeReference: Either, OpenAPI.Link>) throws { + switch maybeReference { + case .a(let reference): + guard requiredLinkReferences.insert(reference).inserted else { return } + try includeComponentsReferencedBy(try document.components.lookup(reference)) + case .b(let value): + try includeComponentsReferencedBy(value) + } + } + + mutating func includeCallbacks(_ maybeReference: Either, OpenAPI.Callbacks>) + throws + { + switch maybeReference { + case .a(let reference): + guard requiredCallbacksReferences.insert(reference).inserted else { return } + try includeComponentsReferencedBy(try document.components.lookup(reference)) + case .b(let value): + try includeComponentsReferencedBy(value) + } + } + + mutating func includeRequestBody(_ maybeReference: Either, OpenAPI.Request>) + throws + { + switch maybeReference { + case .a(let reference): + guard requiredRequestReferences.insert(reference).inserted else { return } + try includeComponentsReferencedBy(try document.components.lookup(reference)) + case .b(let value): + try includeComponentsReferencedBy(value) + } + } + + mutating func includeExample(_ maybeReference: Either, OpenAPI.Example>) throws { + switch maybeReference { + case .a(let reference): + guard requiredExampleReferences.insert(reference).inserted else { return } + try includeComponentsReferencedBy(try document.components.lookup(reference)) + case .b(let value): + try includeComponentsReferencedBy(value) + } + } + + mutating func includeOperations(where predicate: (OpenAPI.PathItem.Endpoint) -> Bool) throws { + for (path, maybePathItemReference) in document.paths { + let originalPathItem: OpenAPI.PathItem + switch maybePathItemReference { + case .a(let reference): + originalPathItem = try document.components.lookup(reference) + case .b(let pathItem): + originalPathItem = pathItem + } + + for endpoint in originalPathItem.endpoints { + guard predicate(endpoint) else { + continue + } + if requiredEndpoints[path] == nil { + requiredEndpoints[path] = Set() + } + if requiredEndpoints[path]!.insert(endpoint.method).inserted { + try includeComponentsReferencedBy(endpoint.operation) + } + } + } + } +} + +private extension FilteredDocumentBuilder { + + mutating func includeComponentsReferencedBy(_ pathItem: OpenAPI.PathItem) throws { + for endpoint in pathItem.endpoints { + try includeComponentsReferencedBy(endpoint.operation) + } + for parameter in pathItem.parameters { + try includeParameter(parameter) + } + } + + mutating func includeComponentsReferencedBy(_ operation: OpenAPI.Operation) throws { + for parameter in operation.parameters { + try includeParameter(parameter) + } + for response in operation.responses.values { + try includeResponse(response) + } + if let requestBody = operation.requestBody { + try includeRequestBody(requestBody) + } + for callbacks in operation.callbacks.values { + try includeCallbacks(callbacks) + } + } + + mutating func includeComponentsReferencedBy(_ request: OpenAPI.Request) throws { + for content in request.content.values { + try includeComponentsReferencedBy(content) + } + } + mutating func includeComponentsReferencedBy(_ callbacks: OpenAPI.Callbacks) throws { + for pathItem in callbacks.values { + try includePathItem(pathItem) + } + } + + mutating func includeComponentsReferencedBy(_ schema: JSONSchema) throws { + switch schema.value { + + case .reference(let reference, _): + guard requiredSchemaReferences.insert(OpenAPI.Reference(reference)).inserted else { return } + try includeComponentsReferencedBy(document.components.lookup(reference)) + + case .object(_, let object): + for schema in object.properties.values { + try includeComponentsReferencedBy(schema) + } + if case .b(let schema) = object.additionalProperties { + try includeComponentsReferencedBy(schema) + } + + case .array(_, let array): + if let schema = array.items { + try includeComponentsReferencedBy(schema) + } + + case .not(let schema, _): + try includeComponentsReferencedBy(schema) + + case .all(of: let schemas, _), .one(of: let schemas, _), .any(of: let schemas, _): + for schema in schemas { + try includeComponentsReferencedBy(schema) + } + case .null, .boolean, .number, .integer, .string, .fragment: return + } + } + + mutating func includeComponentsReferencedBy(_ parameter: OpenAPI.Parameter) throws { + try includeComponentsReferencedBy(parameter.schemaOrContent) + } + + mutating func includeComponentsReferencedBy(_ header: OpenAPI.Header) throws { + try includeComponentsReferencedBy(header.schemaOrContent) + } + + mutating func includeComponentsReferencedBy( + _ schemaOrContent: Either + ) throws { + switch schemaOrContent { + case .a(let schemaContext): + switch schemaContext.schema { + case .a(let reference): + guard requiredSchemaReferences.insert(reference).inserted else { return } + try includeComponentsReferencedBy(try document.components.lookup(reference)) + case .b(let schema): + try includeComponentsReferencedBy(schema) + } + case .b(let contentMap): + for value in contentMap.values { + switch value.schema { + case .a(let reference): + guard requiredSchemaReferences.insert(reference).inserted else { return } + try includeComponentsReferencedBy(try document.components.lookup(reference)) + case .b(let schema): + try includeComponentsReferencedBy(schema) + case .none: + continue + } + } + } + } + + mutating func includeComponentsReferencedBy(_ response: OpenAPI.Response) throws { + if let headers = response.headers { + for header in headers.values { + try includeHeader(header) + } + } + for content in response.content.values { + try includeComponentsReferencedBy(content) + } + for link in response.links.values { + try includeLink(link) + } + } + + mutating func includeComponentsReferencedBy(_ content: OpenAPI.Content) throws { + if let schema = content.schema { + try includeSchema(schema) + } + if let encoding = content.encoding { + for encoding in encoding.values { + if let headers = encoding.headers { + for header in headers.values { + try includeHeader(header) + } + } + } + } + if let examples = content.examples { + for example in examples.values { + try includeExample(example) + } + } + } + + mutating func includeComponentsReferencedBy(_ content: OpenAPI.Link) throws {} + + mutating func includeComponentsReferencedBy(_ content: OpenAPI.Example) throws {} +} + +fileprivate extension OpenAPI.Reference { + var internalComponentKey: OpenAPI.ComponentKey { + get throws { + guard case .internal(.component(name: let name)) = jsonReference else { + throw FilteredDocumentBuilderError.cannotResolveInternalReference(absoluteString) + } + return OpenAPI.ComponentKey(stringLiteral: name) + } + } +} + +fileprivate extension OpenAPI.PathItem { + func filteringEndpoints(_ isIncluded: (Endpoint) -> Bool) -> Self { + var filteredPathItem = self + for endpoint in filteredPathItem.endpoints { + if !isIncluded(endpoint) { + filteredPathItem.set(operation: nil, for: endpoint.method) + } + } + return filteredPathItem + } +} diff --git a/Sources/_OpenAPIGeneratorCore/Parser/YamsParser.swift b/Sources/_OpenAPIGeneratorCore/Parser/YamsParser.swift index b7493e77..3af16e89 100644 --- a/Sources/_OpenAPIGeneratorCore/Parser/YamsParser.swift +++ b/Sources/_OpenAPIGeneratorCore/Parser/YamsParser.swift @@ -40,11 +40,22 @@ public struct YamsParser: ParserProtocol { return yamlKeys } - func parseOpenAPI( + /// Parses a YAML file as an OpenAPI document. + /// + /// This function supports documents following any of the following OpenAPI Specifications: + /// - 3.0.0, 3.0.1, 3.0.2, 3.0.3 + /// - 3.1.0 + /// + /// - Parameters + /// - input: The file contents of the OpenAPI document. + /// - diagnostics: A diagnostics collector used for emiting parsing warnings and errors. + /// - Returns: Parsed OpenAPI document. + /// - Throws: If the OpenAPI document cannot be parsed. + /// Note that errors are also emited using the diagnostics collector. + public static func parseOpenAPIDocument( _ input: InMemoryInputFile, - config: Config, diagnostics: any DiagnosticCollector - ) throws -> ParsedOpenAPIRepresentation { + ) throws -> OpenAPIKit.OpenAPI.Document { let decoder = YAMLDecoder() let openapiData = input.contents @@ -93,13 +104,21 @@ public struct YamsParser: ParserProtocol { } } + func parseOpenAPI( + _ input: InMemoryInputFile, + config: Config, + diagnostics: any DiagnosticCollector + ) throws -> ParsedOpenAPIRepresentation { + try Self.parseOpenAPIDocument(input, diagnostics: diagnostics) + } + /// Detects specific YAML parsing errors to throw nicely formatted diagnostics for IDEs. /// /// - Parameters: /// - context: The decoding error context that triggered the parsing error. /// - input: The input file being worked on when the parsing error was triggered. /// - Throws: Throws a `Diagnostic` if the decoding error is a common parsing error. - private func checkParsingError( + private static func checkParsingError( context: DecodingError.Context, input: InMemoryInputFile ) throws { diff --git a/Sources/swift-openapi-generator/Documentation.docc/Articles/Configuring-the-generator.md b/Sources/swift-openapi-generator/Documentation.docc/Articles/Configuring-the-generator.md index bfeca37a..dbe55e03 100644 --- a/Sources/swift-openapi-generator/Documentation.docc/Articles/Configuring-the-generator.md +++ b/Sources/swift-openapi-generator/Documentation.docc/Articles/Configuring-the-generator.md @@ -31,8 +31,14 @@ The configuration file has the following keys: - `client`: Client code that can be used with any client transport (depends on code from `types`). - `server`: Server code that can be used with any server transport (depends on code from `types`). - `additionalImports` (optional): array of strings. Each string value is a Swift module name. An import statement will be added to the generated source files for each module. +- `filter`: (optional): Filters to apply to the OpenAPI document before generation. + - `operations`: Operations with these operation IDs will be included in the filter. + - `tags`: Operations tagged with these tags will be included in the filter. + - `paths`: Operations for these paths will be included in the filter. + - `schemas`: These (additional) schemas will be included in the filter. - `featureFlags` (optional): array of strings. Each string must be a valid feature flag to enable. For a list of currently supported feature flags, check out [FeatureFlags.swift](https://github.com/apple/swift-openapi-generator/blob/main/Sources/_OpenAPIGeneratorCore/FeatureFlags.swift). + ### Example config files To generate client code in a single target: @@ -66,3 +72,40 @@ generate: additionalImports: - APITypes ``` + +### Document filtering + +The generator supports filtering the OpenAPI document prior to generation, which can be useful when +generating client code for a subset of a large API, or splitting an implementation of a server across multiple modules. + +For example, to generate client code for only the operations with a given tag, use the following config: + +```yaml +generate: + - types + - client + +filter: + tags: + - myTag +``` + +When multiple filters are specified, their union will be considered for inclusion. + +In all cases, the transitive closure of dependencies from the components object will be included. + +The CLI also provides a `filter` command that takes the same configuration file as the `generate` +command, which can be used to inspect the filtered document: + +``` +% swift-openapi-generator filter --config path/to/openapi-generator-config.yaml path/to/openapi.yaml +``` + +To use this command as a standalone filtering tool, use the following config and redirect stdout to a new file: + +```yaml +generate: [] +filter: + tags: + - myTag +``` diff --git a/Sources/swift-openapi-generator/FilterCommand.swift b/Sources/swift-openapi-generator/FilterCommand.swift new file mode 100644 index 00000000..88a624e0 --- /dev/null +++ b/Sources/swift-openapi-generator/FilterCommand.swift @@ -0,0 +1,99 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import ArgumentParser +import Foundation +import _OpenAPIGeneratorCore +import Yams +import OpenAPIKit + +struct _FilterCommand: AsyncParsableCommand { + static var configuration = CommandConfiguration( + commandName: "filter", + abstract: "Filter an OpenAPI document", + discussion: """ + Filtering rules are provided in a YAML configuration file. + + Example configuration file contents: + + ```yaml + \(try! YAMLEncoder().encode(sampleConfig)) + ``` + """ + ) + + @Option(help: "Path to a YAML configuration file.") + var config: URL + + @Option(help: "Output format, either \(OutputFormat.yaml.rawValue) or \(OutputFormat.json.rawValue).") + var outputFormat: OutputFormat = .yaml + + @Argument(help: "Path to the OpenAPI document, either in YAML or JSON.") + var docPath: URL + + func run() async throws { + let configData = try Data(contentsOf: config) + let config = try YAMLDecoder().decode(_UserConfig.self, from: configData) + let documentInput = try InMemoryInputFile(absolutePath: docPath, contents: Data(contentsOf: docPath)) + let document = try timing( + "Parsing document", + YamsParser.parseOpenAPIDocument(documentInput, diagnostics: StdErrPrintingDiagnosticCollector()) + ) + guard let documentFilter = config.filter else { + FileHandle.standardError.write("warning: No filter config provided\n") + FileHandle.standardOutput.write(try encode(document, outputFormat)) + return + } + let filteredDocument = try timing("Filtering document", documentFilter.filter(document)) + FileHandle.standardOutput.write(try encode(filteredDocument, outputFormat)) + } +} + +private func encode(_ document: OpenAPI.Document, _ format: OutputFormat) throws -> Data { + switch format { + case .yaml: + return Data(try YAMLEncoder().encode(document).utf8) + case .json: + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes] + return try encoder.encode(document) + } +} + +private func timing(_ title: String, operation: () throws -> Output) rethrows -> Output { + FileHandle.standardError.write("\(title)...\n") + let start = Date.timeIntervalSinceReferenceDate + let result = try operation() + let diff = Date.timeIntervalSinceReferenceDate - start + FileHandle.standardError.write(String(format: "\(title) complete! (%.2fs)\n", diff)) + return result +} + +private func timing(_ title: String, _ operation: @autoclosure () throws -> Output) rethrows -> Output { + try timing(title, operation: operation) +} + +private let sampleConfig = _UserConfig( + generate: [], + filter: DocumentFilter( + operations: ["getGreeting"], + tags: ["greetings"], + paths: ["/greeting"], + schemas: ["Greeting"] + ) +) + +enum OutputFormat: String, ExpressibleByArgument { + case json + case yaml +} diff --git a/Sources/swift-openapi-generator/GenerateOptions+runGenerator.swift b/Sources/swift-openapi-generator/GenerateOptions+runGenerator.swift index 8a576f7b..7845773f 100644 --- a/Sources/swift-openapi-generator/GenerateOptions+runGenerator.swift +++ b/Sources/swift-openapi-generator/GenerateOptions+runGenerator.swift @@ -40,6 +40,7 @@ extension _GenerateOptions { .init( mode: $0, additionalImports: resolvedAdditionalImports, + filter: config?.filter, featureFlags: resolvedFeatureFlags ) } diff --git a/Sources/swift-openapi-generator/Tool.swift b/Sources/swift-openapi-generator/Tool.swift index 59046fec..e8a9282f 100644 --- a/Sources/swift-openapi-generator/Tool.swift +++ b/Sources/swift-openapi-generator/Tool.swift @@ -19,7 +19,8 @@ struct _Tool: AsyncParsableCommand { commandName: "swift-openapi-generator", abstract: "Generate Swift client and server code from an OpenAPI document", subcommands: [ - _GenerateCommand.self + _FilterCommand.self, + _GenerateCommand.self, ] ) } diff --git a/Sources/swift-openapi-generator/UserConfig.swift b/Sources/swift-openapi-generator/UserConfig.swift index cd62dfc6..5bf76133 100644 --- a/Sources/swift-openapi-generator/UserConfig.swift +++ b/Sources/swift-openapi-generator/UserConfig.swift @@ -27,6 +27,9 @@ struct _UserConfig: Codable { /// generated Swift file. var additionalImports: [String]? + /// Filter to apply to the OpenAPI document before generation. + var filter: DocumentFilter? + /// A set of features to explicitly enable. var featureFlags: FeatureFlags? @@ -36,6 +39,7 @@ struct _UserConfig: Codable { enum CodingKeys: String, CaseIterable, CodingKey { case generate case additionalImports + case filter case featureFlags } } diff --git a/Tests/OpenAPIGeneratorCoreTests/Hooks/FilteredDocumentTests.swift b/Tests/OpenAPIGeneratorCoreTests/Hooks/FilteredDocumentTests.swift new file mode 100644 index 00000000..fc304387 --- /dev/null +++ b/Tests/OpenAPIGeneratorCoreTests/Hooks/FilteredDocumentTests.swift @@ -0,0 +1,169 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import OpenAPIKit +import XCTest +import Yams +@testable import _OpenAPIGeneratorCore + +final class FilteredDocumentTests: XCTestCase { + + func testDocumentFilter() throws { + let documentYAML = """ + openapi: 3.1.0 + info: + title: ExampleService + version: 1.0.0 + tags: + - name: t + paths: + /things/a: + get: + operationId: getA + tags: + - t + responses: + 200: + $ref: '#/components/responses/A' + delete: + operationId: deleteA + responses: + 200: + $ref: '#/components/responses/Empty' + /things/b: + get: + operationId: getB + responses: + 200: + $ref: '#/components/responses/B' + components: + schemas: + A: + type: string + B: + $ref: '#/components/schemas/A' + responses: + A: + description: success + content: + application/json: + schema: + $ref: '#/components/schemas/A' + B: + description: success + content: + application/json: + schema: + $ref: '#/components/schemas/B' + Empty: + description: success + """ + let document = try YAMLDecoder().decode(OpenAPI.Document.self, from: documentYAML) + assert( + filtering: document, + filter: DocumentFilter(), + hasPaths: [], + hasOperations: [], + hasSchemas: [] + ) + assert( + filtering: document, + filter: DocumentFilter(tags: ["t"]), + hasPaths: ["/things/a"], + hasOperations: ["getA"], + hasSchemas: ["A"] + ) + assert( + filtering: document, + filter: DocumentFilter(paths: ["/things/a"]), + hasPaths: ["/things/a"], + hasOperations: ["getA", "deleteA"], + hasSchemas: ["A"] + ) + assert( + filtering: document, + filter: DocumentFilter(paths: ["/things/b"]), + hasPaths: ["/things/b"], + hasOperations: ["getB"], + hasSchemas: ["A", "B"] + ) + assert( + filtering: document, + filter: DocumentFilter(paths: ["/things/a", "/things/b"]), + hasPaths: ["/things/a", "/things/b"], + hasOperations: ["getA", "deleteA", "getB"], + hasSchemas: ["A", "B"] + ) + assert( + filtering: document, + filter: DocumentFilter(schemas: ["A"]), + hasPaths: [], + hasOperations: [], + hasSchemas: ["A"] + ) + assert( + filtering: document, + filter: DocumentFilter(schemas: ["B"]), + hasPaths: [], + hasOperations: [], + hasSchemas: ["A", "B"] + ) + assert( + filtering: document, + filter: DocumentFilter(paths: ["/things/a"], schemas: ["B"]), + hasPaths: ["/things/a"], + hasOperations: ["getA", "deleteA"], + hasSchemas: ["A", "B"] + ) + assert( + filtering: document, + filter: DocumentFilter(tags: ["t"], schemas: ["B"]), + hasPaths: ["/things/a"], + hasOperations: ["getA"], + hasSchemas: ["A", "B"] + ) + assert( + filtering: document, + filter: DocumentFilter(operations: ["deleteA"]), + hasPaths: ["/things/a"], + hasOperations: ["deleteA"], + hasSchemas: [] + ) + } + + func assert( + filtering document: OpenAPI.Document, + filter: DocumentFilter, + hasPaths paths: [OpenAPI.Path.RawValue], + hasOperations operationIDs: [String], + hasSchemas schemas: [String], + file: StaticString = #filePath, + line: UInt = #line + ) { + let filteredDocument: OpenAPI.Document + do { + filteredDocument = try filter.filter(document) + } catch { + XCTFail("Filter threw error: \(error)", file: file, line: line) + return + } + XCTAssertUnsortedEqual(filteredDocument.paths.keys.map(\.rawValue), paths, file: file, line: line) + XCTAssertUnsortedEqual(filteredDocument.allOperationIds, operationIDs, file: file, line: line) + XCTAssertUnsortedEqual( + filteredDocument.components.schemas.keys.map(\.rawValue), + schemas, + file: file, + line: line + ) + } +} diff --git a/Tests/OpenAPIGeneratorCoreTests/TestUtilities.swift b/Tests/OpenAPIGeneratorCoreTests/TestUtilities.swift index e27d7aee..60cb2967 100644 --- a/Tests/OpenAPIGeneratorCoreTests/TestUtilities.swift +++ b/Tests/OpenAPIGeneratorCoreTests/TestUtilities.swift @@ -157,6 +157,22 @@ func XCTAssertEqualCodable( XCTFail(messageLines.joined(separator: "\n"), file: file, line: line) } +func XCTAssertUnsortedEqual( + _ expression1: @autoclosure () throws -> [T], + _ expression2: @autoclosure () throws -> [T], + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line +) where T: Comparable { + XCTAssertEqual( + try expression1().sorted(), + try expression2().sorted(), + message(), + file: file, + line: line + ) +} + /// Both names must have the same number of components, throws otherwise. func newTypeName(swiftFQName: String, jsonFQName: String) throws -> TypeName { var jsonComponents = jsonFQName.split(separator: "/").map(String.init)