diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift index a4f4e3ef11..59105b4d3e 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift @@ -1586,13 +1586,13 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { parametersByTarget[edge.target]?.append(parameter) } case .httpBody: - let body = HTTPBody(mediaType: sourceSymbol.httpMediaType ?? "application/json", contents: [], symbol: sourceSymbol) + let body = HTTPBody(mediaType: sourceSymbol.httpMediaType, contents: [], symbol: sourceSymbol) bodyByTarget[edge.target] = body case .httpResponse: let statusParts = sourceSymbol.title.split(separator: " ", maxSplits: 1) let statusCode = UInt(statusParts[0]) ?? 0 let reason = statusParts.count > 1 ? String(statusParts[1]) : nil - let response = HTTPResponse(statusCode: statusCode, reason: reason, mediaType: sourceSymbol.httpMediaType ?? "application/json", contents: [], symbol: sourceSymbol) + let response = HTTPResponse(statusCode: statusCode, reason: reason, mediaType: sourceSymbol.httpMediaType, contents: [], symbol: sourceSymbol) if responsesByTarget[edge.target] == nil { responsesByTarget[edge.target] = [response] } else { diff --git a/Sources/SwiftDocC/Model/DocumentationNode.swift b/Sources/SwiftDocC/Model/DocumentationNode.swift index e144a5b99f..c11095b417 100644 --- a/Sources/SwiftDocC/Model/DocumentationNode.swift +++ b/Sources/SwiftDocC/Model/DocumentationNode.swift @@ -390,6 +390,21 @@ public struct DocumentationNode { semantic.dictionaryKeysSectionVariants[.fallback] = DictionaryKeysSection(dictionaryKeys:keys) } + if let parameters = markupModel.discussionTags?.httpParameters, !parameters.isEmpty { + // Record the parameters extracted from the markdown + semantic.httpParametersSectionVariants[.fallback] = HTTPParametersSection(parameters: parameters) + } + + if let body = markupModel.discussionTags?.httpBody { + // Record the body extracted from the markdown + semantic.httpBodySectionVariants[.fallback] = HTTPBodySection(body: body) + } + + if let responses = markupModel.discussionTags?.httpResponses, !responses.isEmpty { + // Record the responses extracted from the markdown + semantic.httpResponsesSectionVariants[.fallback] = HTTPResponsesSection(responses: responses) + } + options = documentationExtension?.options[.local] self.metadata = documentationExtension?.metadata diff --git a/Sources/SwiftDocC/Model/Rendering/RenderSectionTranslator/HTTPBodySectionTranslator.swift b/Sources/SwiftDocC/Model/Rendering/RenderSectionTranslator/HTTPBodySectionTranslator.swift index d0b716c801..0e1027c629 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderSectionTranslator/HTTPBodySectionTranslator.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderSectionTranslator/HTTPBodySectionTranslator.swift @@ -20,7 +20,7 @@ struct HTTPBodySectionTranslator: RenderSectionTranslator { translateSectionToVariantCollection( documentationDataVariants: symbol.httpBodySectionVariants ) { _, httpBodySection -> RenderSection? in - guard let symbol = httpBodySection.body.symbol else { return nil } + guard let symbol = httpBodySection.body.symbol, let mediaType = httpBodySection.body.mediaType else { return nil } let responseContent = renderNodeTranslator.visitMarkupContainer( MarkupContainer(httpBodySection.body.contents) @@ -43,7 +43,7 @@ struct HTTPBodySectionTranslator: RenderSectionTranslator { return RESTBodyRenderSection( title: "HTTP Body", - mimeType: httpBodySection.body.mediaType, + mimeType: mediaType, bodyContentType: renderedTokens ?? [], content: responseContent, parameters: nil // TODO: Support body parameters diff --git a/Sources/SwiftDocC/Model/Rendering/RenderSectionTranslator/HTTPParametersSectionTranslator.swift b/Sources/SwiftDocC/Model/Rendering/RenderSectionTranslator/HTTPParametersSectionTranslator.swift index 2e2f8fa3cf..d6aea58902 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderSectionTranslator/HTTPParametersSectionTranslator.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderSectionTranslator/HTTPParametersSectionTranslator.swift @@ -23,14 +23,14 @@ struct HTTPParametersSectionTranslator: RenderSectionTranslator { documentationDataVariants: symbol.httpParametersSectionVariants ) { _, httpParametersSection in // Filter out keys that aren't backed by a symbol or have a different source than requested - let filteredParameters = httpParametersSection.parameters.filter { $0.symbol != nil && $0.source == parameterSource.rawValue } + let filteredParameters = httpParametersSection.parameters.filter { $0.symbol != nil && $0.source != nil && $0.source == parameterSource.rawValue } if filteredParameters.isEmpty { return nil } return RESTParametersRenderSection( title: "\(parameterSource.rawValue.capitalized) Parameters", items: filteredParameters.map { translateParameter($0, &renderNodeTranslator) }, - source: .path + source: parameterSource ) } } diff --git a/Sources/SwiftDocC/Model/Section/Sections/DictionaryKeysSection.swift b/Sources/SwiftDocC/Model/Section/Sections/DictionaryKeysSection.swift index 7bddc6a79d..0809c4b958 100644 --- a/Sources/SwiftDocC/Model/Section/Sections/DictionaryKeysSection.swift +++ b/Sources/SwiftDocC/Model/Section/Sections/DictionaryKeysSection.swift @@ -27,66 +27,16 @@ public struct DictionaryKeysSection { return } - // Build a lookup table of the new keys - var newDictionaryKeyLookup : Dictionary = [:] - newDictionaryKeys.forEach { newDictionaryKeyLookup[$0.name] = $0 } - dictionaryKeys = dictionaryKeys.map { existingKey in - if let newKey = newDictionaryKeyLookup[existingKey.name] { - let contents = existingKey.contents.count > 0 ? existingKey.contents : newKey.contents - let symbol = existingKey.symbol != nil ? existingKey.symbol : newKey.symbol - let required = existingKey.required || newKey.required - let updatedKey = DictionaryKey(name: existingKey.name, contents: contents, symbol: symbol, required: required) - newDictionaryKeyLookup.removeValue(forKey: existingKey.name) - return updatedKey - } - return existingKey - } - // Are there any extra keys that didn't match existing set? - if newDictionaryKeyLookup.count > 0 { - // If documented keys are in alphabetical order, merge new ones in rather than append them. - let extraKeys = newDictionaryKeys.filter { newDictionaryKeyLookup[$0.name] != nil } - if dictionaryKeys.isSortedByName && newDictionaryKeys.isSortedByName { - dictionaryKeys = dictionaryKeys.mergeSortedKeys(extraKeys) - } else { - dictionaryKeys.append(contentsOf: extraKeys) - } + // Update existing keys with new data being passed in. + dictionaryKeys = dictionaryKeys.insertAndUpdate(newDictionaryKeys) { existingKey, newKey in + let contents = existingKey.contents.count > 0 ? existingKey.contents : newKey.contents + let symbol = existingKey.symbol ?? newKey.symbol + let required = existingKey.required || newKey.required + return DictionaryKey(name: existingKey.name, contents: contents, symbol: symbol, required: required) } } } -extension Array where Element == DictionaryKey { - /// Checks whether the array of DictionaryKey values are sorted alphabetically according to their `name`. - var isSortedByName: Bool { - if self.count < 2 { return true } - if self.count == 2 { return (self[0].name < self[1].name) } - return (1.. [DictionaryKey] { - var oldIndex = 0 - var newIndex = 0 - - var mergedKeys = [DictionaryKey]() - - while oldIndex < self.count || newIndex < newKeys.count { - if newIndex >= newKeys.count { - mergedKeys.append(self[oldIndex]) - oldIndex += 1 - } else if oldIndex >= self.count { - mergedKeys.append(newKeys[newIndex]) - newIndex += 1 - } else if self[oldIndex].name < newKeys[newIndex].name { - mergedKeys.append(self[oldIndex]) - oldIndex += 1 - } else { - mergedKeys.append(newKeys[newIndex]) - newIndex += 1 - } - } - - return mergedKeys - } +extension DictionaryKey: ListItemUpdatable { + var listItemIdentifier: String { name } } diff --git a/Sources/SwiftDocC/Model/Section/Sections/HTTPBodySection.swift b/Sources/SwiftDocC/Model/Section/Sections/HTTPBodySection.swift index 39e4c65d9c..ba150e035b 100644 --- a/Sources/SwiftDocC/Model/Section/Sections/HTTPBodySection.swift +++ b/Sources/SwiftDocC/Model/Section/Sections/HTTPBodySection.swift @@ -25,6 +25,9 @@ public struct HTTPBodySection { if body.contents.isEmpty { body.contents = newBody.contents } + if body.mediaType == nil { + body.mediaType = newBody.mediaType + } if body.symbol == nil { body.symbol = newBody.symbol } diff --git a/Sources/SwiftDocC/Model/Section/Sections/HTTPParametersSection.swift b/Sources/SwiftDocC/Model/Section/Sections/HTTPParametersSection.swift index 582ce23888..386abfd249 100644 --- a/Sources/SwiftDocC/Model/Section/Sections/HTTPParametersSection.swift +++ b/Sources/SwiftDocC/Model/Section/Sections/HTTPParametersSection.swift @@ -24,68 +24,17 @@ public struct HTTPParametersSection { return } - // Build a lookup table of the new parameters - var newParameterLookup = [String: HTTPParameter]() - newParameters.forEach { newParameterLookup[$0.name + "/" + $0.source] = $0 } - parameters = parameters.map { existingParameter in - // TODO: Allow for fuzzy matches... name matches, but one of the sources is unknown. - let lookupKey = existingParameter.name + "/" + existingParameter.source - if let newParameter = newParameterLookup[lookupKey] { - let contents = existingParameter.contents.count > 0 ? existingParameter.contents : newParameter.contents - let symbol = existingParameter.symbol != nil ? existingParameter.symbol : newParameter.symbol - let required = existingParameter.required || newParameter.required - let updatedParameter = HTTPParameter(name: existingParameter.name, source: existingParameter.source, contents: contents, symbol: symbol, required: required) - newParameterLookup.removeValue(forKey: lookupKey) - return updatedParameter - } - return existingParameter - } - // Are there any extra parameters that didn't match existing set? - if newParameterLookup.count > 0 { - // If documented parameters are in alphabetical order, merge new ones in rather than append them. - let extraParameters = newParameters.filter { newParameterLookup[$0.name + "/" + $0.source] != nil } - if parameters.isSortedByName && newParameters.isSortedByName { - parameters = parameters.mergeSortedParameters(extraParameters) - } else { - parameters.append(contentsOf: extraParameters) - } + // Update existing parameters with new data being passed in. + parameters = parameters.insertAndUpdate(newParameters) { existingParameter, newParameter in + let contents = existingParameter.contents.count > 0 ? existingParameter.contents : newParameter.contents + let symbol = existingParameter.symbol ?? newParameter.symbol + let source = existingParameter.source ?? newParameter.source + let required = existingParameter.required || newParameter.required + return HTTPParameter(name: existingParameter.name, source: source, contents: contents, symbol: symbol, required: required) } } } -extension Array where Element == HTTPParameter { - /// Checks whether the array of HTTPParameter values are sorted alphabetically according to their `name`. - var isSortedByName: Bool { - if self.count < 2 { return true } - if self.count == 2 { return (self[0].name < self[1].name) } - return (1.. [HTTPParameter] { - var oldIndex = 0 - var newIndex = 0 - - var mergedParameters = [HTTPParameter]() - - while oldIndex < self.count || newIndex < newParameters.count { - if newIndex >= newParameters.count { - mergedParameters.append(self[oldIndex]) - oldIndex += 1 - } else if oldIndex >= self.count { - mergedParameters.append(newParameters[newIndex]) - newIndex += 1 - } else if self[oldIndex].name < newParameters[newIndex].name { - mergedParameters.append(self[oldIndex]) - oldIndex += 1 - } else { - mergedParameters.append(newParameters[newIndex]) - newIndex += 1 - } - } - - return mergedParameters - } +extension HTTPParameter: ListItemUpdatable { + var listItemIdentifier: String { name } } diff --git a/Sources/SwiftDocC/Model/Section/Sections/HTTPResponsesSection.swift b/Sources/SwiftDocC/Model/Section/Sections/HTTPResponsesSection.swift index c496098e97..d145313d59 100644 --- a/Sources/SwiftDocC/Model/Section/Sections/HTTPResponsesSection.swift +++ b/Sources/SwiftDocC/Model/Section/Sections/HTTPResponsesSection.swift @@ -27,68 +27,17 @@ public struct HTTPResponsesSection { return } - // Build a lookup table of the new responses - var newResponsesLookup : Dictionary = [:] - newResponses.forEach { newResponsesLookup["\($0.statusCode)/\($0.mediaType)"] = $0 } - responses = responses.map { existingResponse in - // TODO: Allow for fuzzy matches... statusCode matches, but one of the mediaTypes is unknown. - let lookupKey = "\(existingResponse.statusCode)/\(existingResponse.mediaType)" - if let newResponse = newResponsesLookup[lookupKey] { - let contents = existingResponse.contents.count > 0 ? existingResponse.contents : newResponse.contents - let symbol = existingResponse.symbol != nil ? existingResponse.symbol : newResponse.symbol - let reason = existingResponse.reason != nil ? existingResponse.reason : newResponse.reason - let updatedResponse = HTTPResponse(statusCode: existingResponse.statusCode, reason: reason, mediaType: existingResponse.mediaType, contents: contents, symbol: symbol) - newResponsesLookup.removeValue(forKey: lookupKey) - return updatedResponse - } - return existingResponse - } - // Are there any extra responses that didn't match existing set? - if newResponsesLookup.count > 0 { - // If documented keys are in alphabetical order, merge new ones in rather than append them. - let extraResponses = newResponses.filter { newResponsesLookup["\($0.statusCode)/\($0.mediaType)"] != nil } - if responses.isSortedByCode && newResponses.isSortedByCode { - responses = responses.mergeSortedResponses(extraResponses) - } else { - responses.append(contentsOf: extraResponses) - } + // Update existing responses with new data being passed in. + responses = responses.insertAndUpdate(newResponses) { existingResponse, newResponse in + let contents = existingResponse.contents.count > 0 ? existingResponse.contents : newResponse.contents + let symbol = existingResponse.symbol ?? newResponse.symbol + let reason = existingResponse.reason ?? newResponse.reason + let mediaType = existingResponse.mediaType ?? newResponse.mediaType + return HTTPResponse(statusCode: existingResponse.statusCode, reason: reason, mediaType: mediaType, contents: contents, symbol: symbol) } } } -extension Array where Element == HTTPResponse { - /// Checks whether the array of response values are sorted alphabetically according to their `statusCode`. - var isSortedByCode: Bool { - if self.count < 2 { return true } - if self.count == 2 { return (self[0].statusCode < self[1].statusCode) } - return (1.. [HTTPResponse] { - var oldIndex = 0 - var newIndex = 0 - - var mergedResponses = [HTTPResponse]() - - while oldIndex < self.count || newIndex < newResponses.count { - if newIndex >= newResponses.count { - mergedResponses.append(self[oldIndex]) - oldIndex += 1 - } else if oldIndex >= self.count { - mergedResponses.append(newResponses[newIndex]) - newIndex += 1 - } else if self[oldIndex].statusCode < newResponses[newIndex].statusCode { - mergedResponses.append(self[oldIndex]) - oldIndex += 1 - } else { - mergedResponses.append(newResponses[newIndex]) - newIndex += 1 - } - } - - return mergedResponses - } +extension HTTPResponse: ListItemUpdatable { + var listItemIdentifier: UInt { statusCode } } diff --git a/Sources/SwiftDocC/Model/Semantics/HTTPBody.swift b/Sources/SwiftDocC/Model/Semantics/HTTPBody.swift index 49bc989d4a..4b8392e162 100644 --- a/Sources/SwiftDocC/Model/Semantics/HTTPBody.swift +++ b/Sources/SwiftDocC/Model/Semantics/HTTPBody.swift @@ -14,7 +14,9 @@ import SymbolKit /// Documentation about the payload body of an HTTP request. public struct HTTPBody { /// The media type of the body. - public var mediaType: String + /// + /// Value might be undefined initially when first extracted from markdown. + public var mediaType: String? /// The content that describe the body. public var contents: [Markup] /// The symbol graph symbol representing this body. @@ -25,7 +27,7 @@ public struct HTTPBody { /// - mediaType: The media type of the body. /// - contents: The content that describe this body. /// - symbol: The symbol data extracted from the symbol graph. - public init(mediaType: String, contents: [Markup], symbol: SymbolGraph.Symbol? = nil) { + public init(mediaType: String?, contents: [Markup], symbol: SymbolGraph.Symbol? = nil) { self.mediaType = mediaType self.contents = contents self.symbol = symbol diff --git a/Sources/SwiftDocC/Model/Semantics/HTTPParameter.swift b/Sources/SwiftDocC/Model/Semantics/HTTPParameter.swift index dc9d397426..045168e726 100644 --- a/Sources/SwiftDocC/Model/Semantics/HTTPParameter.swift +++ b/Sources/SwiftDocC/Model/Semantics/HTTPParameter.swift @@ -16,7 +16,9 @@ public struct HTTPParameter { /// The name of the parameter. public var name: String /// The source of the parameter, such as "query" or "path". - public var source: String + /// + /// Value might be undefined initially when first extracted from markdown. + public var source: String? /// The content that describe the parameter. public var contents: [Markup] /// The symbol graph symbol representing this parameter. @@ -31,7 +33,7 @@ public struct HTTPParameter { /// - contents: The content that describe this parameter. /// - symbol: The symbol data extracted from the symbol graph. /// - required: Flag indicating whether the parameter is required to be present in the request. - public init(name: String, source: String, contents: [Markup], symbol: SymbolGraph.Symbol? = nil, required: Bool = false) { + public init(name: String, source: String?, contents: [Markup], symbol: SymbolGraph.Symbol? = nil, required: Bool = false) { self.name = name self.source = source self.contents = contents diff --git a/Sources/SwiftDocC/Model/Semantics/HTTPResponse.swift b/Sources/SwiftDocC/Model/Semantics/HTTPResponse.swift index 119f0f2682..a94d5d2854 100644 --- a/Sources/SwiftDocC/Model/Semantics/HTTPResponse.swift +++ b/Sources/SwiftDocC/Model/Semantics/HTTPResponse.swift @@ -18,7 +18,9 @@ public struct HTTPResponse { /// The HTTP code description string. public var reason: String? /// The media type of the response. - public var mediaType: String + /// + /// Value might be undefined initially when first extracted from markdown. + public var mediaType: String? /// The content that describe the response. public var contents: [Markup] /// The symbol graph symbol representing this response. @@ -31,7 +33,7 @@ public struct HTTPResponse { /// - mediaType: The media type of the response. /// - contents: The content that describe this response. /// - symbol: The symbol data extracted from the symbol graph. - public init(statusCode: UInt, reason: String?, mediaType: String, contents: [Markup], symbol: SymbolGraph.Symbol? = nil) { + public init(statusCode: UInt, reason: String?, mediaType: String?, contents: [Markup], symbol: SymbolGraph.Symbol? = nil) { self.statusCode = statusCode self.reason = reason self.mediaType = mediaType diff --git a/Sources/SwiftDocC/Utility/ListItemUpdatable.swift b/Sources/SwiftDocC/Utility/ListItemUpdatable.swift new file mode 100644 index 0000000000..400966da13 --- /dev/null +++ b/Sources/SwiftDocC/Utility/ListItemUpdatable.swift @@ -0,0 +1,82 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2023 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Foundation + +/// Protocol that provides merging and updating capabilities for list item entities that merge content between markdown files and symbol graphs. +/// +/// The single property, ``listItemIdentifier`` returns the value that uniquely identifies the entity within the list item markdown. +protocol ListItemUpdatable { + associatedtype IdentifierType: Comparable, CustomStringConvertible + var listItemIdentifier: IdentifierType { get } +} + +extension Array where Element: ListItemUpdatable { + /// Merge a list values with the current array of values, updating the content of existing elements if they have the same identifier as new values, returning a new list. + /// + /// If both lists are sorted, any new elements that don't match existing elements will be inserted to preserve a sorted list, otherwise they are appended. + func insertAndUpdate(_ newElements: [Element], updater: (Element, Element) -> Element) -> [Element] { + // Build a lookup table of the new elements + var newElementLookup = [String: Element]() + newElements.forEach { newElementLookup[$0.listItemIdentifier.description] = $0 } + + // Update existing elements with new data being passed in. + var updatedElements = self.map { existingElement -> Element in + if let newElement = newElementLookup.removeValue(forKey: existingElement.listItemIdentifier.description) { + return updater(existingElement, newElement) + } + return existingElement + } + + // Are there any extra elements that didn't match existing set? + if newElementLookup.count > 0 { + // If documented elements are in alphabetical order, merge new ones in rather than append them. + let extraElements = newElements.filter { newElementLookup[$0.listItemIdentifier.description] != nil } + if updatedElements.isSortedByIdentifier && newElements.isSortedByIdentifier { + updatedElements.insertSortedElements(extraElements) + } else { + updatedElements.append(contentsOf: extraElements) + } + } + + return updatedElements + } + + /// Checks whether the array of values are sorted alphabetically according to their `listItemIdentifier`. + private var isSortedByIdentifier: Bool { + if self.count < 2 { return true } + if self.count == 2 { return (self[0].listItemIdentifier <= self[1].listItemIdentifier) } + return (1..= self.count { + // Insertion point is the end of the list, so just append remaining content. + self.append(contentsOf: newElements[newElementPoint.. newElements[newElementPoint].listItemIdentifier { + // Out of order. Inject the new element at this location. + self.insert(newElements[newElementPoint], at: insertionPoint) + newElementPoint += 1 + } + insertionPoint += 1 + } + } + +} diff --git a/Sources/SwiftDocC/Utility/MarkupExtensions/ListItemExtractor.swift b/Sources/SwiftDocC/Utility/MarkupExtensions/ListItemExtractor.swift index 54de74f8d5..e3c3a13b66 100644 --- a/Sources/SwiftDocC/Utility/MarkupExtensions/ListItemExtractor.swift +++ b/Sources/SwiftDocC/Utility/MarkupExtensions/ListItemExtractor.swift @@ -85,6 +85,21 @@ extension Collection where Element == InlineMarkup { } return nil } + + func extractHTTPParameter() -> HTTPParameter? { + if let (name, content) = splitNameAndContent() { + return HTTPParameter(name: name, source: nil, contents: content) + } + return nil + } + + func extractHTTPResponse() -> HTTPResponse? { + if let (name, content) = splitNameAndContent() { + let statusCode = UInt(name) ?? 0 + return HTTPResponse(statusCode: statusCode, reason: nil, mediaType: nil, contents: content) + } + return nil + } } extension ListItem { @@ -195,6 +210,124 @@ extension ListItem { return dictionaryKeys } + /** + Extract a standalone HTTP parameter description from this list item. + + Expected form: + + ```markdown + - httpParameter x: A number. + ``` + */ + func extractStandaloneHTTPParameter() -> HTTPParameter? { + guard let remainder = extractTag(TaggedListItemExtractor.httpParameterTag) else { + return nil + } + return remainder.extractHTTPParameter() + } + + /** + Extracts an outline of dictionary keys from a sublist underneath this list item. + + Expected form: + + ```markdown + - HTTPParameters: + - x: a number + - y: another + ``` + + > Warning: Content underneath `- HTTPParameters` that doesn't match this form will be dropped. + */ + func extractHTTPParameterOutline() -> [HTTPParameter]? { + guard extractTag(TaggedListItemExtractor.httpParametersTag + ":") != nil else { + return nil + } + + var parameters = [HTTPParameter]() + + for child in children { + // The list `- HTTPParameters:` should have one child, a list of parameters. + guard let parametersList = child as? UnorderedList else { + // If it's not, that content is dropped. + continue + } + + // Those sublist items are assumed to be a valid `- ___: ...` parameter form or else they are dropped. + for child in parametersList.children { + guard let listItem = child as? ListItem, + let firstParagraph = listItem.child(at: 0) as? Paragraph, + let parameter = Array(firstParagraph.inlineChildren).extractHTTPParameter() else { + continue + } + // Don't forget the rest of the content under this dictionary key list item. + let contents = parameter.contents + Array(listItem.children.dropFirst(1)) + + parameters.append(HTTPParameter(name: parameter.name, source:parameter.source, contents: contents)) + } + } + return parameters + } + + /** + Extract a standalone HTTP response description from this list item. + + Expected form: + + ```markdown + - httpResponse 200: A number. + ``` + */ + func extractStandaloneHTTPResponse() -> HTTPResponse? { + guard let remainder = extractTag(TaggedListItemExtractor.httpResponseTag) else { + return nil + } + return remainder.extractHTTPResponse() + } + + /** + Extracts an outline of dictionary keys from a sublist underneath this list item. + + Expected form: + + ```markdown + - HTTPResponses: + - 200: a status code + - 204: another status code + ``` + + > Warning: Content underneath `- HTTPResponses` that doesn't match this form will be dropped. + */ + func extractHTTPResponseOutline() -> [HTTPResponse]? { + guard extractTag(TaggedListItemExtractor.httpResponsesTag + ":") != nil else { + return nil + } + + var responses = [HTTPResponse]() + + for child in children { + // The list `- HTTPResponses:` should have one child, a list of responses. + guard let responseList = child as? UnorderedList else { + // If it's not, that content is dropped. + continue + } + + // Those sublist items are assumed to be a valid `- ___: ...` response form or else they are dropped. + for child in responseList.children { + guard let listItem = child as? ListItem, + let firstParagraph = listItem.child(at: 0) as? Paragraph, + let response = Array(firstParagraph.inlineChildren).extractHTTPResponse() else { + continue + } + // Don't forget the rest of the content under this dictionary key list item. + let contents = response.contents + Array(listItem.children.dropFirst(1)) + + responses.append(HTTPResponse(statusCode: response.statusCode, reason: response.reason, mediaType: response.mediaType, contents: contents)) + } + } + return responses + } + /** Extract a standalone parameter description from this list item. @@ -255,6 +388,22 @@ extension ListItem { return parameters } + /** + Extract an HTTP body description from a list item. + + Expected form: + + ```markdown + - httpBody: ... + ``` + */ + func extractHTTPBody() -> HTTPBody? { + guard let remainder = extractTag(TaggedListItemExtractor.httpBodyTag + ":") else { + return nil + } + return HTTPBody(mediaType: nil, contents: [Paragraph(remainder)]) + } + /** Extract a return description from a list item. @@ -295,9 +444,18 @@ struct TaggedListItemExtractor: MarkupRewriter { static let parametersTag = "parameters" static let dictionaryKeyTag = "dictionarykey" static let dictionaryKeysTag = "dictionarykeys" + + static let httpBodyTag = "httpbody" + static let httpResponseTag = "httpresponse" + static let httpResponsesTag = "httpresponses" + static let httpParameterTag = "httpparameter" + static let httpParametersTag = "httpparameters" var parameters = [Parameter]() var dictionaryKeys = [DictionaryKey]() + var httpResponses = [HTTPResponse]() + var httpParameters = [HTTPParameter]() + var httpBody: HTTPBody? = nil var returns = [Return]() var `throws` = [Throw]() var otherTags = [SimpleTag]() @@ -397,6 +555,30 @@ struct TaggedListItemExtractor: MarkupRewriter { // - dictionaryKey x: ... dictionaryKeys.append(dictionaryKeyDescription) return nil + } else if let httpParameterDescription = listItem.extractHTTPParameterOutline() { + // - HTTPParameters: + // - x: ... + // - y: ... + httpParameters.append(contentsOf: httpParameterDescription) + return nil + } else if let httpParameterDescription = listItem.extractStandaloneHTTPParameter() { + // - HTTPParameter x: ... + httpParameters.append(httpParameterDescription) + return nil + } else if let httpBodyDescription = listItem.extractHTTPBody() { + // - httpBody: ... + httpBody = httpBodyDescription + return nil + } else if let httpResponseDescription = listItem.extractHTTPResponseOutline() { + // - HTTPResponses: + // - x: ... + // - y: ... + httpResponses.append(contentsOf: httpResponseDescription) + return nil + } else if let httpResponseDescription = listItem.extractStandaloneHTTPResponse() { + // - HTTPResponse x: ... + httpResponses.append(httpResponseDescription) + return nil } else if let simpleTag = listItem.extractSimpleTag() { // - todo: ... // etc. diff --git a/Tests/SwiftDocCTests/Model/SemaToRenderNodeHTTPRequestTests.swift b/Tests/SwiftDocCTests/Model/SemaToRenderNodeHTTPRequestTests.swift index 9aa2b998d6..380f5da02b 100644 --- a/Tests/SwiftDocCTests/Model/SemaToRenderNodeHTTPRequestTests.swift +++ b/Tests/SwiftDocCTests/Model/SemaToRenderNodeHTTPRequestTests.swift @@ -42,6 +42,8 @@ class SemaToRenderNodeHTTPRequestTests: XCTestCase { "rest:test:get:v1/artists/{}@body-application/json", // 200 response code: "rest:test:get:v1/artists/{}=200-application/json", + // 204 response code: + "rest:test:get:v1/artists/{}=204", ] // Verify we have the right number of cached nodes. @@ -131,10 +133,10 @@ class SemaToRenderNodeHTTPRequestTests: XCTestCase { func testRestRequestRenderNodeHasExpectedContent() throws { let outputConsumer = try renderNodeConsumer(for: "HTTPRequests") - let artistRenderNode = try outputConsumer.renderNode(withIdentifier: "rest:test:get:v1/artists/{}") + let getArtistRenderNode = try outputConsumer.renderNode(withIdentifier: "rest:test:get:v1/artists/{}") assertExpectedContent( - artistRenderNode, + getArtistRenderNode, sourceLanguage: "data", symbolKind: "httpRequest", title: "Get Artist", @@ -153,9 +155,9 @@ class SemaToRenderNodeHTTPRequestTests: XCTestCase { "v1/artists/", // path "{id}" // parameter ], - httpParameters: ["id", "limit"], + httpParameters: ["id@path", "limit@query"], httpBodyType: "application/json", - httpResponses: [200], + httpResponses: [200, 204], discussionSection: [ "The endpoint discussion.", ], @@ -171,5 +173,46 @@ class SemaToRenderNodeHTTPRequestTests: XCTestCase { "'Get Artist' symbol has unexpected content for '\(fieldName)'." } ) + + // Confirm docs for parameters + let paramItemSets = getArtistRenderNode.primaryContentSections.compactMap { ($0 as? RESTParametersRenderSection)?.items } + XCTAssertEqual(2, paramItemSets.count) + if paramItemSets.count > 0 { + let items = paramItemSets[0] + XCTAssertEqual(1, items.count) + if items.count > 0 { + XCTAssertEqual(["ID docs."], items[0].content?.paragraphText) + XCTAssertTrue(items[0].required ?? false) + } + } + if paramItemSets.count > 1 { + let items = paramItemSets[1] + XCTAssertEqual(1, items.count) + if items.count > 0 { + XCTAssertEqual(["Limit query parameter."], items[0].content?.paragraphText) + XCTAssertEqual(["Maximum", "Minimum"], items[0].attributes?.map(\.title).sorted()) + XCTAssertFalse(items[0].required ?? false) + } + } + + // Confirm docs for request body + let body = getArtistRenderNode.primaryContentSections.first(where: { nil != $0 as? RESTBodyRenderSection }) as? RESTBodyRenderSection + XCTAssertNotNil(body) + if let body = body { + XCTAssertEqual(["Simple body."], body.content?.paragraphText) + XCTAssertEqual("application/json", body.mimeType) + } + + // Confirm docs for responses + let responses = getArtistRenderNode.primaryContentSections.compactMap { ($0 as? RESTResponseRenderSection)?.items }.flatMap { $0 } + XCTAssertEqual(2, responses.count) + if responses.count > 0 { + let response = responses[0] + XCTAssertEqual(["Everything is good with json."], response.content?.paragraphText) + } + if responses.count > 1 { + let response = responses[1] + XCTAssertEqual(["Success without content."], response.content?.paragraphText) + } } } diff --git a/Tests/SwiftDocCTests/Test Bundles/HTTPRequests.docc/GetArtist.md b/Tests/SwiftDocCTests/Test Bundles/HTTPRequests.docc/GetArtist.md index 0b330f02d1..49047dd69d 100644 --- a/Tests/SwiftDocCTests/Test Bundles/HTTPRequests.docc/GetArtist.md +++ b/Tests/SwiftDocCTests/Test Bundles/HTTPRequests.docc/GetArtist.md @@ -4,4 +4,16 @@ Get Artist request. The endpoint discussion. +- HTTPParameters: + - id: ID docs. + - limit: Limit query parameter. + - ignored: Ignored parameter. + +- HTTPBody: Simple body. + +- HTTPResponses: + - 200: Everything is good with json. + - 204: Success without content. + - 887: Bad value. + diff --git a/Tests/SwiftDocCTests/Test Bundles/HTTPRequests.docc/requests.symbols.json b/Tests/SwiftDocCTests/Test Bundles/HTTPRequests.docc/requests.symbols.json index 0cc993736b..2d1d460f1b 100644 --- a/Tests/SwiftDocCTests/Test Bundles/HTTPRequests.docc/requests.symbols.json +++ b/Tests/SwiftDocCTests/Test Bundles/HTTPRequests.docc/requests.symbols.json @@ -44,6 +44,12 @@ "source": "rest:test:get:v1/artists/{}=200-application/json", "target": "rest:test:get:v1/artists/{}", "targetFallback": null + }, + { + "kind": "memberOf", + "source": "rest:test:get:v1/artists/{}=204", + "target": "rest:test:get:v1/artists/{}", + "targetFallback": null } ], "symbols": [ @@ -167,13 +173,31 @@ "identifier": "httpResponse" }, "names": { - "title": "200 Is Good" + "title": "200 Is OK" }, "pathComponents": [ "Get_Artist", "200" ] }, + { + "accessLevel": "public", + "identifier": { + "interfaceLanguage": "data", + "precise": "rest:test:get:v1/artists/{}=204" + }, + "kind": { + "displayName": "HTTP Response", + "identifier": "httpResponse" + }, + "names": { + "title": "204" + }, + "pathComponents": [ + "Get_Artist", + "204" + ] + }, { "accessLevel": "public", "declarationFragments": [ diff --git a/Tests/SwiftDocCTests/XCTestCase+AssertingTestData.swift b/Tests/SwiftDocCTests/XCTestCase+AssertingTestData.swift index cbb6b90849..0d7f3a5c89 100644 --- a/Tests/SwiftDocCTests/XCTestCase+AssertingTestData.swift +++ b/Tests/SwiftDocCTests/XCTestCase+AssertingTestData.swift @@ -84,8 +84,9 @@ extension XCTestCase { XCTAssertEqual( (renderNode.primaryContentSections.compactMap { $0 as? RESTParametersRenderSection }) - .flatMap(\.items) - .map(\.name), + .flatMap { section in + section.items.map { "\($0.name)@\(section.source.rawValue)" } + }, expectedHTTPParameters ?? [], // compactMap gives an empty [], but should treat it as match for nil, too failureMessageForField("rest parameters"), file: file,