diff --git a/Sources/RouteDocs/DefaultDocsView/doc_documentation.leaf b/Sources/RouteDocs/DefaultDocsView/doc_documentation.leaf index 927e28a..6e0d262 100644 --- a/Sources/RouteDocs/DefaultDocsView/doc_documentation.leaf +++ b/Sources/RouteDocs/DefaultDocsView/doc_documentation.leaf @@ -2,7 +2,12 @@

-

@@ -14,36 +19,34 @@
#endif -
+
- #if(!doc.query || doc.query.body.isEmpty): + #if(!doc.query): #if(!doc.request): - #if(!doc.response): -

This request has no query or body and returns no response (usually a HTTP 204).

#endif #endif #endif - #if(doc.query && !doc.query.body.isEmpty): + #if(doc.query):
-

Query (#(doc.query.name)) parameters

- #if(doc.query.body.fields): -
    - #for(field in doc.query.body.fields): - #extend("doc_field"): - #export("nonOptionalBadgeStyle", "badge-danger") - #export("optionalBadgeStyle", "badge-secondary") - #endextend - #endfor -
- #endif +

Query parameters

+ #for(object in doc.query.objects): + #extend("doc_object"): + #export("nonOptionalBadgeStyle", "badge-danger") + #export("optionalBadgeStyle", "badge-secondary") + #endextend + #endfor
#endif #if(doc.request): -
+

Request Body (#(doc.request.mediaType.type)/#(doc.request.mediaType.subtype))

@@ -56,7 +59,7 @@
#endif #if(doc.response): -
+

Response Body (#(doc.response.mediaType.type)/#(doc.response.mediaType.subtype))

diff --git a/Sources/RouteDocs/DefaultDocsView/docs.leaf b/Sources/RouteDocs/DefaultDocsView/docs.leaf index bbd6488..3d7bc09 100644 --- a/Sources/RouteDocs/DefaultDocsView/docs.leaf +++ b/Sources/RouteDocs/DefaultDocsView/docs.leaf @@ -1,5 +1,5 @@ - + @@ -11,7 +11,11 @@ API Docs - + @@ -29,7 +33,9 @@

📚API Documentation


-

Each endpoint can be expanded to list the query parameters as well as request and response bodies.

+

+ Each endpoint can be expanded to list the query parameters as well as request and response bodies. +

#extend("doc_list"): #endextend @@ -38,7 +44,7 @@ diff --git a/Sources/RouteDocs/DocsViewContext.swift b/Sources/RouteDocs/DocsViewContext.swift index 6e345b6..14138b1 100644 --- a/Sources/RouteDocs/DocsViewContext.swift +++ b/Sources/RouteDocs/DocsViewContext.swift @@ -46,6 +46,10 @@ public struct DocsViewContext: Encodable, Sendable { public let body: Body } + public struct Query: Encodable, Sendable { + public let objects: Array + } + public struct Payload: Encodable, @unchecked Sendable { // unchecked because of HTTPMediaType public let mediaType: HTTPMediaType public let objects: Array @@ -53,7 +57,7 @@ public struct DocsViewContext: Encodable, Sendable { public let method: HTTPMethod public let path: String - public let query: Object? + public let query: Query? public let request: Payload? public let response: Payload? public let requiredAuthorization: Array @@ -83,7 +87,7 @@ fileprivate extension HTTPMethod { fileprivate extension DocumentationType { func docsTypeName(using namePath: KeyPath?) -> String { - customName ?? namePath.map { self[keyPath: $0] } ?? typeDescription.typeName(includingModule: false) + customName ?? namePath.map { self[keyPath: $0] } ?? typeDescription.typeName(with: [.withParents]) } } @@ -126,6 +130,13 @@ extension DocsViewContext.Documentation.Object { } } +extension DocsViewContext.Documentation.Query { + public init(query: EndpointDocumentation.Query, + usingName namePath: KeyPath? = nil) { + self.init(objects: query.objects.map { .init(object: $0, usingName: namePath) }) + } +} + extension DocsViewContext.Documentation.Payload { public init(payload: EndpointDocumentation.Payload, usingName namePath: KeyPath? = nil) { @@ -139,7 +150,7 @@ extension DocsViewContext.Documentation { usingName namePath: KeyPath? = nil) { self.init(method: documentation.method, path: documentation.path, - query: documentation.query.map { .init(object: $0, usingName: namePath) }, + query: documentation.query.map { .init(query: $0, usingName: namePath) }, request: documentation.request.map { .init(payload: $0, usingName: namePath) }, response: documentation.response.map { .init(payload: $0, usingName: namePath) }, requiredAuthorization: documentation.requiredAuthorization) diff --git a/Sources/RouteDocs/EndpointDocumentable.swift b/Sources/RouteDocs/EndpointDocumentable.swift index b574dfe..89179a1 100644 --- a/Sources/RouteDocs/EndpointDocumentable.swift +++ b/Sources/RouteDocs/EndpointDocumentable.swift @@ -23,7 +23,7 @@ extension Route: EndpointDocumentable { @inlinable public func addDocumentation(groupedAs groupName: String? = nil, - query: EndpointDocumentation.Object? = nil, + query: EndpointDocumentation.Query? = nil, request: EndpointDocumentation.Payload? = nil, response: EndpointDocumentation.Payload? = nil, requiredAuthorization: Array = .init()) { diff --git a/Sources/RouteDocs/EndpointDocumentation.swift b/Sources/RouteDocs/EndpointDocumentation.swift index ca8adf9..9fa6d16 100644 --- a/Sources/RouteDocs/EndpointDocumentation.swift +++ b/Sources/RouteDocs/EndpointDocumentation.swift @@ -5,7 +5,7 @@ public struct DocumentationType: Codable, Equatable, CustomStringConvertible, Se public let customName: String? public var defaultName: String { - customName ?? typeDescription.typeName(includingModule: true) + customName ?? typeDescription.typeName(with: [.withModule, .withParents]) } public var description: String { defaultName } @@ -159,6 +159,24 @@ public struct EndpointDocumentation: Codable, Equatable, CustomStringConvertible } } + public struct Query: Codable, Equatable, CustomStringConvertible, Sendable { + public let objects: Array + + public var description: String { + """ + \(objects.map { String(describing: $0) }.joined(separator: "\n\n")) + """ + } + + public func filterObjects(with closure: (Object) throws -> Object?) rethrows -> Self { + try .init(objects: objects.compactMap(closure)) + } + + public func filterObjects(with closure: (Object) throws -> Bool) rethrows -> Self { + try .init(objects: objects.filter(closure)) + } + } + public struct Payload: Codable, Equatable, CustomStringConvertible, @unchecked Sendable { // unchecked because of HTTPMediaType public let mediaType: HTTPMediaType public let objects: Array @@ -182,7 +200,7 @@ public struct EndpointDocumentation: Codable, Equatable, CustomStringConvertible public let groupName: String? public let method: HTTPMethod public let path: String - public let query: Object? + public let query: Query? public let request: Payload? public let response: Payload? public let requiredAuthorization: Array @@ -203,7 +221,7 @@ extension EndpointDocumentation { public init(method: HTTPMethod, path: Array, groupName: String? = nil, - query: Object? = nil, + query: Query? = nil, request: Payload? = nil, response: Payload? = nil, requiredAuthorization: Array = .init()) { @@ -237,6 +255,17 @@ extension EndpointDocumentation.Payload { } } +extension EndpointDocumentation.Query { + public init(object: T.Type, + customUserInfo: Dictionary = .init()) throws { + try self.init(objects: EndpointDocumentation.Object.objects(from: object.reflectedDocumentation(withCustomUserInfo: customUserInfo))) + } + + public init(object: T.Type) throws { + self.init(objects: EndpointDocumentation.Object.objects(from: object.object(with: object))) + } +} + extension EndpointDocumentation.Object { fileprivate static func addObjects(from documentation: DocumentationObject, to list: inout Array) { @@ -256,14 +285,6 @@ extension EndpointDocumentation.Object { let actualType = (documentation.type as? AnyTypeWrapping.Type)?.leafType ?? documentation.type self.init(type: DocumentationType(actualType), body: .init(documentation: documentation.body)) } - - public init(object: T.Type, customUserInfo: Dictionary = .init()) throws { - try self.init(documentation: object.reflectedDocumentation(withCustomUserInfo: customUserInfo)) - } - - public init(object: T.Type) { - self.init(documentation: object.object(with: object)) - } } extension EndpointDocumentation.Object.Body { diff --git a/Sources/RouteDocs/TypeDescription.swift b/Sources/RouteDocs/TypeDescription.swift index 82795b4..ee2e9cd 100644 --- a/Sources/RouteDocs/TypeDescription.swift +++ b/Sources/RouteDocs/TypeDescription.swift @@ -1,61 +1,235 @@ public struct TypeDescription: Hashable, Sendable, Codable, CustomStringConvertible { private enum CodingKeys: String, CodingKey { - case name + case module, parent, name case genericParameters = "generic_parameters" } + private enum _ParentStorage: Hashable, Sendable { + case none + indirect case some(TypeDescription) + + var value: TypeDescription? { + switch self { + case .none: return nil + case .some(let desc): return desc + } + } + } + + private let _parent: _ParentStorage + + public let module: String + public var parent: TypeDescription? { _parent.value } public let name: String - public fileprivate(set) var genericParameters: Array + public let genericParameters: Array + + public var parents: some Sequence { + sequence(state: parent, next: { state in + defer { state = state?.parent } + return state + }) + } @inlinable public var isGeneric: Bool { !genericParameters.isEmpty } @inlinable - public var description: String { typeName(includingModule: true) } + public var description: String { typeName(with: [.withModule, .withParents]) } - fileprivate init(name: String, genericParameters: Array = []) { + // private but @testable + init( + module: String, + parent: TypeDescription?, + name: String, + genericParameters: Array + ) { + self.module = module + self._parent = parent.map(_ParentStorage.some) ?? .none self.name = name self.genericParameters = genericParameters } + private init(decodedModule: String, container: KeyedDecodingContainer) throws { + module = decodedModule + _parent = try container.decodeIfPresent(TypeDescription.self, forKey: .parent).map(_ParentStorage.some) ?? .none + name = try container.decode(String.self, forKey: .name) + genericParameters = try container.decode(Array.self, forKey: .genericParameters) + } + + private init(decodedType: TypeDescription, container: KeyedDecodingContainer) throws { + module = decodedType.module + _parent = decodedType._parent + name = decodedType.name + genericParameters = try container.decode(Array.self, forKey: .genericParameters) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let module: String + do { + module = try container.decode(String.self, forKey: .module) + } catch DecodingError.keyNotFound(_, _) { + let intermediateType = try TypeParser.type(in: container.decode(String.self, forKey: .name)) + try self.init(decodedType: intermediateType, container: container) + return + } + try self.init(decodedModule: module, container: container) + } + public init(_ type: T.Type) { - self = String(reflecting: type).parseType() + self = TypeParser.type(in: String(reflecting: type)) } public init(any type: Any.Type) { - self = String(reflecting: type).parseType() + self = TypeParser.type(in: String(reflecting: type)) } + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(module, forKey: .module) + if case .some(let wrapped) = _parent { + try container.encode(wrapped, forKey: .parent) + } + try container.encode(name, forKey: .name) + try container.encode(genericParameters, forKey: .genericParameters) + } + + public func typeName(with options: NameOptions = [.withModule, .withParents]) -> String { + var typeName = String() + if case .some(let parent) = _parent, options.contains(.withParents) { + typeName.append(parent.typeName(with: options)) + } else if options.contains(.withModule) { // parent has module already + typeName.append(module) + } + typeName.append(name) + typeName.append("<\(genericParameters.lazy.map { $0.typeName(with: options) }.joined(separator: ", "))>") + return typeName + } + + + @available(*, deprecated, message: "Use typeName(with:)") public func typeName(includingModule: Bool = true) -> String { - (includingModule ? name : name.cleanedModuleName()) + ( - genericParameters.isEmpty - ? "" - : "<\(genericParameters.lazy.map { $0.typeName(includingModule: includingModule) }.joined(separator: ", "))>" - ) + typeName(with: [includingModule ? .withModule : [], .withParents]) } } -extension StringProtocol { - private func parseNextTypes(currentIndex: inout Index) -> Array { - let separatorIndex = firstIndex(where: "<,>".contains) ?? endIndex - var result = separatorIndex > startIndex ? [TypeDescription(name: String(self[..": return result - default: break +extension TypeDescription { + public struct NameOptions: OptionSet, Sendable { + public typealias RawValue = UInt + + public let rawValue: RawValue + + public init(rawValue: RawValue) { + self.rawValue = rawValue + } + } +} + +extension TypeDescription.NameOptions { + public static let withModule = TypeDescription.NameOptions(rawValue: 1 << 0) + public static let withParents = TypeDescription.NameOptions(rawValue: 1 << 1) +} + +fileprivate struct TypeParser { + private struct Context { + let name: Text.SubSequence + var generics = Array() + + func typeDescription(in module: String, parent: TypeDescription?) -> TypeDescription { + .init(module: module, parent: parent, name: String(name), genericParameters: generics) + } + } + + private let string: Text + private var currentIndex: Text.Index + + private var currentChar: Text.Element { + string[currentIndex] + } + + private var remainder: Text.SubSequence { + string[currentIndex...] + } + + private init(string: Text) { + self.string = string + self.currentIndex = string.startIndex + } + + static func type(in string: Text) -> TypeDescription { + var parser = Self.init(string: string) + return parser.parseType() + } + + private mutating func parseType() -> TypeDescription { + let (module, isExtension) = parseModule() + if isExtension { + let extendedType = parseSubtype() + return .init(module: module, + parent: extendedType.parent, + name: extendedType.name, + genericParameters: extendedType.genericParameters) + } + + var parent: TypeDescription? + var context = Context(name: parseIdentifier()) + loop: + while currentIndex < string.endIndex, case let char = currentChar { + string.formIndex(after: ¤tIndex) + switch char { + case ".": + parent = context.typeDescription(in: module, parent: parent) + context = .init(name: parseIdentifier()) + case " ", "<": context.generics.append(parseSubtype()) + case ",", ">": break loop + default: fatalError("Invalid type! Unexpected character: \(currentChar)") + } + } + return context.typeDescription(in: module, parent: parent) + } + + private mutating func parseModule() -> (String, isExtension: Bool) { + if currentChar == "(" { + let prefix = seek(to: ")").dropFirst() // Drop past the opening bracket + seek(to: ":") // (...):MODULE.TYPENAME <- Move past the colon + return (String(prefix.dropPrefix("extension in ") ?? prefix), true) + } else { + return (String(seek(to: ".")), false) + } + } + + private mutating func parseIdentifier() -> Text.SubSequence { + if currentChar == "(" { // TODO: Where else can this occur? + // (unknown context ...) + seek(to: ".") + } + if let index = remainder.firstIndex(where: ".<,>".contains) { + defer { currentIndex = index } + return remainder[.. TypeDescription { - var currentIndex = startIndex - return parseNextTypes(currentIndex: ¤tIndex)[0] + @discardableResult + private mutating func seek(to char: Text.Element) -> Text.SubSequence { + guard let index = remainder.firstIndex(of: char) + else { fatalError("Invalid type! Missing '\(char)' in '\(remainder)'") } + defer { currentIndex = remainder.index(after: index) } + return remainder[.. String { - split(separator: ".").dropFirst().joined(separator: ".") + private mutating func parseSubtype() -> TypeDescription { + var subParser = TypeParser(string: remainder) + defer { currentIndex = subParser.currentIndex } + return subParser.parseType() + } +} + +extension StringProtocol { + fileprivate func dropPrefix(_ prefix: some StringProtocol) -> SubSequence? { + guard starts(with: prefix) else { return nil } + return dropFirst(prefix.count) } } diff --git a/Tests/RouteDocsTests/TypeDescriptionTests.swift b/Tests/RouteDocsTests/TypeDescriptionTests.swift new file mode 100644 index 0000000..d95545f --- /dev/null +++ b/Tests/RouteDocsTests/TypeDescriptionTests.swift @@ -0,0 +1,60 @@ +import XCTest +@testable import RouteDocs + +extension Dictionary { + enum SomeNestedType {} + fileprivate enum SomeOtherNestedType {} +} + +final class TypeDescriptionTests: XCTestCase { + func testVariousTypes() { + let typeDesc1 = TypeDescription(Dictionary.SomeNestedType>.Index.self) + let typeDesc2 = TypeDescription(Dictionary.Index>.SomeOtherNestedType.self) + let expected1 = TypeDescription( + module: "Swift", + parent: .init( + module: "Swift", + parent: nil, + name: "Dictionary", + genericParameters: [ + .init(module: "Swift", parent: nil, name: "String", genericParameters: []), + .init( + module: "RouteDocsTests", + parent: .init(module: "Swift", parent: nil, name: "Dictionary", genericParameters: [ + .init(module: "Swift", parent: nil, name: "String", genericParameters: []), + .init(module: "Swift", parent: nil, name: "Int", genericParameters: []), + ]), + name: "SomeNestedType", + genericParameters: [] + ), + ] + ), + name: "Index", + genericParameters: [] + ) + let expected2 = TypeDescription( + module: "RouteDocsTests", + parent: .init( + module: "Swift", + parent: nil, + name: "Dictionary", + genericParameters: [ + .init(module: "Swift", parent: nil, name: "String", genericParameters: []), + .init( + module: "Swift", + parent: .init(module: "Swift", parent: nil, name: "Dictionary", genericParameters: [ + .init(module: "Swift", parent: nil, name: "String", genericParameters: []), + .init(module: "Swift", parent: nil, name: "Int", genericParameters: []), + ]), + name: "Index", + genericParameters: [] + ), + ] + ), + name: "SomeOtherNestedType", + genericParameters: [] + ) + XCTAssertEqual(typeDesc1, expected1) + XCTAssertEqual(typeDesc2, expected2) + } +}