Skip to content

Commit

Permalink
Add support for documenting HTTP endpoints (swiftlang#512)
Browse files Browse the repository at this point in the history
* Add support for documenting HTTP endpoints

Adds support for documenting HTTP parameters, body, and responses in
Markdown.

rdar://106389558

Co-Authored-By: Peter Wilson <[email protected]>

* Add an explicit return type to a closure and fix a bad comment

---------

Co-authored-by: Peter Wilson <[email protected]>
  • Loading branch information
franklinsch and Peter Wilson authored Mar 21, 2023
1 parent fc8fa20 commit 7513841
Show file tree
Hide file tree
Showing 17 changed files with 413 additions and 197 deletions.
4 changes: 2 additions & 2 deletions Sources/SwiftDocC/Infrastructure/DocumentationContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 15 additions & 0 deletions Sources/SwiftDocC/Model/DocumentationNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,66 +27,16 @@ public struct DictionaryKeysSection {
return
}

// Build a lookup table of the new keys
var newDictionaryKeyLookup : Dictionary<String, DictionaryKey> = [:]
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..<self.count).allSatisfy {
self[$0 - 1].name < self[$0].name
}
}

/// Merge a list of dictionary keys with the current array of sorted keys, returning a new array.
func mergeSortedKeys(_ newKeys: [DictionaryKey]) -> [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 }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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..<self.count).allSatisfy {
self[$0 - 1].name < self[$0].name
}
}

/// Merge a list of dictionary keys with the current array of sorted keys, returning a new array.
func mergeSortedParameters(_ newParameters: [HTTPParameter]) -> [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 }
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,68 +27,17 @@ public struct HTTPResponsesSection {
return
}

// Build a lookup table of the new responses
var newResponsesLookup : Dictionary<String, HTTPResponse> = [:]
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..<self.count).allSatisfy {
self[$0 - 1].statusCode < self[$0].statusCode
}
}

/// Merge a list of responses with the current array of sorted responses, returning a new array.
func mergeSortedResponses(_ newResponses: [HTTPResponse]) -> [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 }
}
6 changes: 4 additions & 2 deletions Sources/SwiftDocC/Model/Semantics/HTTPBody.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions Sources/SwiftDocC/Model/Semantics/HTTPParameter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions Sources/SwiftDocC/Model/Semantics/HTTPResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
Loading

0 comments on commit 7513841

Please sign in to comment.