Skip to content

Commit 168e01f

Browse files
Add builtin validations for querystring parameter location (#488)
* Add querystring parameter builtin validations
1 parent ae9f70b commit 168e01f

3 files changed

Lines changed: 266 additions & 4 deletions

File tree

Sources/OpenAPIKit/Validator/Validation+Builtins.swift

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,108 @@ extension Validation {
394394
)
395395
}
396396

397+
/// Validate that `querystring` parameters are unique and do not coexist
398+
/// with `query` parameters within a Path Item's effective operation
399+
/// parameters.
400+
///
401+
/// OpenAPI 3.2.0 requires that a `querystring` parameter
402+
/// [must not appear more than once and must not appear in the same operation
403+
/// as any `query` parameters](https://spec.openapis.org/oas/v3.2.0.html#parameter-locations).
404+
///
405+
/// - Important: This is included in validation by default.
406+
public static var querystringParametersAreCompatible: Validation<OpenAPI.PathItem> {
407+
.init(
408+
description: "Querystring parameters are unique and do not coexist with query parameters",
409+
check: { context in
410+
let pathParameters = resolvedParameters(context.subject.parameters, components: context.document.components)
411+
let pathSummary = ParameterLocationSummary(pathParameters)
412+
let pathParametersPath = context.codingPath + [Validator.CodingKey.init(stringValue: "parameters")]
413+
var errors = [ValidationError]()
414+
let pathHasMultipleQuerystringParameters = pathSummary.querystringCount > 1
415+
let pathMixesQueryLocations = pathSummary.querystringCount > 0 && pathSummary.queryCount > 0
416+
417+
if pathHasMultipleQuerystringParameters {
418+
errors.append(
419+
ValidationError(
420+
reason: "Path Item parameters must not contain more than one `querystring` parameter",
421+
at: pathParametersPath
422+
)
423+
)
424+
}
425+
426+
if pathMixesQueryLocations {
427+
errors.append(
428+
ValidationError(
429+
reason: "Path Item parameters must not mix `querystring` and `query` parameter locations",
430+
at: pathParametersPath
431+
)
432+
)
433+
}
434+
435+
for endpoint in context.subject.endpoints {
436+
let operationParameters = resolvedParameters(endpoint.operation.parameters, components: context.document.components)
437+
let operationSummary = ParameterLocationSummary(operationParameters)
438+
let operationParametersPath = context.codingPath + [
439+
Validator.CodingKey.init(stringValue: codingPathKey(for: endpoint.method)),
440+
Validator.CodingKey.init(stringValue: "parameters")
441+
]
442+
let operationHasMultipleQuerystringParameters = operationSummary.querystringCount > 1
443+
let operationMixesQueryLocations = operationSummary.querystringCount > 0 &&
444+
operationSummary.queryCount > 0
445+
let effectiveQuerystringCount = pathSummary.querystringCount + operationSummary.querystringCount
446+
let effectiveQueryCount = pathSummary.queryCount + operationSummary.queryCount
447+
let inheritedHasMultipleQuerystringParameters =
448+
!pathHasMultipleQuerystringParameters &&
449+
!operationHasMultipleQuerystringParameters &&
450+
effectiveQuerystringCount > 1
451+
let inheritedMixesQueryLocations =
452+
!pathMixesQueryLocations &&
453+
!operationMixesQueryLocations &&
454+
effectiveQuerystringCount > 0 &&
455+
effectiveQueryCount > 0
456+
457+
if operationHasMultipleQuerystringParameters {
458+
errors.append(
459+
ValidationError(
460+
reason: "Operation parameters must not contain more than one `querystring` parameter",
461+
at: operationParametersPath
462+
)
463+
)
464+
}
465+
466+
if operationMixesQueryLocations {
467+
errors.append(
468+
ValidationError(
469+
reason: "Operation parameters must not mix `querystring` and `query` parameter locations",
470+
at: operationParametersPath
471+
)
472+
)
473+
}
474+
475+
if inheritedHasMultipleQuerystringParameters {
476+
errors.append(
477+
ValidationError(
478+
reason: "Operation parameters must not contain more than one `querystring` parameter, including inherited Path Item parameters",
479+
at: operationParametersPath
480+
)
481+
)
482+
}
483+
484+
if inheritedMixesQueryLocations {
485+
errors.append(
486+
ValidationError(
487+
reason: "Operation parameters must not mix `querystring` and `query` parameter locations, including inherited Path Item parameters",
488+
at: operationParametersPath
489+
)
490+
)
491+
}
492+
}
493+
494+
return errors
495+
}
496+
)
497+
}
498+
397499
/// Validate that all OpenAPI Operation Ids are unique across the whole Document.
398500
///
399501
/// The OpenAPI Specification requires that Operation Ids [are unique](https://spec.openapis.org/oas/v3.2.0.html#operation-object).
@@ -592,9 +694,32 @@ extension Validation {
592694
/// Used by both the Path Item parameter check and the
593695
/// Operation parameter check in the default validations.
594696
fileprivate func parametersAreUnique(_ parameters: OpenAPI.Parameter.Array, components: OpenAPI.Components) -> Bool {
595-
let foundParameters = parameters.compactMap { try? components.lookup($0) }
697+
let foundParameters = resolvedParameters(parameters, components: components)
596698

597699
let identities = foundParameters.map { OpenAPI.Parameter.ParameterIdentity(name: $0.name, location: $0.location) }
598700

599701
return Set(identities).count == foundParameters.count
600702
}
703+
704+
fileprivate func resolvedParameters(_ parameters: OpenAPI.Parameter.Array, components: OpenAPI.Components) -> [OpenAPI.Parameter] {
705+
parameters.compactMap { try? components.lookup($0) }
706+
}
707+
708+
fileprivate struct ParameterLocationSummary {
709+
let queryCount: Int
710+
let querystringCount: Int
711+
712+
init(_ parameters: [OpenAPI.Parameter]) {
713+
queryCount = parameters.filter { $0.location == .query }.count
714+
querystringCount = parameters.filter { $0.location == .querystring }.count
715+
}
716+
}
717+
718+
fileprivate func codingPathKey(for method: OpenAPI.HttpMethod) -> String {
719+
switch method {
720+
case .builtin(let builtin):
721+
return builtin.rawValue.lowercased()
722+
case .other(let other):
723+
return other
724+
}
725+
}

Sources/OpenAPIKit/Validator/Validator.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ extension OpenAPI.Document {
7878
/// - Server names are unique across the whole Document.
7979
/// - Parameters are unique within each Path Item.
8080
/// - Parameters are unique within each Operation.
81+
/// - Querystring parameters are unique and do not coexist with query parameters.
8182
/// - Operation Ids are unique across the whole Document.
8283
/// - All OpenAPI.References that refer to components in this
8384
/// document can be found in the components dictionary.
@@ -160,6 +161,7 @@ public final class Validator {
160161
.init(.documentServerNamesAreUnique),
161162
.init(.pathItemParametersAreUnique),
162163
.init(.operationParametersAreUnique),
164+
.init(.querystringParametersAreCompatible),
163165
.init(.operationIdsAreUnique),
164166
.init(.serverVariableEnumIsValid),
165167
.init(.serverVariableDefaultExistsInEnum),
@@ -205,6 +207,7 @@ public final class Validator {
205207
/// - Server names are unique across the whole Document.
206208
/// - Parameters are unique within each Path Item.
207209
/// - Parameters are unique within each Operation.
210+
/// - Querystring parameters are unique and do not coexist with query parameters.
208211
/// - Operation Ids are unique across the whole Document.
209212
/// - All OpenAPI.References that refer to components in this document can
210213
/// be found in the components dictionary.

Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift

Lines changed: 137 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,25 +24,27 @@ final class BuiltinValidationTests: XCTestCase {
2424
])
2525

2626
let withoutReferenceValidations = Validator().skippingReferenceValidations()
27-
XCTAssertEqual(withoutReferenceValidations.validationDescriptions.count, 8)
27+
XCTAssertEqual(withoutReferenceValidations.validationDescriptions.count, 9)
2828
XCTAssertEqual(withoutReferenceValidations.validationDescriptions, [
2929
"The names of Tags in the Document are unique",
3030
"The names of Servers in the Document are unique",
3131
"Path Item parameters are unique (identity is defined by the \'name\' and \'location\')",
3232
"Operation parameters are unique (identity is defined by the \'name\' and \'location\')",
33+
"Querystring parameters are unique and do not coexist with query parameters",
3334
"All Operation Ids in Document are unique",
3435
"Server Variable\'s enum is either not defined or is non-empty (if defined).",
3536
"Server Variable\'s default must exist in enum, if enum is defined.",
3637
"Parameter styles are all compatible with their locations"
3738
])
3839

3940
let defaultValidations = Validator()
40-
XCTAssertEqual(defaultValidations.validationDescriptions.count, 18)
41+
XCTAssertEqual(defaultValidations.validationDescriptions.count, 19)
4142
XCTAssertEqual(defaultValidations.validationDescriptions, [
4243
"The names of Tags in the Document are unique",
4344
"The names of Servers in the Document are unique",
4445
"Path Item parameters are unique (identity is defined by the \'name\' and \'location\')",
4546
"Operation parameters are unique (identity is defined by the \'name\' and \'location\')",
47+
"Querystring parameters are unique and do not coexist with query parameters",
4648
"All Operation Ids in Document are unique",
4749
"Server Variable\'s enum is either not defined or is non-empty (if defined).",
4850
"Server Variable\'s default must exist in enum, if enum is defined.",
@@ -60,12 +62,13 @@ final class BuiltinValidationTests: XCTestCase {
6062
])
6163

6264
let stricterReferenceValidations = Validator().validatingAllReferencesFoundInComponents()
63-
XCTAssertEqual(stricterReferenceValidations.validationDescriptions.count, 18)
65+
XCTAssertEqual(stricterReferenceValidations.validationDescriptions.count, 19)
6466
XCTAssertEqual(stricterReferenceValidations.validationDescriptions, [
6567
"The names of Tags in the Document are unique",
6668
"The names of Servers in the Document are unique",
6769
"Path Item parameters are unique (identity is defined by the \'name\' and \'location\')",
6870
"Operation parameters are unique (identity is defined by the \'name\' and \'location\')",
71+
"Querystring parameters are unique and do not coexist with query parameters",
6972
"All Operation Ids in Document are unique",
7073
"Server Variable\'s enum is either not defined or is non-empty (if defined).",
7174
"Server Variable\'s default must exist in enum, if enum is defined.",
@@ -1483,4 +1486,135 @@ final class BuiltinValidationTests: XCTestCase {
14831486
XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters[0]")
14841487
}
14851488
}
1489+
1490+
func test_duplicateQuerystringParametersOnPathItem_fails() throws {
1491+
let document = OpenAPI.Document(
1492+
info: .init(title: "test", version: "1.0"),
1493+
servers: [],
1494+
paths: [
1495+
"/hello": .init(
1496+
parameters: [
1497+
.parameter(OpenAPI.Parameter.querystring(name: "first", content: [:])),
1498+
.parameter(OpenAPI.Parameter.querystring(name: "second", content: [:]))
1499+
],
1500+
get: .init(responses: [:])
1501+
)
1502+
],
1503+
components: .noComponents
1504+
)
1505+
1506+
let validator = Validator.blank.validating(.querystringParametersAreCompatible)
1507+
1508+
XCTAssertThrowsError(try document.validate(using: validator)) { error in
1509+
let errorCollection = error as? ValidationErrorCollection
1510+
XCTAssertEqual(errorCollection?.values.first?.reason, "Path Item parameters must not contain more than one `querystring` parameter")
1511+
XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].parameters")
1512+
}
1513+
}
1514+
1515+
func test_duplicateQuerystringParametersAcrossPathItemAndOperation_fails() throws {
1516+
let document = OpenAPI.Document(
1517+
info: .init(title: "test", version: "1.0"),
1518+
servers: [],
1519+
paths: [
1520+
"/hello": .init(
1521+
parameters: [
1522+
.parameter(OpenAPI.Parameter.querystring(name: "first", content: [:]))
1523+
],
1524+
get: .init(
1525+
parameters: [
1526+
.parameter(OpenAPI.Parameter.querystring(name: "second", content: [:]))
1527+
],
1528+
responses: [:]
1529+
)
1530+
)
1531+
],
1532+
components: .noComponents
1533+
)
1534+
1535+
let validator = Validator.blank.validating(.querystringParametersAreCompatible)
1536+
1537+
XCTAssertThrowsError(try document.validate(using: validator)) { error in
1538+
let errorCollection = error as? ValidationErrorCollection
1539+
XCTAssertEqual(errorCollection?.values.first?.reason, "Operation parameters must not contain more than one `querystring` parameter, including inherited Path Item parameters")
1540+
XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters")
1541+
}
1542+
}
1543+
1544+
func test_querystringAndQueryParametersOnOperation_fails() throws {
1545+
let document = OpenAPI.Document(
1546+
info: .init(title: "test", version: "1.0"),
1547+
servers: [],
1548+
paths: [
1549+
"/hello": .init(
1550+
get: .init(
1551+
parameters: [
1552+
.parameter(OpenAPI.Parameter.query(name: "query", schema: .string)),
1553+
.parameter(OpenAPI.Parameter.querystring(name: "querystring", content: [:]))
1554+
],
1555+
responses: [:]
1556+
)
1557+
)
1558+
],
1559+
components: .noComponents
1560+
)
1561+
1562+
let validator = Validator.blank.validating(.querystringParametersAreCompatible)
1563+
1564+
XCTAssertThrowsError(try document.validate(using: validator)) { error in
1565+
let errorCollection = error as? ValidationErrorCollection
1566+
XCTAssertEqual(errorCollection?.values.first?.reason, "Operation parameters must not mix `querystring` and `query` parameter locations")
1567+
XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters")
1568+
}
1569+
}
1570+
1571+
func test_querystringAndQueryParametersAcrossPathItemAndOperation_fails() throws {
1572+
let document = OpenAPI.Document(
1573+
info: .init(title: "test", version: "1.0"),
1574+
servers: [],
1575+
paths: [
1576+
"/hello": .init(
1577+
parameters: [
1578+
.parameter(OpenAPI.Parameter.query(name: "query", schema: .string))
1579+
],
1580+
get: .init(
1581+
parameters: [
1582+
.parameter(OpenAPI.Parameter.querystring(name: "querystring", content: [:]))
1583+
],
1584+
responses: [:]
1585+
)
1586+
)
1587+
],
1588+
components: .noComponents
1589+
)
1590+
1591+
let validator = Validator.blank.validating(.querystringParametersAreCompatible)
1592+
1593+
XCTAssertThrowsError(try document.validate(using: validator)) { error in
1594+
let errorCollection = error as? ValidationErrorCollection
1595+
XCTAssertEqual(errorCollection?.values.first?.reason, "Operation parameters must not mix `querystring` and `query` parameter locations, including inherited Path Item parameters")
1596+
XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters")
1597+
}
1598+
}
1599+
1600+
func test_singleQuerystringParameter_succeeds() throws {
1601+
let document = OpenAPI.Document(
1602+
info: .init(title: "test", version: "1.0"),
1603+
servers: [],
1604+
paths: [
1605+
"/hello": .init(
1606+
get: .init(
1607+
parameters: [
1608+
.parameter(OpenAPI.Parameter.querystring(name: "querystring", content: [:]))
1609+
],
1610+
responses: [:]
1611+
)
1612+
)
1613+
],
1614+
components: .noComponents
1615+
)
1616+
1617+
let validator = Validator.blank.validating(.querystringParametersAreCompatible)
1618+
try document.validate(using: validator)
1619+
}
14861620
}

0 commit comments

Comments
 (0)