diff --git a/README.md b/README.md index 2da9afb47f..b16ccda07c 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,9 @@ SwaggerParseResult result = new OpenAPIParser().readContents("./path/to/swagger. the Swagger/OpenAPI 2.0 document will be first converted into a comparable OpenAPI 3.0 one. -You can also directly use `OpenAPIV3Parser` which only handles OpenAPI 3.0 documents, and provides a convenience method to get directly the parsed `OpenAPI object: +You can also directly use `OpenAPIV3Parser` which handles OpenAPI 3.x documents, and provides a convenience method to get directly the parsed `OpenAPI` object. + +Note: OpenAPI 3.2 documents are currently parsed through the OpenAPI 3.1 model. Elements introduced in 3.2 (e.g. the `query` operation, `additionalOperations`, `$self`, `in: querystring` parameters) are reported as validation messages but are not reproduced in the parsed model, so resolved output can differ from what a fully 3.2-aware parser would produce. ```java import io.swagger.v3.parser.OpenAPIV3Parser; diff --git a/modules/swagger-parser-core/src/main/java/io/swagger/v3/parser/core/models/SwaggerParseResult.java b/modules/swagger-parser-core/src/main/java/io/swagger/v3/parser/core/models/SwaggerParseResult.java index 53b6e3b15f..9558c189e3 100644 --- a/modules/swagger-parser-core/src/main/java/io/swagger/v3/parser/core/models/SwaggerParseResult.java +++ b/modules/swagger-parser-core/src/main/java/io/swagger/v3/parser/core/models/SwaggerParseResult.java @@ -11,6 +11,7 @@ public class SwaggerParseResult { private List messages = null; private OpenAPI openAPI; private boolean openapi31; + private boolean openapi32; public SwaggerParseResult messages(List messages) { this.messages = messages; @@ -68,16 +69,29 @@ public boolean isOpenapi31() { return this.openapi31; } + public void setOpenapi32(boolean openapi32) { + this.openapi32 = openapi32; + } + + public SwaggerParseResult openapi32(boolean openapi32) { + this.openapi32 = openapi32; + return this; + } + + public boolean isOpenapi32() { + return this.openapi32; + } + @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; SwaggerParseResult result = (SwaggerParseResult) o; - return openapi31 == result.openapi31 && Objects.equals(messages, result.messages) && Objects.equals(openAPI, result.openAPI); + return openapi31 == result.openapi31 && openapi32 == result.openapi32 && Objects.equals(messages, result.messages) && Objects.equals(openAPI, result.openAPI); } @Override public int hashCode() { - return Objects.hash(messages, openAPI, openapi31); + return Objects.hash(messages, openAPI, openapi31, openapi32); } } diff --git a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/OpenAPIV3Parser.java b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/OpenAPIV3Parser.java index 5ca34cefff..03fd681633 100644 --- a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/OpenAPIV3Parser.java +++ b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/OpenAPIV3Parser.java @@ -213,7 +213,7 @@ private SwaggerParseResult resolve(SwaggerParseResult result, List auths, String par } public ResolverCache(OpenAPI openApi, List auths, String parentFileLocation, Set resolveValidationMessages, ParseOptions parseOptions) { - this.openapi31 = openApi != null && openApi.getOpenapi() != null && openApi.getOpenapi().startsWith("3.1"); + this.openapi31 = openApi != null && openApi.getOpenapi() != null && (openApi.getOpenapi().startsWith("3.1") || openApi.getOpenapi().startsWith("3.2")); this.openApi = openApi; this.auths = auths; this.rootPath = parentFileLocation; diff --git a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/IdsTraverser.java b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/IdsTraverser.java index 3b1e2a4797..82d899dd87 100644 --- a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/IdsTraverser.java +++ b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/IdsTraverser.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import io.swagger.v3.core.util.Json31; +import io.swagger.v3.core.util.Json32; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Operation; @@ -110,6 +111,7 @@ public Components traverseComponents(Components components, Visitor visitor) { traverseMap(resolved.getHeaders(), visitor, this::traverseHeader); traverseMap(resolved.getLinks(), visitor, this::traverseLink); traverseMap(resolved.getResponses(), visitor, this::traverseResponse); + traverseMap(resolved.getMediaTypes(), visitor, this::traverseMediaType); traverseMap(resolved.getExamples(), visitor, this::traverseExample); visitedMap.put(components, resolved); @@ -308,6 +310,12 @@ public PathItem traversePathItem(PathItem pathItem, Visitor visitor) { if (resolvedOperation != null) { resolved.setTrace(resolvedOperation); } + Operation queryOp = resolved.getQuery(); + resolvedOperation = traverseOperation(queryOp, visitor); + if (resolvedOperation != null) { + resolved.setQuery(resolvedOperation); + } + traverseMap(resolved.getAdditionalOperations(), visitor, this::traverseOperation); if (resolved.getParameters() != null) { for (int i = 0; i < resolved.getParameters().size(); i++) { @@ -409,7 +417,15 @@ public MediaType traverseMediaType(MediaType mediaType, Visitor visitor) { if (resolved.getSchema() != null) { traverseSchema(resolved.getSchema(), visitor, new ArrayList<>()); } + if (resolved.getItemSchema() != null) { + traverseSchema(resolved.getItemSchema(), visitor, new ArrayList<>()); + } traverseMap(resolved.getEncoding(), visitor, this::traverseEncoding); + traverseEncodingList(resolved.getPrefixEncoding(), visitor); + Encoding itemEncoding = traverseEncoding(resolved.getItemEncoding(), visitor); + if (itemEncoding != null) { + resolved.setItemEncoding(itemEncoding); + } traverseMap(resolved.getExamples(), visitor, this::traverseExample); visitedMap.put(mediaType, resolved); visiting.remove(mediaType); @@ -431,12 +447,30 @@ public Encoding traverseEncoding(Encoding encoding, Visitor visitor) { Encoding resolved = encoding; traverseMap(resolved.getHeaders(), visitor, this::traverseHeader); + traverseMap(resolved.getEncoding(), visitor, this::traverseEncoding); + traverseEncodingList(resolved.getPrefixEncoding(), visitor); + Encoding itemEncoding = traverseEncoding(resolved.getItemEncoding(), visitor); + if (itemEncoding != null) { + resolved.setItemEncoding(itemEncoding); + } visitedMap.put(encoding, resolved); visiting.remove(encoding); return resolved; } + private void traverseEncodingList(List encodings, Visitor visitor) { + if (encodings == null) { + return; + } + for (int i = 0; i < encodings.size(); i++) { + Encoding resolved = traverseEncoding(encodings.get(i), visitor); + if (resolved != null) { + encodings.set(i, resolved); + } + } + } + public Header traverseHeader(Header header, Visitor visitor) { if (header == null) { return null; @@ -520,7 +554,8 @@ public Schema traverseSchema(Schema schema, Visitor visitor, List inheri resolvedURI = ReferenceUtils.resolve(urlWithoutHash, resolvedURI); resolvedURI = ReferenceUtils.toBaseURI(resolvedURI); } - context.getIdsCache().put(resolvedURI, Json31.pretty(schema)); + context.getIdsCache().put(resolvedURI, + isOpenapi32() ? Json32.pretty(schema) : Json31.pretty(schema)); } catch (Exception e) { // } @@ -639,9 +674,18 @@ public Schema traverseSchema(Schema schema, Visitor visitor, List inheri } + private boolean isOpenapi32() { + return context != null && context.getSwaggerParseResult() != null + && context.getSwaggerParseResult().isOpenapi32(); + } + + private com.fasterxml.jackson.databind.ObjectMapper modelMapper() { + return isOpenapi32() ? Json32.mapper() : Json31.mapper(); + } + public T deepcopy(T entity, Class clazz) { try { - return (T)Json31.mapper().readValue(Json31.mapper().writeValueAsString(entity), clazz); + return (T)modelMapper().readValue(modelMapper().writeValueAsString(entity), clazz); } catch (JsonProcessingException e) { throw new RuntimeException(e); } diff --git a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/OpenAPI31Traverser.java b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/OpenAPI31Traverser.java index ddcb08ba69..f84aa1adb6 100644 --- a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/OpenAPI31Traverser.java +++ b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/OpenAPI31Traverser.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import io.swagger.v3.core.util.Json31; +import io.swagger.v3.core.util.Json32; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Operation; @@ -70,7 +71,8 @@ public OpenAPI traverse(OpenAPI openAPI, Visitor visitor) throws Exception { public T deserializeFragment(JsonNode node, Class expectedType, String uri, String fragment, Set validationMessages) { String sanitizedFragment = fragment == null ? "" : fragment; - OpenAPIDeserializer.ParseResult parseResult = new OpenAPIDeserializer.ParseResult().openapi31(true); + OpenAPIDeserializer.ParseResult parseResult = new OpenAPIDeserializer.ParseResult() + .openapi31(true).openapi32(isOpenapi32()); T result = null; if (expectedType.equals(Schema.class)) { result = (T) deserializer.getSchema((ObjectNode) node, sanitizedFragment.replace("/", "."), parseResult); @@ -92,6 +94,8 @@ public T deserializeFragment(JsonNode node, Class expectedType, String ur result = (T) deserializer.getSecurityScheme((ObjectNode) node, sanitizedFragment.replace("/", "."), parseResult); }else if (expectedType.equals(PathItem.class)) { result = (T) deserializer.getPathItem((ObjectNode) node, sanitizedFragment.replace("/", "."), parseResult); + }else if (expectedType.equals(MediaType.class)) { + result = (T) deserializer.getMediaType((ObjectNode) node, sanitizedFragment.replace("/", "."), parseResult); } parseResult.getMessages().forEach((m) -> { validationMessages.add(m + " (" + uri + ")"); @@ -170,6 +174,7 @@ public Components traverseComponents(Components components, ReferenceVisitor vis traverseMap(resolved.getHeaders(), visitor, this::traverseHeader); traverseMap(resolved.getLinks(), visitor, this::traverseLink); traverseMap(resolved.getResponses(), visitor, this::traverseResponse); + traverseMap(resolved.getMediaTypes(), visitor, this::traverseMediaType); traverseMap(resolved.getExamples(), visitor, this::traverseExample); visitedMap.put(components, resolved); @@ -430,6 +435,12 @@ public PathItem traversePathItem(PathItem pathItem, ReferenceVisitor visitor) { if (resolvedOperation != null) { resolved.setTrace(resolvedOperation); } + Operation queryOp = resolved.getQuery(); + resolvedOperation = traverseOperation(queryOp, visitor); + if (resolvedOperation != null) { + resolved.setQuery(resolvedOperation); + } + traverseMap(resolved.getAdditionalOperations(), visitor, this::traverseOperation); if (resolved.getParameters() != null) { for (int i = 0; i < resolved.getParameters().size(); i++) { @@ -462,6 +473,15 @@ public PathItem traversePathItem(PathItem pathItem, ReferenceVisitor visitor) { if (pathItem.getSummary() != null) { resolved.summary(pathItem.getSummary()); } + if (pathItem.getQuery() != null) { + resolved.setQuery(pathItem.getQuery()); + } + if (pathItem.getAdditionalOperations() != null) { + if (resolved.getAdditionalOperations() == null) { + resolved.setAdditionalOperations(new LinkedHashMap<>()); + } + resolved.getAdditionalOperations().putAll(pathItem.getAdditionalOperations()); + } // TODO additional undefined merge if other props are defined visitedMap.put(pathItem, deepcopy(resolved, PathItem.class)); @@ -595,8 +615,12 @@ public MediaType traverseMediaType(MediaType mediaType, ReferenceVisitor visitor visiting.add(mediaType); MediaType resolved = visitor.visitMediaType(mediaType); + boolean resolvedNotNull = false; + if (resolved == null) { resolved = mediaType; + } else { + resolvedNotNull = true; } if (resolved.getSchema() != null) { @@ -605,9 +629,33 @@ public MediaType traverseMediaType(MediaType mediaType, ReferenceVisitor visitor resolved.setSchema(schema); } } + if (resolved.getItemSchema() != null) { + Schema itemSchema = traverseSchema(resolved.getItemSchema(), visitor, new ArrayList<>()); + if (itemSchema != null) { + resolved.setItemSchema(itemSchema); + } + } traverseMap(resolved.getEncoding(), visitor, this::traverseEncoding); + if (resolved.getPrefixEncoding() != null) { + for (int i = 0; i < resolved.getPrefixEncoding().size(); i++) { + Encoding prefixEncoding = traverseEncoding(resolved.getPrefixEncoding().get(i), visitor); + if (prefixEncoding != null) { + resolved.getPrefixEncoding().set(i, prefixEncoding); + } + } + } + traverseEncoding(resolved.getItemEncoding(), visitor); traverseMap(resolved.getExamples(), visitor, this::traverseExample); - visitedMap.put(mediaType, resolved); + + // only if this is root and local ref + if (shouldHandleRootLocalRefs(resolvedNotNull, mediaType.get$ref(), visitor)) { + ensureComponents(context.getOpenApi()); + if (context.getOpenApi().getComponents().getMediaTypes() == null) context.getOpenApi().getComponents().mediaTypes(new LinkedHashMap<>()); + visitedMap.put(mediaType, deepcopy(mediaType, MediaType.class)); + visiting.remove(mediaType); + return handleRootLocalRefs(mediaType.get$ref(), resolved, context.getOpenApi().getComponents().getMediaTypes()); + } + visitedMap.put(mediaType, deepcopy(resolved, MediaType.class)); visiting.remove(mediaType); return resolved; } @@ -630,6 +678,16 @@ public Encoding traverseEncoding(Encoding encoding, ReferenceVisitor visitor) { resolved = encoding; } traverseMap(resolved.getHeaders(), visitor, this::traverseHeader); + traverseMap(resolved.getEncoding(), visitor, this::traverseEncoding); + if (resolved.getPrefixEncoding() != null) { + for (int i = 0; i < resolved.getPrefixEncoding().size(); i++) { + Encoding prefixEncoding = traverseEncoding(resolved.getPrefixEncoding().get(i), visitor); + if (prefixEncoding != null) { + resolved.getPrefixEncoding().set(i, prefixEncoding); + } + } + } + traverseEncoding(resolved.getItemEncoding(), visitor); visitedMap.put(encoding, resolved); visiting.remove(encoding); return resolved; @@ -793,7 +851,8 @@ public Schema traverseSchema(Schema schema, ReferenceVisitor visitor, List T deepcopy(T entity, Class clazz) { try { - return (T)Json31.mapper().readValue(Json31.mapper().writeValueAsString(entity), clazz); + return (T)modelMapper().readValue(modelMapper().writeValueAsString(entity), clazz); } catch (JsonProcessingException e) { throw new RuntimeException(e); } diff --git a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/OpenAPIDereferencer31.java b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/OpenAPIDereferencer31.java index 9ac62a9873..9fc4b9c188 100644 --- a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/OpenAPIDereferencer31.java +++ b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/OpenAPIDereferencer31.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.core.util.Json31; +import io.swagger.v3.core.util.Json32; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.parser.core.models.SwaggerParseResult; import org.apache.commons.lang3.StringUtils; @@ -15,7 +16,7 @@ public class OpenAPIDereferencer31 implements OpenAPIDereferencer { public boolean canDereference(DereferencerContext context) { - if (context.openApi != null && context.openApi.getOpenapi().startsWith("3.1")) { + if (context.openApi != null && context.openApi.getOpenapi() != null && (context.openApi.getOpenapi().startsWith("3.1") || context.openApi.getOpenapi().startsWith("3.2"))) { return true; } return false; @@ -50,7 +51,8 @@ public void dereference(DereferencerContext context, Iterator auths, PermittedUrlsChecker permittedUrlsChecker) throws Exception { if(context.getParseOptions().isSafelyResolveURL()){ diff --git a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/util/OpenAPIDeserializer.java b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/util/OpenAPIDeserializer.java index 421822dd3b..86f9876801 100644 --- a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/util/OpenAPIDeserializer.java +++ b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/util/OpenAPIDeserializer.java @@ -46,6 +46,7 @@ import io.swagger.v3.oas.models.parameters.Parameter.StyleEnum; import io.swagger.v3.oas.models.parameters.PathParameter; import io.swagger.v3.oas.models.parameters.QueryParameter; +import io.swagger.v3.oas.models.parameters.QueryStringParameter; import io.swagger.v3.oas.models.parameters.RequestBody; import io.swagger.v3.oas.models.responses.ApiResponse; import io.swagger.v3.oas.models.responses.ApiResponses; @@ -194,6 +195,30 @@ public class OpenAPIDeserializer { protected static Set ENCODING_KEYS_31 = new LinkedHashSet<>(Arrays.asList("contentType", "headers", "style", "explode", "allowReserved")); + // OpenAPI 3.2 fixed-field additions over 3.1 + protected static Set ROOT_KEYS_32 = keys32(ROOT_KEYS_31, "$self"); + protected static Set TAG_KEYS_32 = keys32(TAG_KEYS_31, "summary", "parent", "kind"); + protected static Set RESPONSE_KEYS_32 = keys32(RESPONSE_KEYS_31, "summary"); + protected static Set SERVER_KEYS_32 = keys32(SERVER_KEYS_31, "name"); + protected static Set PATHITEM_KEYS_32 = keys32(PATHITEM_KEYS_31, "query", "additionalOperations"); + protected static Set COMPONENTS_KEYS_32 = keys32(COMPONENTS_KEYS_31, "mediaTypes"); + protected static Set MEDIATYPE_KEYS_32 = keys32(MEDIATYPE_KEYS_31, "$ref", "itemSchema", + "prefixEncoding", "itemEncoding"); + protected static Set EXAMPLE_KEYS_32 = keys32(EXAMPLE_KEYS_31, "dataValue", "serializedValue"); + protected static Set SECURITY_SCHEME_KEYS_32 = keys32(SECURITY_SCHEME_KEYS_31, "deprecated", + "oauth2MetadataUrl"); + protected static Set XML_KEYS_32 = keys32(XML_KEYS_31, "nodeType"); + protected static Set OAUTHFLOW_KEYS_32 = keys32(OAUTHFLOW_KEYS_31, "deviceAuthorizationUrl"); + protected static Set OAUTHFLOWS_KEYS_32 = keys32(OAUTHFLOWS_KEYS_31, "deviceAuthorization"); + protected static Set ENCODING_KEYS_32 = keys32(ENCODING_KEYS_31, "encoding", "prefixEncoding", + "itemEncoding"); + + private static Set keys32(Set keys31, String... additions) { + Set keys = new LinkedHashSet<>(keys31); + keys.addAll(Arrays.asList(additions)); + return keys; + } + protected static Map>> KEYS = new LinkedHashMap<>(); protected static Set validNodeTypes = new LinkedHashSet<>( @@ -255,10 +280,38 @@ public class OpenAPIDeserializer { KEYS.put("openapi30", keys30); KEYS.put("openapi31", keys31); + Map> keys32 = new LinkedHashMap<>(); + // unchanged categories are inherited from 3.1 + keys31.forEach(keys32::putIfAbsent); + keys32.put("ROOT_KEYS", ROOT_KEYS_32); + keys32.put("TAG_KEYS", TAG_KEYS_32); + keys32.put("RESPONSE_KEYS", RESPONSE_KEYS_32); + keys32.put("SERVER_KEYS", SERVER_KEYS_32); + keys32.put("PATHITEM_KEYS", PATHITEM_KEYS_32); + keys32.put("COMPONENTS_KEYS", COMPONENTS_KEYS_32); + keys32.put("MEDIATYPE_KEYS", MEDIATYPE_KEYS_32); + keys32.put("EXAMPLE_KEYS", EXAMPLE_KEYS_32); + keys32.put("SECURITY_SCHEME_KEYS", SECURITY_SCHEME_KEYS_32); + keys32.put("XML_KEYS", XML_KEYS_32); + keys32.put("OAUTHFLOW_KEYS", OAUTHFLOW_KEYS_32); + keys32.put("OAUTHFLOWS_KEYS", OAUTHFLOWS_KEYS_32); + keys32.put("ENCODING_KEYS", ENCODING_KEYS_32); + KEYS.put("openapi32", keys32); + + } + + private static Map> getSpecKeys(ParseResult result) { + if (result.isOpenapi32()) { + return KEYS.get("openapi32"); + } + return KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); } private static final String QUERY_PARAMETER = "query"; + private static final String QUERYSTRING_PARAMETER = "querystring"; private static final String COOKIE_PARAMETER = "cookie"; + private static final Set FIXED_PATH_METHODS = new LinkedHashSet<>(Arrays.asList( + "get", "put", "post", "delete", "head", "patch", "options", "trace", "query")); private static final String PATH_PARAMETER = "path"; private static final String HEADER_PARAMETER = "header"; private static final Pattern RFC3339_DATE_TIME_PATTERN = Pattern.compile("^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):" + @@ -309,6 +362,7 @@ public SwaggerParseResult deserialize(JsonNode rootNode, String path, ParseOptio rootParse.setExplicitStyleAndExplode(options.isExplicitStyleAndExplode()); OpenAPI api = parseRoot(rootNode, rootParse, path); result.openapi31(rootParse.isOpenapi31()); + result.openapi32(rootParse.isOpenapi32()); result.setOpenAPI(api); result.setMessages(rootParse.getMessages()); } catch (Exception e) { @@ -331,17 +385,29 @@ public OpenAPI parseRoot(JsonNode node, ParseResult result, String path) { String value = getString("openapi", rootNode, true, location, result); // we don't even try if the version isn't there - if (value == null || (!value.startsWith("3.0") && !value.startsWith("3.1"))) { + if (value == null || (!value.startsWith("3.0") && !value.startsWith("3.1") && !value.startsWith("3.2"))) { return null; - } else if (value.startsWith("3.1")) { + } else if (value.startsWith("3.1") || value.startsWith("3.2")) { result.openapi31(true); - openAPI.setSpecVersion(SpecVersion.V31); + if (value.startsWith("3.2")) { + result.openapi32(true); + openAPI.setSpecVersion(SpecVersion.V32); + } else { + openAPI.setSpecVersion(SpecVersion.V31); + } } - if (!value.startsWith("3.0.") && !value.startsWith("3.1.")){ + if (!value.startsWith("3.0.") && !value.startsWith("3.1.") && !value.startsWith("3.2.")){ result.warning(location, "The provided definition does not specify a valid version field"); } openAPI.setOpenapi(value); + if (result.isOpenapi32()) { + String self = getString("$self", rootNode, false, location, result); + if (StringUtils.isNotBlank(self)) { + openAPI.set$self(self); + } + } + ObjectNode obj = getObject("info", rootNode, true, location, result); if (obj != null) { @@ -442,7 +508,7 @@ public OpenAPI parseRoot(JsonNode node, ParseResult result, String path) { } Set keys = getKeys(rootNode); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("ROOT_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, node.get(key)); @@ -531,6 +597,10 @@ public Components getComponents(ObjectNode obj, String location, ParseResult res return null; } Components components = new Components(); + // expose the in-progress components so that nested parsing (e.g. + // components.pathItems operations referencing components.parameters) + // can resolve local refs while this object is still being built + this.components = components; ObjectNode node = getObject("schemas", obj, false, location, result); if (node != null) { @@ -542,13 +612,6 @@ public Components getComponents(ObjectNode obj, String location, ParseResult res components.setResponses(getResponses(node, String.format("%s.%s", location, "responses"), result, true)); } - if(result.isOpenapi31()){ - node = getObject("pathItems", obj, false, location, result); - if (node != null) { - components.setPathItems(getPathItems(node, String.format("%s.%s", location, "pathItems"), result, true)); - } - } - node = getObject("parameters", obj, false, location, result); if (node != null) { components.setParameters(getParameters(node, String.format("%s.%s", location, "parameters"), result, @@ -586,6 +649,34 @@ public Components getComponents(ObjectNode obj, String location, ParseResult res components.setCallbacks(getCallbacks(node, String.format("%s.%s", location, "callbacks"), result, true)); } + + if (result.isOpenapi32()) { + node = getObject("mediaTypes", obj, false, location, result); + if (node != null) { + Map mediaTypes = new LinkedHashMap<>(); + for (String name : getKeys(node)) { + JsonNode mediaTypeNode = node.get(name); + if (mediaTypeNode == null || !mediaTypeNode.isObject()) { + result.invalidType(location, "mediaTypes." + name, "object", mediaTypeNode); + continue; + } + MediaType mediaType = getMediaType((ObjectNode) mediaTypeNode, + String.format("%s.%s.%s", location, "mediaTypes", name), result); + if (mediaType != null) { + mediaTypes.put(name, mediaType); + } + } + components.setMediaTypes(mediaTypes); + } + } + // pathItems are parsed last so that operations inside components.pathItems + // can resolve refs to parameters/schemas/etc defined in this components + if(result.isOpenapi31()){ + node = getObject("pathItems", obj, false, location, result); + if (node != null) { + components.setPathItems(getPathItems(node, String.format("%s.%s", location, "pathItems"), result, true)); + } + } components.setExtensions(new LinkedHashMap<>()); Map extensions = getExtensions(obj); @@ -594,7 +685,7 @@ public Components getComponents(ObjectNode obj, String location, ParseResult res } Set keys = getKeys(obj); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("COMPONENTS_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, obj.get(key)); @@ -645,6 +736,21 @@ public Tag getTag(ObjectNode obj, String location, ParseResult result) { tag.setDescription(value); } + if (result.isOpenapi32()) { + value = getString("summary", obj, false, location, result); + if ((result.isAllowEmptyStrings() && value != null) || (!result.isAllowEmptyStrings() && !StringUtils.isBlank(value))) { + tag.setSummary(value); + } + value = getString("parent", obj, false, location, result); + if ((result.isAllowEmptyStrings() && value != null) || (!result.isAllowEmptyStrings() && !StringUtils.isBlank(value))) { + tag.setParent(value); + } + value = getString("kind", obj, false, location, result); + if ((result.isAllowEmptyStrings() && value != null) || (!result.isAllowEmptyStrings() && !StringUtils.isBlank(value))) { + tag.setKind(value); + } + } + ObjectNode docs = getObject("externalDocs", obj, false, location, result); ExternalDocumentation externalDocs = getExternalDocs(docs, String.format("%s.%s", location, "externalDocs"), result); @@ -658,7 +764,7 @@ public Tag getTag(ObjectNode obj, String location, ParseResult result) { } Set keys = getKeys(obj); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("TAG_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, obj.get(key)); @@ -745,13 +851,20 @@ public Server getServer(ObjectNode obj, String location, ParseResult result, Str server.setDescription(value); } + if (result.isOpenapi32()) { + value = getString("name", obj, false, location, result); + if ((result.isAllowEmptyStrings() && value != null) || (!result.isAllowEmptyStrings() && !StringUtils.isBlank(value))) { + server.setName(value); + } + } + Map extensions = getExtensions(obj); if (extensions != null && extensions.size() > 0) { server.setExtensions(extensions); } Set keys = getKeys(obj); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("SERVER_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, obj.get(key)); @@ -830,7 +943,7 @@ public ServerVariable getServerVariable(ObjectNode obj, String location, ParseRe } Set keys = getKeys(obj); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("SERVER_VARIABLE_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, obj.get(key)); @@ -961,24 +1074,10 @@ private boolean isPathParamDefined(String pathParam, List parameters) return true; } - private void addToOperationsList(List operationsList, Operation operation) { - if (operation == null) { - return; - } - operationsList.add(operation); - } - public List getAllOperationsInAPath(PathItem pathObj) { - List operations = new ArrayList<>(); - addToOperationsList(operations, pathObj.getGet()); - addToOperationsList(operations, pathObj.getPut()); - addToOperationsList(operations, pathObj.getPost()); - addToOperationsList(operations, pathObj.getPatch()); - addToOperationsList(operations, pathObj.getDelete()); - addToOperationsList(operations, pathObj.getTrace()); - addToOperationsList(operations, pathObj.getOptions()); - addToOperationsList(operations, pathObj.getHead()); - return operations; + // readOperations() covers the fixed methods plus query and + // additionalOperations entries + return pathObj.readOperations(); } public PathItem getPathItem(ObjectNode obj, String location, ParseResult result) { @@ -1098,6 +1197,34 @@ public PathItem getPathItem(ObjectNode obj, String location, ParseResult result) pathItem.setTrace(operation); } } + if (result.isOpenapi32()) { + node = getObject("query", obj, false, location, result); + if (node != null) { + Operation operation = getOperation(node, location + "(query)", result); + if (operation != null) { + pathItem.setQuery(operation); + } + } + node = getObject("additionalOperations", obj, false, location, result); + if (node != null) { + for (String method : getKeys(node)) { + if (FIXED_PATH_METHODS.contains(method.toLowerCase(Locale.ROOT))) { + result.warning(location, "additionalOperations key '" + method + + "' duplicates a fixed Path Item method name"); + } + JsonNode operationNode = node.get(method); + if (operationNode == null || !operationNode.isObject()) { + result.invalidType(location, "additionalOperations." + method, "object", operationNode); + continue; + } + Operation operation = getOperation((ObjectNode) operationNode, + location + "(additionalOperations." + method + ")", result); + if (operation != null) { + pathItem.addAdditionalOperation(method, operation); + } + } + } + } Map extensions = getExtensions(obj); if (extensions != null && extensions.size() > 0) { @@ -1105,7 +1232,7 @@ public PathItem getPathItem(ObjectNode obj, String location, ParseResult result) } Set keys = getKeys(obj); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("PATHITEM_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, obj.get(key)); @@ -1113,6 +1240,39 @@ public PathItem getPathItem(ObjectNode obj, String location, ParseResult result) validateReservedKeywords(specKeys, key, location, result); } + // OpenAPI 3.2: an in: querystring parameter must not coexist with in: query + // parameters (or a second querystring parameter) in the effective operation, + // which includes path-item level parameters. Operation parameters override + // path-item parameters sharing the same name+in, so the check runs on the + // effective parameter set rather than the raw union. + if (result.isOpenapi32() && pathItem.getParameters() != null) { + for (Operation op : pathItem.readOperations()) { + if (op == null || op.getParameters() == null) { + continue; + } + Map effective = new LinkedHashMap<>(); + pathItem.getParameters().stream() + .map(this::getParameterDefinition) + .filter(p -> p.getIn() != null) + .forEach(p -> effective.put(p.getName() + "#" + p.getIn(), p)); + op.getParameters().stream() + .map(this::getParameterDefinition) + .filter(p -> p.getIn() != null) + .forEach(p -> effective.put(p.getName() + "#" + p.getIn(), p)); + long querystringCount = effective.values().stream() + .filter(p -> QUERYSTRING_PARAMETER.equals(p.getIn())).count(); + if (querystringCount > 1) { + result.warning(location, + "There can be only one in: querystring parameter per operation"); + } + if (querystringCount > 0 && effective.values().stream() + .anyMatch(p -> QUERY_PARAMETER.equals(p.getIn()))) { + result.warning(location, + "in: querystring parameter cannot be combined with in: query parameters"); + } + } + } + return pathItem; } @@ -1139,7 +1299,7 @@ public ExternalDocumentation getExternalDocs(ObjectNode node, String location, P } Set keys = getKeys(node); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("EXTERNAL_DOCS_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, node.get(key)); @@ -1305,7 +1465,7 @@ public Info getInfo(ObjectNode node, String location, ParseResult result) { } Set keys = getKeys(node); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("INFO_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, node.get(key)); @@ -1359,7 +1519,7 @@ public License getLicense(ObjectNode node, String location, ParseResult result) } Set keys = getKeys(node); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("LICENSE_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, node.get(key)); @@ -1402,7 +1562,7 @@ public Contact getContact(ObjectNode node, String location, ParseResult result) } Set keys = getKeys(node); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("CONTACT_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, node.get(key)); @@ -1437,6 +1597,24 @@ public MediaType getMediaType(ObjectNode contentNode, String location, ParseResu } MediaType mediaType = new MediaType(); + if (result.isOpenapi32()) { + JsonNode ref = contentNode.get("$ref"); + if (ref != null) { + if (ref.getNodeType().equals(JsonNodeType.STRING)) { + String mungedRef = mungedRef(ref.textValue()); + mediaType.set$ref(mungedRef != null ? mungedRef : ref.textValue()); + if (ref.textValue().startsWith("#/components") + && !ref.textValue().startsWith("#/components/mediaTypes")) { + result.warning(location, "$ref target " + ref.textValue() + + " is not of expected type MediaType"); + } + return mediaType; + } + result.invalidType(location, "$ref", "string", contentNode); + return null; + } + } + ObjectNode schemaObject = getObject("schema", contentNode, false, location, result); if (schemaObject != null) { mediaType.setSchema(getSchema(schemaObject, String.format("%s.%s", location, "schema"), result)); @@ -1484,9 +1662,38 @@ public MediaType getMediaType(ObjectNode contentNode, String location, ParseResu } } + if (result.isOpenapi32()) { + JsonNode itemSchemaNode = getObjectOrBoolean("itemSchema", contentNode, false, location, result); + if (itemSchemaNode != null) { + mediaType.setItemSchema(getSchema(itemSchemaNode, + String.format("%s.%s", location, "itemSchema"), result)); + } + ArrayNode prefixEncodingArray = getArray("prefixEncoding", contentNode, false, location, result); + if (prefixEncodingArray != null) { + List prefixEncoding = new ArrayList<>(); + for (JsonNode item : prefixEncodingArray) { + if (item.isObject()) { + Encoding encoding = getEncoding((ObjectNode) item, + String.format("%s.%s", location, "prefixEncoding"), result); + if (encoding != null) { + prefixEncoding.add(encoding); + } + } else { + result.invalidType(location, "prefixEncoding", "object", item); + } + } + mediaType.setPrefixEncoding(prefixEncoding); + } + ObjectNode itemEncodingObject = getObject("itemEncoding", contentNode, false, location, result); + if (itemEncodingObject != null) { + mediaType.setItemEncoding(getEncoding(itemEncodingObject, + String.format("%s.%s", location, "itemEncoding"), result)); + } + } + Set keys = getKeys(contentNode); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("MEDIATYPE_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, contentNode.get(key)); @@ -1558,13 +1765,42 @@ public Encoding getEncoding(ObjectNode node, String location, ParseResult result encoding.setHeaders(getHeaders(headersObject, location, result, false)); } + if (result.isOpenapi32()) { + ObjectNode nestedEncoding = getObject("encoding", node, false, location, result); + if (nestedEncoding != null) { + encoding.setEncoding(getEncodingMap(nestedEncoding, + String.format("%s.%s", location, "encoding"), result)); + } + ArrayNode prefixEncodingArray = getArray("prefixEncoding", node, false, location, result); + if (prefixEncodingArray != null) { + List prefixEncoding = new ArrayList<>(); + for (JsonNode item : prefixEncodingArray) { + if (item.isObject()) { + Encoding nested = getEncoding((ObjectNode) item, + String.format("%s.%s", location, "prefixEncoding"), result); + if (nested != null) { + prefixEncoding.add(nested); + } + } else { + result.invalidType(location, "prefixEncoding", "object", item); + } + } + encoding.setPrefixEncoding(prefixEncoding); + } + ObjectNode itemEncodingObject = getObject("itemEncoding", node, false, location, result); + if (itemEncodingObject != null) { + encoding.setItemEncoding(getEncoding(itemEncodingObject, + String.format("%s.%s", location, "itemEncoding"), result)); + } + } + Map extensions = getExtensions(node); if (extensions != null && extensions.size() > 0) { encoding.setExtensions(extensions); } Set keys = getKeys(node); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("ENCODING_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, node.get(key)); @@ -1681,7 +1917,7 @@ public Link getLink(ObjectNode linkNode, String location, ParseResult result) { } Set keys = getKeys(linkNode); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("LINK_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, linkNode.get(key)); @@ -1807,6 +2043,17 @@ public XML getXml(ObjectNode node, String location, ParseResult result) { xml.setWrapped(wrapped); } + if (result.isOpenapi32()) { + value = getString("nodeType", node, false, String.format("%s.%s", location, "nodeType"), result); + if ((result.isAllowEmptyStrings() && value != null) || (!result.isAllowEmptyStrings() && !StringUtils.isBlank(value))) { + xml.setNodeType(value); + } + if (value != null && (attribute != null || wrapped != null)) { + result.warning(location, + "nodeType cannot be used together with attribute/wrapped"); + } + } + Map extensions = getExtensions(node); if (extensions != null && extensions.size() > 0) { xml.setExtensions(extensions); @@ -1814,7 +2061,7 @@ public XML getXml(ObjectNode node, String location, ParseResult result) { Set keys = getKeys(node); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("XML_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, node.get(key)); @@ -1971,11 +2218,29 @@ public List getParameterList(ArrayNode obj, String location, ParseRes } } }); + if (result.isOpenapi32()) { + long queryStringParams = parameters.stream() + .map(this::getParameterDefinition) + .filter(p -> QUERYSTRING_PARAMETER.equals(p.getIn())) + .count(); + if (queryStringParams > 1) { + result.warning(location, "There can be only one in: querystring parameter per location"); + } + if (queryStringParams > 0 && parameters.stream() + .map(this::getParameterDefinition) + .anyMatch(p -> QUERY_PARAMETER.equals(p.getIn()))) { + result.warning(location, + "in: querystring parameter cannot be combined with in: query parameters"); + } + } return parameters; } private Parameter getParameterDefinition(Parameter parameter) { - if (parameter.get$ref() == null) { + if (parameter.get$ref() == null || !parameter.get$ref().startsWith("#/")) { + // non-local refs are not resolvable here; an external ref like + // "external.yaml#/components/parameters/q" must not be compared + // against an unrelated local parameter that happens to share the name return parameter; } Object parameterSchemaName = extractSimpleName(parameter.get$ref()).getLeft(); @@ -2042,10 +2307,14 @@ public Parameter getParameter(ObjectNode obj, String location, ParseResult resul parameter = new PathParameter(); } else if (COOKIE_PARAMETER.equals(value)) { parameter = new CookieParameter(); + } else if (QUERYSTRING_PARAMETER.equals(value) && result.isOpenapi32()) { + parameter = new QueryStringParameter(); } if (parameter == null) { - result.invalidType(location, "in", "[query|header|path|cookie]", obj); + result.invalidType(location, "in", result.isOpenapi32() + ? "[query|header|path|cookie|querystring]" + : "[query|header|path|cookie]", obj); return null; } @@ -2132,6 +2401,27 @@ else if(parameter.getSchema() == null) { result.missing(location,"content"); } + if (QUERYSTRING_PARAMETER.equals(parameter.getIn())) { + // OpenAPI 3.2: a querystring parameter describes the whole query + // string through content; schema/style/explode/allowReserved/ + // allowEmptyValue/example(s) are not applicable + if (parameter.getSchema() != null) { + result.warning(location, "in: querystring parameter must not define schema"); + } + // when schema is absent the generic branch above already reports + // missing content; report it here only when it was suppressed + if (parameter.getContent() == null && parameter.getSchema() != null) { + result.missing(location, "content"); + } + for (String forbidden : Arrays.asList("style", "explode", "allowReserved", + "allowEmptyValue")) { + if (obj.get(forbidden) != null) { + result.warning(location, + "in: querystring parameter must not define " + forbidden); + } + } + } + value = getString("style", obj, false, location, result); if (parameter.getContent() == null) { setStyle(value, parameter, location, obj, result); @@ -2139,7 +2429,8 @@ else if(parameter.getSchema() == null) { Boolean explode = getBoolean("explode", obj, false, location, result); if (explode != null) { parameter.setExplode(explode); - } else if (StyleEnum.FORM.equals(parameter.getStyle()) && result.isExplicitStyleAndExplode()) { + } else if ((StyleEnum.FORM.equals(parameter.getStyle()) + || StyleEnum.COOKIE.equals(parameter.getStyle())) && result.isExplicitStyleAndExplode()) { parameter.setExplode(Boolean.TRUE); } else if (result.isExplicitStyleAndExplode()){ parameter.setExplode(Boolean.FALSE); @@ -2152,7 +2443,7 @@ else if(parameter.getSchema() == null) { } Set keys = getKeys(obj); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("PARAMETER_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, obj.get(key)); @@ -2295,7 +2586,7 @@ public Header getHeader(ObjectNode headerNode, String location, ParseResult resu } Set oAuthFlowKeys = getKeys(headerNode); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : oAuthFlowKeys) { if (!specKeys.get("HEADER_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, headerNode.get(key)); @@ -2471,13 +2762,24 @@ public SecurityScheme getSecurityScheme(ObjectNode node, String location, ParseR securityScheme.setOpenIdConnectUrl(value); } + if (result.isOpenapi32()) { + Boolean deprecatedFlag = getBoolean("deprecated", node, false, location, result); + if (deprecatedFlag != null) { + securityScheme.setDeprecated(deprecatedFlag); + } + value = getString("oauth2MetadataUrl", node, false, location, result); + if ((result.isAllowEmptyStrings() && value != null) || (!result.isAllowEmptyStrings() && !StringUtils.isBlank(value))) { + securityScheme.setOauth2MetadataUrl(value); + } + } + Map extensions = getExtensions(node); if (extensions != null && extensions.size() > 0) { securityScheme.setExtensions(extensions); } Set securitySchemeKeys = getKeys(node); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : securitySchemeKeys) { if (!specKeys.get("SECURITY_SCHEME_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, node.get(key)); @@ -2514,13 +2816,20 @@ public OAuthFlows getOAuthFlows(ObjectNode node, String location, ParseResult re oAuthFlows.setAuthorizationCode(getOAuthFlow("authorizationCode", objectNode, location, result)); } + if (result.isOpenapi32()) { + objectNode = getObject("deviceAuthorization", node, false, location, result); + if (objectNode != null) { + oAuthFlows.setDeviceAuthorization(getOAuthFlow("deviceAuthorization", objectNode, location, result)); + } + } + Map extensions = getExtensions(node); if (extensions != null && extensions.size() > 0) { oAuthFlows.setExtensions(extensions); } Set oAuthFlowKeys = getKeys(node); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : oAuthFlowKeys) { if (!specKeys.get("OAUTHFLOWS_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, node.get(key)); @@ -2538,8 +2847,9 @@ public OAuthFlow getOAuthFlow(String oAuthFlowType, ObjectNode node, String loca OAuthFlow oAuthFlow = new OAuthFlow(); - boolean authorizationUrlRequired, tokenUrlRequired, refreshUrlRequired, scopesRequired; - authorizationUrlRequired = tokenUrlRequired = refreshUrlRequired = false; + boolean authorizationUrlRequired, tokenUrlRequired, refreshUrlRequired, scopesRequired, + deviceAuthorizationUrlRequired; + authorizationUrlRequired = tokenUrlRequired = refreshUrlRequired = deviceAuthorizationUrlRequired = false; scopesRequired = true; switch (oAuthFlowType) { case "implicit": @@ -2554,9 +2864,21 @@ public OAuthFlow getOAuthFlow(String oAuthFlowType, ObjectNode node, String loca case "authorizationCode": authorizationUrlRequired = tokenUrlRequired = true; break; + case "deviceAuthorization": + // OpenAPI 3.2 + deviceAuthorizationUrlRequired = tokenUrlRequired = true; + break; } - String value = getString("authorizationUrl", node, authorizationUrlRequired, location, result); + String value = null; + if (result.isOpenapi32()) { + value = getString("deviceAuthorizationUrl", node, deviceAuthorizationUrlRequired, location, result); + if ((result.isAllowEmptyStrings() && value != null) || (!result.isAllowEmptyStrings() && !StringUtils.isBlank(value))) { + oAuthFlow.setDeviceAuthorizationUrl(value); + } + } + + value = getString("authorizationUrl", node, authorizationUrlRequired, location, result); if ((result.isAllowEmptyStrings() && value != null) || (!result.isAllowEmptyStrings() && !StringUtils.isBlank(value))) { oAuthFlow.setAuthorizationUrl(value); } @@ -2589,7 +2911,7 @@ public OAuthFlow getOAuthFlow(String oAuthFlowType, ObjectNode node, String loca } Set oAuthFlowKeys = getKeys(node); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : oAuthFlowKeys) { if (!specKeys.get("OAUTHFLOW_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, node.get(key)); @@ -2648,6 +2970,13 @@ public Discriminator getDiscriminator(ObjectNode node, String location, ParseRes discriminator.setMapping(mapping); } + if (result.isOpenapi32()) { + String defaultMapping = getString("defaultMapping", node, false, location, result); + if (StringUtils.isNotBlank(defaultMapping)) { + discriminator.setDefaultMapping(defaultMapping); + } + } + if(result.isOpenapi31()) { Set keys = getKeys(node); for (String key : keys) { @@ -3375,13 +3704,27 @@ public Example getExample(ObjectNode node, String location, ParseResult result) result.warning(location, " value and externalValue are both present"); } + if (result.isOpenapi32()) { + Object dataValue = getAnyType("dataValue", node, location, result); + if (dataValue != null) { + example.setDataValue(dataValue instanceof NullNode ? null : dataValue); + example.setDataValueSetFlag(true); + } + + String serializedValue = getString("serializedValue", node, false, location, result); + if ((result.isAllowEmptyStrings() && serializedValue != null) + || (!result.isAllowEmptyStrings() && !StringUtils.isBlank(serializedValue))) { + example.setSerializedValue(serializedValue); + } + } + Map extensions = getExtensions(node); if (extensions != null && extensions.size() > 0) { example.setExtensions(extensions); } Set keys = getKeys(node); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("EXAMPLE_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, node.get(key)); @@ -3417,6 +3760,13 @@ public void setStyle(String value, Parameter parameter, String location, ObjectN parameter.setStyle(StyleEnum.SIMPLE); } else if (value.equals(StyleEnum.SPACEDELIMITED.toString())) { parameter.setStyle(StyleEnum.SPACEDELIMITED); + } else if (value.equals(StyleEnum.COOKIE.toString()) && result.isOpenapi32()) { + // OpenAPI 3.2: 'cookie' style is defined for in: cookie parameters + parameter.setStyle(StyleEnum.COOKIE); + if (!COOKIE_PARAMETER.equals(parameter.getIn())) { + result.warning(location, + "style: cookie is only defined for in: cookie parameters"); + } } else { result.invalidType(location, "style", "StyleEnum", obj); } @@ -3496,6 +3846,13 @@ public ApiResponse getResponse(ObjectNode node, String location, ParseResult res apiResponse.description(value); } + if (result.isOpenapi32()) { + value = getString("summary", node, false, location, result); + if ((result.isAllowEmptyStrings() && value != null) || (!result.isAllowEmptyStrings() && !StringUtils.isBlank(value))) { + apiResponse.setSummary(value); + } + } + ObjectNode headerObject = getObject("headers", node, false, location, result); if (headerObject != null) { @@ -3524,7 +3881,7 @@ public ApiResponse getResponse(ObjectNode node, String location, ParseResult res } Set keys = getKeys(node); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("RESPONSE_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, node.get(key)); @@ -3632,7 +3989,7 @@ public Operation getOperation(ObjectNode obj, String location, ParseResult resul } Set keys = getKeys(obj); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("OPERATION_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, obj.get(key)); @@ -3771,7 +4128,7 @@ public RequestBody getRequestBody(ObjectNode node, String location, ParseResult } Set keys = getKeys(node); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("REQUEST_BODY_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, node.get(key)); @@ -4300,6 +4657,7 @@ public static class ParseResult { private boolean inferSchemaType = true; private boolean openapi31 = false; + private boolean openapi32 = false; private boolean oaiAuthor = false; private boolean explicitStyleAndExplode = true; @@ -4386,6 +4744,18 @@ public boolean isOpenapi31() { return this.openapi31; } + public void setOpenapi32(boolean openapi32) { + this.openapi32 = openapi32; + } + + public ParseResult openapi32(boolean openapi32) { + this.openapi32 = openapi32; + return this; + } + public boolean isOpenapi32() { + return this.openapi32; + } + public boolean isOaiAuthor() { return this.oaiAuthor; } diff --git a/modules/swagger-parser-v3/src/test/java/io/swagger/v3/parser/test/OAI32DeserializationTest.java b/modules/swagger-parser-v3/src/test/java/io/swagger/v3/parser/test/OAI32DeserializationTest.java new file mode 100644 index 0000000000..b3d72af08e --- /dev/null +++ b/modules/swagger-parser-v3/src/test/java/io/swagger/v3/parser/test/OAI32DeserializationTest.java @@ -0,0 +1,1165 @@ +package io.swagger.v3.parser.test; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.parser.OpenAPIV3Parser; +import io.swagger.v3.parser.core.models.ParseOptions; +import io.swagger.v3.parser.core.models.SwaggerParseResult; +import org.testng.annotations.Test; + +import static org.testng.Assert.*; + +public class OAI32DeserializationTest { + + @Test(description = "OpenAPI 3.2.0 version string is recognized and parsed") + public void testBasicOAS320() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: Swagger Petstore\n" + + " version: 1.0.0\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertNotNull(result.getOpenAPI()); + assertEquals(result.getOpenAPI().getOpenapi(), "3.2.0"); + assertNotNull(result.getOpenAPI().getPaths().get("/pets").getGet()); + // 3.2 shares the 3.1 data model code path + assertTrue(result.isOpenapi31()); + } + + @Test(description = "OpenAPI 3.2.1 version string is recognized and parsed") + public void testBasicOAS321() { + String json = "{\n" + + " \"openapi\": \"3.2.1\",\n" + + " \"info\": {\n" + + " \"title\": \"Swagger Petstore\",\n" + + " \"version\": \"1.0.0\"\n" + + " },\n" + + " \"paths\": {}\n" + + "}"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(json, null, null); + assertNotNull(result.getOpenAPI()); + assertEquals(result.getOpenAPI().getOpenapi(), "3.2.1"); + assertTrue(result.isOpenapi31()); + } + + @Test(description = "3.2-specific fields are bound to the model") + public void testOAS32SpecificFieldsRecorded() { + String yaml = "openapi: 3.2.0\n" + + "$self: https://example.com/api/openapi.yaml\n" + + "jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema\n" + + "info:\n" + + " title: Swagger Petstore\n" + + " version: 1.0.0\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " query:\n" + + " operationId: queryPets\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + OpenAPI openAPI = result.getOpenAPI(); + assertNotNull(openAPI); + assertNotNull(openAPI.getPaths().get("/pets").getGet()); + // 3.2 members bind to the model now + assertEquals(openAPI.get$self(), "https://example.com/api/openapi.yaml"); + assertNotNull(openAPI.getPaths().get("/pets").getQuery()); + assertEquals(openAPI.getPaths().get("/pets").getQuery().getOperationId(), "queryPets"); + assertTrue(result.isOpenapi32()); + assertEquals(openAPI.getSpecVersion(), io.swagger.v3.oas.models.SpecVersion.V32); + // 3.1-family fields continue to work + assertEquals(openAPI.getJsonSchemaDialect(), "https://json-schema.org/draft/2020-12/schema"); + } + + @Test(description = "3.2 document parses with resolveFully option without crashing") + public void testOAS32ResolveFully() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: Swagger Petstore\n" + + " version: 1.0.0\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " $ref: '#/components/schemas/Pet'\n" + + "components:\n" + + " schemas:\n" + + " Pet:\n" + + " type: object\n" + + " properties:\n" + + " name:\n" + + " type: string\n"; + ParseOptions options = new ParseOptions(); + options.setResolveFully(true); + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, options); + OpenAPI openAPI = result.getOpenAPI(); + assertNotNull(openAPI); + assertEquals(openAPI.getOpenapi(), "3.2.0"); + // the $ref is actually resolved through the 3.1-family dereference path + Schema schema = openAPI.getPaths().get("/pets").getGet().getResponses().get("200") + .getContent().get("application/json").getSchema(); + assertNull(schema.get$ref()); + assertNotNull(schema.getProperties().get("name")); + } + + @Test(description = "3.2 resolve routes through the 3.1-family dereferencer: components.pathItems refs resolve") + public void testOAS32ResolveRoutesTo31Dereferencer() { + // components.pathItems is a 3.1+ feature the legacy (3.0) resolver cannot resolve: + // ResolverCache has no pattern for #/components/pathItems, so under the wrong + // routing this assertion fails (the pathItem stays an unresolved $ref with no ops) + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: Swagger Petstore\n" + + " version: 1.0.0\n" + + "paths:\n" + + " /pets:\n" + + " $ref: '#/components/pathItems/PetOps'\n" + + "components:\n" + + " pathItems:\n" + + " PetOps:\n" + + " get:\n" + + " operationId: listPets\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + ParseOptions options = new ParseOptions(); + options.setResolveFully(true); + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, options); + OpenAPI openAPI = result.getOpenAPI(); + assertNotNull(openAPI); + assertNotNull(openAPI.getPaths().get("/pets").getGet()); + assertEquals(openAPI.getPaths().get("/pets").getGet().getOperationId(), "listPets"); + } + + @Test(description = "3.2 'in: querystring' parameter binds as QueryStringParameter; missing content is reported") + public void testOAS32QuerystringParamReported() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: Swagger Petstore\n" + + " version: 1.0.0\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " parameters:\n" + + " - in: querystring\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: object\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + OpenAPI openAPI = result.getOpenAPI(); + assertNotNull(openAPI); + assertEquals(openAPI.getPaths().get("/pets").getGet().getParameters().size(), 1); + assertTrue(openAPI.getPaths().get("/pets").getGet().getParameters().get(0) + instanceof io.swagger.v3.oas.models.parameters.QueryStringParameter); + assertNotNull(openAPI.getPaths().get("/pets").getGet().getParameters().get(0).getContent()); + + // querystring without content is invalid per spec + String noContent = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: 1.0.0\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " parameters:\n" + + " - in: querystring\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + SwaggerParseResult bad = new OpenAPIV3Parser().readContents(noContent, null, null); + assertTrue(bad.getMessages().stream().anyMatch(m -> m.contains("content"))); + } + + @Test(description = "bare 3.2 (no patch version) is accepted per upstream's loose convention, with a warning") + public void testBareVersionString() { + // mirrors upstream's handling of bare "3.0"/"3.1" (see OpenAPIV3ParserTest#testIssue1780): + // accepted and routed to the 3.1-family model, but reported as not a valid version field + String yaml = "openapi: '3.2'\n" + + "info:\n" + + " title: Swagger Petstore\n" + + " version: 1.0.0\n" + + "paths: {}\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertNotNull(result.getOpenAPI()); + assertEquals(result.getOpenAPI().getOpenapi(), "3.2"); + assertTrue(result.isOpenapi31()); + assertTrue(result.getMessages().contains("The provided definition does not specify a valid version field")); + } + + @Test(description = "loose prefix: 3.20.x is accepted because it starts with \"3.2\" (prefix match, not true 3.x support -- 3.3.0 is rejected), and warned since it has no 3.2. dotted form") + public void testVersionPrefixBoundary() { + String yaml = "openapi: 3.20.0\n" + + "info:\n" + + " title: Swagger Petstore\n" + + " version: 1.0.0\n" + + "paths: {}\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertNotNull(result.getOpenAPI()); + assertTrue(result.isOpenapi31()); + assertTrue(result.getMessages().contains("The provided definition does not specify a valid version field")); + } + + @Test(description = "malformed version 3.2. (trailing dot, no patch) still parses; no warning fires, matching the pre-existing 3.0./3.1. quirk this change extends rather than fixes") + public void testMalformedVersionString() { + // "3.2." satisfies startsWith("3.2."), so the dotted-form warning check does not + // fire for it, the same way "3.0." and "3.1." alone would not warn either. This is + // an inherited quirk of the pre-existing convention, not something new to 3.2 -- + // asserted explicitly here so a future change to the 3.0/3.1 behavior doesn't + // silently leave 3.2 inconsistent with it. + String yaml = "openapi: '3.2.'\n" + + "info:\n" + + " title: Swagger Petstore\n" + + " version: 1.0.0\n" + + "paths: {}\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertNotNull(result.getOpenAPI()); + assertEquals(result.getOpenAPI().getOpenapi(), "3.2."); + assertTrue(result.isOpenapi31()); + assertFalse(result.getMessages().contains("The provided definition does not specify a valid version field")); + } + + @Test(description = "3.3.0 is genuinely rejected (unlike 3.20.0, which is only accepted by prefix-match accident)") + public void testUnrecognizedMinorVersionRejected() { + String yaml = "openapi: 3.3.0\n" + + "info:\n" + + " title: Swagger Petstore\n" + + " version: 1.0.0\n" + + "paths: {}\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertNull(result.getOpenAPI()); + } + + @Test(description = "additionalOperations bind to the PathItem model; keys keep their case") + public void testOAS32AdditionalOperations() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " operationId: getPets\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " additionalOperations:\n" + + " PURGE:\n" + + " operationId: purgePets\n" + + " responses:\n" + + " '204':\n" + + " description: purged\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + OpenAPI openAPI = result.getOpenAPI(); + assertNotNull(openAPI); + assertNotNull(openAPI.getPaths().get("/pets").getAdditionalOperations()); + assertEquals(openAPI.getPaths().get("/pets").getAdditionalOperations() + .get("PURGE").getOperationId(), "purgePets"); + } + + @Test(description = "additionalOperations key duplicating a fixed method is detected") + public void testOAS32AdditionalOperationsDuplicateDetected() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " additionalOperations:\n" + + " get:\n" + + " operationId: dupGet\n" + + " responses:\n" + + " '200':\n" + + " description: dup\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertTrue(result.getMessages().stream() + .anyMatch(m -> m.contains("duplicates a fixed Path Item method name"))); + // lenient: the operation is still bound + assertNotNull(result.getOpenAPI().getPaths().get("/pets").getAdditionalOperations().get("get")); + } + + @Test(description = "additionalOperations work inside webhooks and components.pathItems") + public void testOAS32AdditionalOperationsNested() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "webhooks:\n" + + " hook:\n" + + " additionalOperations:\n" + + " NOTIFY:\n" + + " operationId: notify\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + "components:\n" + + " pathItems:\n" + + " Shared:\n" + + " additionalOperations:\n" + + " SEARCH:\n" + + " operationId: search\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + OpenAPI openAPI = result.getOpenAPI(); + assertNotNull(openAPI); + assertEquals(openAPI.getWebhooks().get("hook").getAdditionalOperations() + .get("NOTIFY").getOperationId(), "notify"); + assertEquals(openAPI.getComponents().getPathItems().get("Shared") + .getAdditionalOperations().get("SEARCH").getOperationId(), "search"); + } + + @Test(description = "in: querystring is rejected for 3.1 documents") + public void testQuerystringRejectedOn31() { + String yaml = "openapi: 3.1.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " parameters:\n" + + " - in: querystring\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: object\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertFalse(result.isOpenapi32()); + assertTrue(result.getMessages().stream() + .anyMatch(m -> m.contains("in is not of type"))); + } + + @Test(description = "duplicate querystring parameters and querystring+query coexistence are detected") + public void testQuerystringConstraints() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " parameters:\n" + + " - in: querystring\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: object\n" + + " - in: querystring\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: object\n" + + " - name: q\n" + + " in: query\n" + + " schema:\n" + + " type: string\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertTrue(result.getMessages().stream() + .anyMatch(m -> m.contains("only one in: querystring"))); + assertTrue(result.getMessages().stream() + .anyMatch(m -> m.contains("cannot be combined with in: query"))); + } + + @Test(description = "style: cookie binds for in: cookie in 3.2 (explode defaults true), warns on other locations, rejected for 3.1") + public void testCookieStyle() { + String responses = " responses:\n" + + " '200':\n" + + " description: ok\n"; + // valid: in: cookie + style: cookie (explode defaults to true per spec) + String validParams = " parameters:\n" + + " - name: session\n" + + " in: cookie\n" + + " style: cookie\n" + + " schema:\n" + + " type: string\n"; + SwaggerParseResult v32 = new OpenAPIV3Parser().readContents( + "openapi: 3.2.0\ninfo:\n title: t\n version: '1'\npaths:\n /p:\n get:\n" + + validParams + responses, null, null); + io.swagger.v3.oas.models.parameters.Parameter cookieParam = + v32.getOpenAPI().getPaths().get("/p").getGet().getParameters().get(0); + assertEquals(cookieParam.getStyle(), + io.swagger.v3.oas.models.parameters.Parameter.StyleEnum.COOKIE); + assertEquals(cookieParam.getExplode(), Boolean.TRUE); + assertFalse(v32.getMessages().stream() + .anyMatch(m -> m.contains("only defined for in: cookie"))); + + // invalid: style: cookie with in: query + String invalidParams = " parameters:\n" + + " - name: session\n" + + " in: query\n" + + " style: cookie\n" + + " schema:\n" + + " type: string\n"; + SwaggerParseResult v32bad = new OpenAPIV3Parser().readContents( + "openapi: 3.2.0\ninfo:\n title: t\n version: '1'\npaths:\n /p:\n get:\n" + + invalidParams + responses, null, null); + assertTrue(v32bad.getMessages().stream() + .anyMatch(m -> m.contains("only defined for in: cookie"))); + + SwaggerParseResult v31 = new OpenAPIV3Parser().readContents( + "openapi: 3.1.0\ninfo:\n title: t\n version: '1'\npaths:\n /p:\n get:\n" + + validParams + responses, null, null); + assertTrue(v31.getMessages().stream() + .anyMatch(m -> m.contains("style is not of type"))); + } + + @Test(description = "components.mediaTypes and content map $ref values bind under 3.2") + public void testOAS32MediaTypesAndContentRef() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " content:\n" + + " application/json:\n" + + " $ref: '#/components/mediaTypes/Pet'\n" + + "components:\n" + + " mediaTypes:\n" + + " Pet:\n" + + " schema:\n" + + " type: object\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + OpenAPI openAPI = result.getOpenAPI(); + assertNotNull(openAPI); + assertEquals(openAPI.getPaths().get("/pets").getGet().getResponses().get("200") + .getContent().get("application/json").get$ref(), + "#/components/mediaTypes/Pet"); + assertNotNull(openAPI.getComponents().getMediaTypes()); + assertNotNull(openAPI.getComponents().getMediaTypes().get("Pet").getSchema()); + } + + @Test(description = "content map $ref is not bound under 3.1 (MediaType has no $ref there)") + public void testContentRefNotBound31() { + String yaml = "openapi: 3.1.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " content:\n" + + " application/json:\n" + + " $ref: '#/components/mediaTypes/Pet'\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + OpenAPI openAPI = result.getOpenAPI(); + assertNotNull(openAPI); + assertNull(openAPI.getPaths().get("/pets").getGet().getResponses().get("200") + .getContent().get("application/json").get$ref()); + assertTrue(result.getMessages().stream() + .anyMatch(m -> m.contains("$ref") && m.contains("unexpected"))); + } + + @Test(description = "3.2 scalar fields bind: server.name, tag.summary/parent/kind, response.summary, example.dataValue/serializedValue, securityScheme.deprecated/oauth2MetadataUrl, deviceAuthorization flow, xml.nodeType, discriminator.defaultMapping, mediaType.itemSchema/encoding") + public void testOAS32NewScalarFields() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "servers:\n" + + " - url: https://api.example.com\n" + + " name: production\n" + + "tags:\n" + + " - name: pet\n" + + " summary: Pet operations\n" + + " parent: root\n" + + " kind: nav\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " summary: pet list\n" + + " content:\n" + + " application/json:\n" + + " itemSchema:\n" + + " type: string\n" + + "components:\n" + + " securitySchemes:\n" + + " oauth:\n" + + " type: oauth2\n" + + " deprecated: true\n" + + " oauth2MetadataUrl: https://example.com/.well-known/oauth-authorization-server\n" + + " flows:\n" + + " deviceAuthorization:\n" + + " deviceAuthorizationUrl: https://example.com/device\n" + + " tokenUrl: https://example.com/token\n" + + " scopes: {}\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + OpenAPI openAPI = result.getOpenAPI(); + assertNotNull(openAPI); + assertEquals(openAPI.getServers().get(0).getName(), "production"); + assertEquals(openAPI.getTags().get(0).getSummary(), "Pet operations"); + assertEquals(openAPI.getTags().get(0).getParent(), "root"); + assertEquals(openAPI.getTags().get(0).getKind(), "nav"); + assertEquals(openAPI.getPaths().get("/pets").getGet().getResponses().get("200") + .getSummary(), "pet list"); + assertNotNull(openAPI.getPaths().get("/pets").getGet().getResponses().get("200") + .getContent().get("application/json").getItemSchema()); + io.swagger.v3.oas.models.security.SecurityScheme scheme = + openAPI.getComponents().getSecuritySchemes().get("oauth"); + assertEquals(scheme.getDeprecated(), Boolean.TRUE); + assertEquals(scheme.getOauth2MetadataUrl(), + "https://example.com/.well-known/oauth-authorization-server"); + assertEquals(scheme.getFlows().getDeviceAuthorization().getDeviceAuthorizationUrl(), + "https://example.com/device"); + assertEquals(scheme.getFlows().getDeviceAuthorization().getTokenUrl(), + "https://example.com/token"); + } + + @Test(description = "3.2 example dataValue/serializedValue bind; value+dataValue coexistence warns") + public void testOAS32ExampleFields() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths: {}\n" + + "components:\n" + + " examples:\n" + + " ex:\n" + + " dataValue:\n" + + " name: fido\n" + + " serializedValue: '{\"name\":\"fido\"}'\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + io.swagger.v3.oas.models.examples.Example ex = + result.getOpenAPI().getComponents().getExamples().get("ex"); + assertNotNull(ex.getDataValue()); + assertEquals(ex.getSerializedValue(), "{\"name\":\"fido\"}"); + } + + @Test(description = "3.2 xml.nodeType and discriminator.defaultMapping bind") + public void testOAS32XmlAndDiscriminator() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths: {}\n" + + "components:\n" + + " schemas:\n" + + " Pet:\n" + + " type: object\n" + + " xml:\n" + + " nodeType: element\n" + + " discriminator:\n" + + " propertyName: kind\n" + + " defaultMapping: '#/components/schemas/Dog'\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + io.swagger.v3.oas.models.media.Schema pet = + result.getOpenAPI().getComponents().getSchemas().get("Pet"); + assertEquals(pet.getXml().getNodeType(), "element"); + assertEquals(pet.getDiscriminator().getDefaultMapping(), "#/components/schemas/Dog"); + } + + @Test(description = "3.2 nested encodings (encoding/prefixEncoding/itemEncoding) bind") + public void testOAS32NestedEncodings() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /upload:\n" + + " post:\n" + + " requestBody:\n" + + " content:\n" + + " multipart/mixed:\n" + + " schema:\n" + + " type: object\n" + + " prefixEncoding:\n" + + " - contentType: application/json\n" + + " itemEncoding:\n" + + " contentType: text/plain\n" + + " encoding:\n" + + " part:\n" + + " contentType: application/octet-stream\n" + + " itemEncoding:\n" + + " contentType: application/json\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + io.swagger.v3.oas.models.media.MediaType mt = result.getOpenAPI().getPaths() + .get("/upload").getPost().getRequestBody().getContent().get("multipart/mixed"); + assertEquals(mt.getPrefixEncoding().size(), 1); + assertEquals(mt.getPrefixEncoding().get(0).getContentType(), "application/json"); + assertEquals(mt.getItemEncoding().getContentType(), "text/plain"); + assertEquals(mt.getEncoding().get("part").getItemEncoding().getContentType(), + "application/json"); + } + + @Test(description = "3.2 fields are reported unexpected and not bound under 3.1") + public void testOAS32FieldsNotBound31() { + String yaml = "openapi: 3.1.0\n" + + "$self: https://example.com/openapi.yaml\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "servers:\n" + + " - url: https://api.example.com\n" + + " name: production\n" + + "paths:\n" + + " /pets:\n" + + " query:\n" + + " operationId: q\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + "components:\n" + + " mediaTypes:\n" + + " Pet:\n" + + " schema:\n" + + " type: object\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + OpenAPI openAPI = result.getOpenAPI(); + assertNotNull(openAPI); + assertFalse(result.isOpenapi32()); + assertNull(openAPI.get$self()); + assertNull(openAPI.getServers().get(0).getName()); + assertNull(openAPI.getPaths().get("/pets").getQuery()); + assertNull(openAPI.getComponents().getMediaTypes()); + assertTrue(result.getMessages().contains("attribute $self is unexpected")); + assertTrue(result.getMessages().contains("attribute .servers.name is unexpected")); + assertTrue(result.getMessages().contains("attribute paths.'/pets'.query is unexpected")); + assertTrue(result.getMessages().contains("attribute components.mediaTypes is unexpected")); + } + + @Test(description = "JSON round-trip: parse -> serialize via Json32 -> re-parse preserves 3.2 fields") + public void testOAS32JsonRoundTrip() throws Exception { + String json = "{\n" + + " \"openapi\": \"3.2.0\",\n" + + " \"$self\": \"https://example.com/openapi.json\",\n" + + " \"info\": {\"title\": \"t\", \"version\": \"1\"},\n" + + " \"paths\": {\n" + + " \"/pets\": {\n" + + " \"get\": {\n" + + " \"operationId\": \"getPets\",\n" + + " \"responses\": {\"200\": {\"description\": \"ok\",\n" + + " \"content\": {\"application/json\": {\"$ref\": \"#/components/mediaTypes/Pet\"}}}},\n" + + " \"parameters\": [{\"in\": \"querystring\",\n" + + " \"content\": {\"application/json\": {\"schema\": {\"type\": \"object\"}}}}]\n" + + " },\n" + + " \"additionalOperations\": {\"PURGE\": {\"operationId\": \"purge\",\n" + + " \"responses\": {\"204\": {\"description\": \"done\"}}}}\n" + + " }\n" + + " },\n" + + " \"components\": {\"mediaTypes\": {\"Pet\": {\"schema\": {\"type\": \"object\"}}}}\n" + + "}"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(json, null, null); + OpenAPI openAPI = result.getOpenAPI(); + assertNotNull(openAPI); + + String serialized = io.swagger.v3.core.util.Json32.pretty(openAPI); + SwaggerParseResult reparsed = new OpenAPIV3Parser().readContents(serialized, null, null); + OpenAPI again = reparsed.getOpenAPI(); + assertNotNull(again); + assertEquals(again.get$self(), "https://example.com/openapi.json"); + assertEquals(again.getPaths().get("/pets").getAdditionalOperations() + .get("PURGE").getOperationId(), "purge"); + assertEquals(again.getPaths().get("/pets").getGet().getResponses().get("200") + .getContent().get("application/json").get$ref(), "#/components/mediaTypes/Pet"); + assertTrue(again.getPaths().get("/pets").getGet().getParameters().get(0) + instanceof io.swagger.v3.oas.models.parameters.QueryStringParameter); + assertNotNull(again.getComponents().getMediaTypes().get("Pet")); + } + + @Test(description = "YAML round-trip: parse -> serialize via Yaml32 -> re-parse preserves 3.2 fields") + public void testOAS32YamlRoundTrip() throws Exception { + String yaml = "openapi: 3.2.0\n" + + "$self: https://example.com/openapi.yaml\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " query:\n" + + " operationId: queryPets\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " additionalOperations:\n" + + " PURGE:\n" + + " operationId: purgePets\n" + + " responses:\n" + + " '204':\n" + + " description: done\n" + + "components:\n" + + " mediaTypes:\n" + + " Pet:\n" + + " schema:\n" + + " type: object\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + OpenAPI openAPI = result.getOpenAPI(); + assertNotNull(openAPI); + + String serialized = io.swagger.v3.core.util.Yaml32.pretty(openAPI); + SwaggerParseResult reparsed = new OpenAPIV3Parser().readContents(serialized, null, null); + OpenAPI again = reparsed.getOpenAPI(); + assertNotNull(again); + assertEquals(again.get$self(), "https://example.com/openapi.yaml"); + assertEquals(again.getPaths().get("/pets").getQuery().getOperationId(), "queryPets"); + assertEquals(again.getPaths().get("/pets").getAdditionalOperations() + .get("PURGE").getOperationId(), "purgePets"); + assertNotNull(again.getComponents().getMediaTypes().get("Pet")); + } + + @Test(description = "name remains REQUIRED for in: querystring parameters") + public void testQuerystringNameRequired() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " parameters:\n" + + " - in: querystring\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: object\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertTrue(result.getMessages().stream() + .anyMatch(m -> m.contains("name"))); + } + + @Test(description = "example/examples are permitted common fields on in: querystring parameters") + public void testQuerystringAllowsExamples() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " parameters:\n" + + " - name: query\n" + + " in: querystring\n" + + " example: a=1&b=2\n" + + " content:\n" + + " application/x-www-form-urlencoded:\n" + + " schema:\n" + + " type: object\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertFalse(result.getMessages().stream() + .anyMatch(m -> m.contains("must not define example"))); + assertNotNull(result.getOpenAPI().getPaths().get("/pets").getGet() + .getParameters().get(0).getExample()); + } + + @Test(description = "querystring/query conflict is detected across path-item and operation parameters") + public void testQuerystringCrossLevelConflict() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " parameters:\n" + + " - name: q\n" + + " in: query\n" + + " schema:\n" + + " type: string\n" + + " get:\n" + + " parameters:\n" + + " - name: qs\n" + + " in: querystring\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: object\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertTrue(result.getMessages().stream() + .anyMatch(m -> m.contains("cannot be combined with in: query"))); + + // conflict via a local components.parameters $ref is also detected + String refYaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " parameters:\n" + + " - $ref: '#/components/parameters/q'\n" + + " - name: qs\n" + + " in: querystring\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: object\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + "components:\n" + + " parameters:\n" + + " q:\n" + + " name: q\n" + + " in: query\n" + + " schema:\n" + + " type: string\n"; + SwaggerParseResult refResult = new OpenAPIV3Parser().readContents(refYaml, null, null); + assertTrue(refResult.getMessages().stream() + .anyMatch(m -> m.contains("cannot be combined with in: query"))); + } + + @Test(description = "deviceAuthorizationUrl is not bound for 3.0/3.1 documents") + public void testDeviceAuthorizationUrlVersionGated() { + String tail = "components:\n" + + " securitySchemes:\n" + + " oauth:\n" + + " type: oauth2\n" + + " flows:\n" + + " implicit:\n" + + " authorizationUrl: https://example.com/auth\n" + + " deviceAuthorizationUrl: https://example.com/device\n"; + SwaggerParseResult v31 = new OpenAPIV3Parser().readContents( + "openapi: 3.1.0\ninfo:\n title: t\n version: '1'\npaths: {}\n" + tail, null, null); + assertNull(v31.getOpenAPI().getComponents().getSecuritySchemes().get("oauth") + .getFlows().getImplicit().getDeviceAuthorizationUrl()); + + SwaggerParseResult v32 = new OpenAPIV3Parser().readContents( + "openapi: 3.2.0\ninfo:\n title: t\n version: '1'\npaths: {}\n" + tail, null, null); + assertEquals(v32.getOpenAPI().getComponents().getSecuritySchemes().get("oauth") + .getFlows().getImplicit().getDeviceAuthorizationUrl(), "https://example.com/device"); + } + + @Test(description = "boolean itemSchema is accepted") + public void testItemSchemaBoolean() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " content:\n" + + " application/json-seq:\n" + + " itemSchema: false\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertNotNull(result.getOpenAPI().getPaths().get("/pets").getGet() + .getResponses().get("200").getContent().get("application/json-seq").getItemSchema()); + assertFalse(result.getMessages().stream() + .anyMatch(m -> m.contains("itemSchema"))); + } + + @Test(description = "resolve=true preserves 3.2 fields in $ref'd PathItems and resolves MediaType references") + public void testResolvePreserves32Fields() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " $ref: '#/components/pathItems/petsPath'\n" + + "components:\n" + + " pathItems:\n" + + " petsPath:\n" + + " query:\n" + + " operationId: queryPets\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " content:\n" + + " application/json:\n" + + " $ref: '#/components/mediaTypes/PetMedia'\n" + + " additionalOperations:\n" + + " PURGE:\n" + + " operationId: purgePets\n" + + " responses:\n" + + " '204':\n" + + " description: done\n" + + " mediaTypes:\n" + + " PetMedia:\n" + + " schema:\n" + + " type: object\n"; + ParseOptions options = new ParseOptions(); + options.setResolve(true); + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, options); + OpenAPI openAPI = result.getOpenAPI(); + assertNotNull(openAPI); + // local pathItem refs resolve into components.pathItems (upstream behavior) + io.swagger.v3.oas.models.PathItem pets = + openAPI.getComponents().getPathItems().get("petsPath"); + assertNotNull(pets); + assertNotNull(pets.getQuery()); + assertEquals(pets.getQuery().getOperationId(), "queryPets"); + assertNotNull(pets.getAdditionalOperations()); + assertEquals(pets.getAdditionalOperations().get("PURGE").getOperationId(), "purgePets"); + // media type root-local ref is preserved like every other component type + // (resolved value is stored under components.mediaTypes) + io.swagger.v3.oas.models.media.MediaType mt = pets.getQuery().getResponses().get("200") + .getContent().get("application/json"); + assertNotNull(mt); + assertEquals(mt.get$ref(), "#/components/mediaTypes/PetMedia"); + assertNotNull(openAPI.getComponents().getMediaTypes()); + assertNotNull(openAPI.getComponents().getMediaTypes().get("PetMedia").getSchema()); + } + + @Test(description = "resolveFully inlines mediaType refs") + public void testResolveFullyInlinesMediaTypeRef() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " content:\n" + + " application/json:\n" + + " $ref: '#/components/mediaTypes/PetMedia'\n" + + "components:\n" + + " mediaTypes:\n" + + " PetMedia:\n" + + " schema:\n" + + " type: object\n"; + ParseOptions options = new ParseOptions(); + options.setResolveFully(true); + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, options); + io.swagger.v3.oas.models.media.MediaType mt = result.getOpenAPI().getPaths().get("/pets") + .getGet().getResponses().get("200").getContent().get("application/json"); + assertNotNull(mt); + assertNull(mt.get$ref()); + assertNotNull(mt.getSchema()); + } + + @Test(description = "forward $id refs resolve when the $id target lives in 3.2 containers") + public void testForwardIdRefsIn32Containers() { + // $id target nested inside components.mediaTypes schema + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " $ref: 'https://example.com/schemas/pet'\n" + + "components:\n" + + " mediaTypes:\n" + + " PetMedia:\n" + + " schema:\n" + + " $id: https://example.com/schemas/pet\n" + + " type: object\n" + + " properties:\n" + + " name:\n" + + " type: string\n"; + ParseOptions options = new ParseOptions(); + options.setResolve(true); + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, options); + Schema schema = result.getOpenAPI().getPaths().get("/pets").getGet() + .getResponses().get("200").getContent().get("application/json").getSchema(); + assertNotNull(schema); + assertNull(schema.get$ref()); + assertTrue(schema.getTypes().contains("object")); + + // $id target inside a query operation of components.pathItems + String yaml2 = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " $ref: 'https://example.com/schemas/pet2'\n" + + "components:\n" + + " pathItems:\n" + + " petPath:\n" + + " query:\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " $id: https://example.com/schemas/pet2\n" + + " type: object\n"; + SwaggerParseResult result2 = new OpenAPIV3Parser().readContents(yaml2, null, options); + Schema schema2 = result2.getOpenAPI().getPaths().get("/pets").getGet() + .getResponses().get("200").getContent().get("application/json").getSchema(); + assertNotNull(schema2); + assertNull(schema2.get$ref()); + assertTrue(schema2.getTypes().contains("object")); + } + + @Test(description = "querystring conflict detection works inside components.pathItems") + public void testQuerystringConflictInComponentsPathItems() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "components:\n" + + " parameters:\n" + + " q:\n" + + " name: q\n" + + " in: query\n" + + " schema:\n" + + " type: string\n" + + " pathItems:\n" + + " petsPath:\n" + + " get:\n" + + " parameters:\n" + + " - $ref: '#/components/parameters/q'\n" + + " - name: qs\n" + + " in: querystring\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: object\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertTrue(result.getMessages().stream() + .anyMatch(m -> m.contains("cannot be combined with in: query"))); + } + + @Test(description = "a same-name querystring parameter on the operation is a legal override") + public void testQuerystringOverrideNotDuplicate() { + String base = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " parameters:\n" + + " - name: qs\n" + + " in: querystring\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: object\n" + + " get:\n" + + " parameters:\n" + + " - name: %s\n" + + " in: querystring\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: object\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + // same name: operation-level param overrides the path-level one + SwaggerParseResult ok = new OpenAPIV3Parser().readContents(String.format(base, "qs"), null, null); + assertFalse(ok.getMessages().stream() + .anyMatch(m -> m.contains("querystring"))); + // different name: two distinct querystring params in the effective set + SwaggerParseResult dup = new OpenAPIV3Parser().readContents(String.format(base, "other"), null, null); + assertTrue(dup.getMessages().stream() + .anyMatch(m -> m.contains("only one in: querystring parameter per operation"))); + } + + @Test(description = "external parameter refs are not compared against unrelated local parameters") + public void testExternalRefNotMiscompared() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " parameters:\n" + + " - $ref: 'external.yaml#/components/parameters/q'\n" + + " - name: qs\n" + + " in: querystring\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: object\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + "components:\n" + + " parameters:\n" + + " q:\n" + + " name: q\n" + + " in: query\n" + + " schema:\n" + + " type: string\n"; + // the local in:query parameter "q" must not be confused with the + // unresolved external ref that happens to end with the same name + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertFalse(result.getMessages().stream() + .anyMatch(m -> m.contains("cannot be combined with in: query"))); + } + + @Test(description = "path template params are validated inside query and additionalOperations") + public void testPathTemplateValidationCoversQueryAndAdditional() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets/{petId}:\n" + + " query:\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " additionalOperations:\n" + + " PURGE:\n" + + " responses:\n" + + " '204':\n" + + " description: done\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertEquals(result.getMessages().stream() + .filter(m -> m.contains("needs to be defined")).count(), 2); + } +} diff --git a/modules/swagger-parser/src/test/java/io/swagger/parser/OpenAPIParserTest.java b/modules/swagger-parser/src/test/java/io/swagger/parser/OpenAPIParserTest.java index f8b3f3f480..192c70baa3 100644 --- a/modules/swagger-parser/src/test/java/io/swagger/parser/OpenAPIParserTest.java +++ b/modules/swagger-parser/src/test/java/io/swagger/parser/OpenAPIParserTest.java @@ -795,7 +795,8 @@ public void testIssue1552AdditionalProps() throws Exception { " type: string\n" + " additionalProperties: false\n" + " x-original-swagger-version: \"2.0\"\n" + - "openapi31: false\n"); + "openapi31: false\n" + + "openapi32: false\n"); } } diff --git a/pom.xml b/pom.xml index f21c8096eb..246ded81f5 100644 --- a/pom.xml +++ b/pom.xml @@ -362,7 +362,7 @@ 1.0.76 2.22.0 2.0.18 - 2.2.53 + 2.2.56-SNAPSHOT 1.6.16 4.13.2 7.12.0