001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.rest.server.openapi; 018 019import static org.apache.juneau.commons.utils.ObjectUtils.*; 020import static org.apache.juneau.commons.utils.Shorts.*; 021 022import java.util.*; 023import java.util.stream.*; 024 025import org.apache.juneau.bean.openapi3.OpenApi; 026import org.apache.juneau.commons.svl.*; 027import org.apache.juneau.marshall.cp.*; 028import org.apache.juneau.marshall.json.*; 029import org.apache.juneau.marshall.json5.*; 030import org.apache.juneau.marshall.jsonschema.*; 031import org.apache.juneau.marshall.marshaller.*; 032import org.apache.juneau.rest.server.*; 033import org.apache.juneau.rest.server.swagger.*; 034 035/** 036 * A single session of generating an OpenAPI 3.1 document. 037 * 038 * <p> 039 * Reuses the {@link BasicSwaggerProviderSession} machinery to produce a Swagger 2.0 document, then 040 * applies the well-known Swagger 2.0 → OpenAPI 3.1 transformation (servers from host/basePath/schemes, 041 * requestBody from in:body and in:formData parameters, content negotiation moved to per-response 042 * blocks, definitions lifted to components.schemas, $ref rewritten under 043 * {@code "#/components/schemas/"}, etc) and parses the resulting JSON into an {@link OpenApi} bean. 044 * 045 * <p> 046 * The transformation runs at the JSON level on the Json5 representation of the Swagger document, 047 * which keeps the conversion rules co-located and makes them straightforward to extend without 048 * touching the underlying annotation-walker. 049 * 050 * <h5 class='section'>See Also:</h5><ul> 051 * <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/ApiDocsMixins">API-Docs Mixin Pack</a> 052 * </ul> 053 */ 054@SuppressWarnings({ 055 "java:S115", // Field/constant identifiers mirror OpenAPI/Swagger wire-format keys (camelCase, dollar-prefixed) 056 "java:S1192", // Duplicate string literals are OpenAPI wire-format keys used in JSON map construction; intentional 057 "java:S3776" // transform(), transformOperation() and visitOperation() walk every Swagger 2 section to OpenAPI 3.1 in one pass; splitting them would obscure the mapping 058}) 059public class BasicOpenApiProviderSession { 060 061 private static final String OPENAPI_VERSION = "3.1.0"; 062 063 private static final Set<String> PARAMETER_SCHEMA_KEYS = Set.of( 064 "type", "format", "enum", "items", "default", "pattern", 065 "minLength", "maxLength", "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", 066 "minItems", "maxItems", "uniqueItems", "multipleOf", "collectionFormat" 067 ); 068 069 private final BasicSwaggerProviderSession swaggerSession; 070 private final JsonParser jp = Json5Parser.create().ignoreUnknownBeanProperties().build(); 071 072 /** 073 * Constructor. 074 * 075 * @param context The context of the REST object we're generating an OpenAPI doc for. 076 * <br>Must not be <jk>null</jk>. 077 * @param locale The language of the document we're asking for. 078 * @param ff The file finder to use for finding JSON files. 079 * @param messages The messages to use for finding localized strings. 080 * @param vr The variable resolver to use for resolving variables. 081 * @param js The JSON-schema generator to use for stuff like examples. 082 */ 083 public BasicOpenApiProviderSession(RestContext context, Locale locale, FileFinder ff, Messages messages, VarResolverSession vr, JsonSchemaGeneratorSession js) { 084 this.swaggerSession = new BasicSwaggerProviderSession(context, locale, ff, messages, vr, js); 085 } 086 087 /** 088 * Generates the OpenAPI 3.1 document. 089 * 090 * @return A new {@link OpenApi} object. 091 * @throws Exception If an error occurred producing the document. 092 */ 093 @SuppressWarnings({ 094 "java:S112" // throws Exception intentional - callback/lifecycle method 095 }) 096 public OpenApi getOpenApi() throws Exception { 097 var swagger = swaggerSession.getSwagger(); 098 // Round-trip via Json5 so we work with a plain Json5Map and can apply the spec mapping 099 // without touching the typed Swagger / OpenApi beans. 100 var swaggerMap = Json5.to(Json5.of(swagger), Json5Map.class); 101 if (swaggerMap == null) 102 swaggerMap = new Json5Map(); 103 var openApiMap = transform(swaggerMap); 104 var openApiJson = Json5R.of(openApiMap); 105 return jp.read(openApiJson, OpenApi.class); 106 } 107 108 /** 109 * Apply the Swagger 2.0 → OpenAPI 3.1 mapping to a Json5Map produced by the Swagger session. 110 * 111 * @param swagger The Swagger 2.0 representation as a Json5Map. 112 * @return A fresh {@link Json5Map} representing the OpenAPI 3.1 document. 113 */ 114 static Json5Map transform(Json5Map swagger) { 115 var out = new Json5Map(); 116 out.put("openapi", OPENAPI_VERSION); 117 118 copyIfPresent(swagger, out, "info"); 119 copyIfPresent(swagger, out, "tags"); 120 copyIfPresent(swagger, out, "externalDocs"); 121 122 // Build servers from host / basePath / schemes. 123 var servers = buildServers(swagger); 124 if (! servers.isEmpty()) 125 out.put("servers", servers); 126 127 var topConsumes = listOfStrings(swagger.get("consumes")); 128 var topProduces = listOfStrings(swagger.get("produces")); 129 130 // Paths: rewrite each operation. 131 var paths = swagger.get("paths"); 132 if (paths instanceof Map<?,?> paths2) { 133 var newPaths = new Json5Map(); 134 for (var pe : paths2.entrySet()) { 135 var path = String.valueOf(pe.getKey()); 136 if (! (pe.getValue() instanceof Map<?,?> pathItem)) 137 continue; 138 var newPathItem = new Json5Map(); 139 for (var oe : pathItem.entrySet()) { 140 var method = String.valueOf(oe.getKey()); 141 if (! (oe.getValue() instanceof Map<?,?> opMap)) 142 continue; 143 newPathItem.put(method, transformOperation(toJson5Map(opMap), topConsumes, topProduces)); 144 } 145 newPaths.put(path, newPathItem); 146 } 147 out.put("paths", newPaths); 148 } 149 150 // definitions → components.schemas 151 var components = new Json5Map(); 152 var defs = swagger.get("definitions"); 153 if (defs instanceof Map<?,?> defs2 && ! defs2.isEmpty()) { 154 var schemas = new Json5Map(); 155 for (var e : defs2.entrySet()) 156 schemas.put(String.valueOf(e.getKey()), rewriteRefs(e.getValue())); 157 components.put("schemas", schemas); 158 } 159 // securityDefinitions → components.securitySchemes 160 var secDefs = swagger.get("securityDefinitions"); 161 if (secDefs instanceof Map<?,?> secDefs2 && ! secDefs2.isEmpty()) { 162 var schemes = new Json5Map(); 163 for (var e : secDefs2.entrySet()) 164 schemes.put(String.valueOf(e.getKey()), rewriteRefs(e.getValue())); 165 components.put("securitySchemes", schemes); 166 } 167 if (! components.isEmpty()) 168 out.put("components", components); 169 170 // Rewrite all $refs anywhere in the document so subsequent passes work uniformly. 171 var rewritten = (Json5Map) rewriteRefs(out); 172 173 // Swagger 2.0 carries write-only flags as the x-writeOnly extension; OpenAPI 3.x has the native keyword. 174 restoreWriteOnly(rewritten); 175 176 // Lift inline schemas that occur in two or more operation slots into components.schemas 177 // and replace each occurrence with a $ref. Schemas already carrying a $ref are left alone. 178 return deduplicateInlineSchemas(rewritten); 179 } 180 181 /** 182 * Maps the Swagger 2.0 <js>"x-writeOnly"</js> extension back to the native OpenAPI 3.x <js>"writeOnly"</js> keyword. 183 * 184 * <p> 185 * Only walks schema positions (components.schemas, and parameter/request/response/header schemas), so user-supplied 186 * extensions elsewhere (e.g. on an operation) and example values are left untouched. 187 */ 188 private static void restoreWriteOnly(Json5Map doc) { 189 if (doc.get("components") instanceof Map<?,?> components && components.get("schemas") instanceof Map<?,?> schemas) 190 schemas.values().forEach(BasicOpenApiProviderSession::restoreWriteOnlySchema); 191 if (! (doc.get("paths") instanceof Map<?,?> paths)) 192 return; 193 for (var pathItem : paths.values()) { 194 if (! (pathItem instanceof Map<?,?> pi)) 195 continue; 196 for (var op : pi.values()) { 197 if (! (op instanceof Map<?,?> op2)) 198 continue; 199 if (op2.get("parameters") instanceof List<?> params) 200 params.forEach(BasicOpenApiProviderSession::restoreWriteOnlyHolder); 201 restoreWriteOnlyContent(op2.get("requestBody")); 202 if (op2.get("responses") instanceof Map<?,?> responses) { 203 for (var r : responses.values()) { 204 restoreWriteOnlyContent(r); 205 if (r instanceof Map<?,?> r2 && r2.get("headers") instanceof Map<?,?> headers) 206 headers.values().forEach(BasicOpenApiProviderSession::restoreWriteOnlyHolder); 207 } 208 } 209 } 210 } 211 } 212 213 /** A parameter/header/media-type object whose <js>"schema"</js> member is a schema. */ 214 private static void restoreWriteOnlyHolder(Object holder) { 215 if (holder instanceof Map<?,?> h) 216 restoreWriteOnlySchema(h.get("schema")); 217 } 218 219 private static void restoreWriteOnlyContent(Object holder) { 220 if (holder instanceof Map<?,?> h && h.get("content") instanceof Map<?,?> content) 221 content.values().forEach(BasicOpenApiProviderSession::restoreWriteOnlyHolder); 222 } 223 224 private static void restoreWriteOnlySchema(Object schema) { 225 if (! (schema instanceof Map<?,?> m)) 226 return; 227 @SuppressWarnings({ 228 "unchecked" // The instanceof Map<?,?> match is a JSON-model map, whose keys are strings. 229 }) 230 var m2 = (Map<String,Object>)m; 231 if (Boolean.TRUE.equals(m2.get("x-writeOnly"))) { 232 m2.remove("x-writeOnly"); 233 m2.put("writeOnly", true); 234 } 235 for (var k : new String[]{"properties", "patternProperties", "definitions", "$defs"}) { 236 if (m2.get(k) instanceof Map<?,?> children) 237 children.values().forEach(BasicOpenApiProviderSession::restoreWriteOnlySchema); 238 } 239 for (var k : new String[]{"items", "additionalProperties", "not"}) 240 restoreWriteOnlySchema(m2.get(k)); 241 for (var k : new String[]{"allOf", "anyOf", "oneOf", "items"}) { 242 if (m2.get(k) instanceof List<?> l) 243 l.forEach(BasicOpenApiProviderSession::restoreWriteOnlySchema); 244 } 245 } 246 247 /** 248 * Walks all operation-level schema slots ({@code parameters[*].schema}, 249 * {@code requestBody.content[*].schema}, {@code responses[*].content[*].schema} — and any 250 * {@code schema} key nested within those), and lifts every inline schema that appears two or 251 * more times into {@code components.schemas}, replacing each occurrence with a 252 * {@code $ref: "#/components/schemas/<name>"} pointer. Schemas already carrying a 253 * {@code $ref} are left in place. The lifted entry is keyed by its {@code title} when present 254 * (and that name is not already taken); otherwise a synthesized {@code Schema<N>} name is 255 * assigned with collision avoidance against any existing {@code components.schemas} entries. 256 * 257 * @param doc The OpenAPI document after the initial transform + $ref rewrite pass. 258 * @return The same document with duplicate inline schemas hoisted. 259 */ 260 static Json5Map deduplicateInlineSchemas(Json5Map doc) { 261 var sites = new LinkedHashMap<String,List<SchemaSite>>(); 262 collectOperationSchemas(doc, sites); 263 if (sites.isEmpty()) 264 return doc; 265 266 var components = (Json5Map) doc.get("components"); 267 if (components == null) 268 components = new Json5Map(); 269 var schemas = (Json5Map) components.get("schemas"); 270 if (schemas == null) 271 schemas = new Json5Map(); 272 var existingNames = new LinkedHashSet<>(schemas.keySet()); 273 274 var counter = new int[]{0}; 275 var hoisted = false; 276 for (var entry : sites.entrySet()) { 277 var occurrences = entry.getValue(); 278 if (occurrences.size() < 2) 279 continue; 280 var inline = occurrences.get(0).schema(); 281 var name = pickSchemaName(inline, existingNames, counter); 282 existingNames.add(name); 283 schemas.put(name, inline); 284 var ref = new Json5Map(); 285 ref.put("$ref", "#/components/schemas/" + name); 286 for (var s : occurrences) 287 s.replaceWith(new Json5Map(ref)); 288 hoisted = true; 289 } 290 291 if (hoisted) { 292 if (! schemas.isEmpty()) 293 components.put("schemas", schemas); 294 if (! components.isEmpty()) 295 doc.put("components", components); 296 } 297 return doc; 298 } 299 300 private static void collectOperationSchemas(Json5Map doc, Map<String,List<SchemaSite>> sites) { 301 var paths = doc.get("paths"); 302 if (! (paths instanceof Map<?,?> paths2)) 303 return; 304 for (var pe : paths2.entrySet()) { 305 if (! (pe.getValue() instanceof Map<?,?> pathItem)) 306 continue; 307 for (var oe : pathItem.entrySet()) { 308 if (! (oe.getValue() instanceof Map<?,?> op)) 309 continue; 310 visitOperation(toJson5Map(op), sites); 311 } 312 } 313 } 314 315 private static void visitOperation(Json5Map op, Map<String,List<SchemaSite>> sites) { 316 var params = op.get("parameters"); 317 if (params instanceof List<?> params2) { 318 for (var p : params2) { 319 if (p instanceof Map<?,?> p2) 320 visitSchemaSlot(toJson5Map(p2), "schema", sites); 321 } 322 } 323 var requestBody = op.get("requestBody"); 324 if (requestBody instanceof Map<?,?> requestBody2) 325 visitContent(toJson5Map(requestBody2), sites); 326 var responses = op.get("responses"); 327 if (responses instanceof Map<?,?> responses2) { 328 for (var re : responses2.entrySet()) { 329 if (re.getValue() instanceof Map<?,?> r) 330 visitContent(toJson5Map(r), sites); 331 } 332 } 333 } 334 335 private static void visitContent(Json5Map holder, Map<String,List<SchemaSite>> sites) { 336 var content = holder.get("content"); 337 if (! (content instanceof Map<?,?> content2)) 338 return; 339 for (var ce : content2.entrySet()) { 340 if (ce.getValue() instanceof Map<?,?> media) 341 visitSchemaSlot(toJson5Map(media), "schema", sites); 342 } 343 } 344 345 private static void visitSchemaSlot(Json5Map parent, String key, Map<String,List<SchemaSite>> sites) { 346 var v = parent.get(key); 347 if (! (v instanceof Map<?,?> v2)) 348 return; 349 var schema = toJson5Map(v2); 350 // Re-attach the normalized map so subsequent replaceWith() updates the document. 351 parent.put(key, schema); 352 if (schema.containsKey("$ref")) 353 return; 354 if (schema.isEmpty()) 355 return; 356 var canonical = canonicalize(schema); 357 sites.computeIfAbsent(canonical, k -> l()).add(new SchemaSite(parent, key)); 358 } 359 360 private static String canonicalize(Object o) { 361 try { 362 return Json5Serializer.DEFAULT.copy().sortMaps().build().toString(o); 363 } catch (Exception e) { 364 return String.valueOf(o); 365 } 366 } 367 368 private static String pickSchemaName(Json5Map schema, Set<String> taken, int[] counter) { 369 var title = schema.getString("title"); 370 if (nn(title) && ! title.isBlank() && ! taken.contains(title)) 371 return title; 372 while (true) { 373 counter[0]++; 374 var candidate = "Schema" + counter[0]; 375 if (! taken.contains(candidate)) 376 return candidate; 377 } 378 } 379 380 /** Tracks the parent map plus the slot key where an inline schema lives so we can replace it in-place. */ 381 private record SchemaSite(Json5Map parent, String key) { 382 Json5Map schema() { 383 return (Json5Map) parent.get(key); 384 } 385 void replaceWith(Json5Map replacement) { 386 parent.put(key, replacement); 387 } 388 } 389 390 @SuppressWarnings({ 391 "java:S6541" // Brain Method acceptable for OpenAPI operation transformation dispatch 392 }) 393 private static Json5Map transformOperation(Json5Map op, List<String> topConsumes, List<String> topProduces) { 394 var newOp = new Json5Map(); 395 for (var e : op.entrySet()) { 396 var k = e.getKey(); 397 if (eqa(k, "parameters", "responses", "consumes", "produces")) 398 continue; 399 newOp.put(k, e.getValue()); 400 } 401 var consumes = listOfStrings(op.get("consumes")); 402 if (consumes.isEmpty()) 403 consumes = topConsumes; 404 var produces = listOfStrings(op.get("produces")); 405 if (produces.isEmpty()) 406 produces = topProduces; 407 408 var oldParams = op.get("parameters"); 409 var newParams = new ArrayList<Object>(); 410 Json5Map requestBody = null; 411 Json5Map formSchema = null; 412 var formRequired = new LinkedHashSet<String>(); 413 414 if (oldParams instanceof List<?> oldParams2) { 415 for (var p : oldParams2) { 416 if (! (p instanceof Map<?,?> p2)) 417 continue; 418 var pmap = toJson5Map(p2); 419 var in = String.valueOf(pmap.getOrDefault("in", "")); 420 if (eq(in, "body")) { 421 requestBody = bodyParameterToRequestBody(pmap, consumes); 422 } else if (eq(in, "formData")) { 423 if (formSchema == null) { 424 formSchema = new Json5Map(); 425 formSchema.put("type", "object"); 426 formSchema.put("properties", new Json5Map()); 427 } 428 var name = String.valueOf(pmap.getOrDefault("name", "")); 429 if (! name.isEmpty()) { 430 var props = (Json5Map) formSchema.get("properties"); 431 props.put(name, extractInlineSchema(pmap)); 432 if (isTrue(pmap.get("required"))) 433 formRequired.add(name); 434 } 435 } else { 436 newParams.add(transformQueryHeaderPathParameter(pmap)); 437 } 438 } 439 } 440 441 if (requestBody == null && formSchema != null) { 442 requestBody = new Json5Map(); 443 if (! formRequired.isEmpty()) 444 formSchema.put("required", new ArrayList<>(formRequired)); 445 var content = new Json5Map(); 446 var media = new Json5Map(); 447 media.put("schema", formSchema); 448 content.put("application/x-www-form-urlencoded", media); 449 requestBody.put("content", content); 450 } 451 if (requestBody != null) 452 newOp.put("requestBody", requestBody); 453 454 if (! newParams.isEmpty()) 455 newOp.put("parameters", newParams); 456 457 var oldResponses = op.get("responses"); 458 if (oldResponses instanceof Map<?,?> oldResponses2) { 459 var newResponses = new Json5Map(); 460 for (var re : oldResponses2.entrySet()) { 461 var code = String.valueOf(re.getKey()); 462 if (re.getValue() instanceof Map<?,?> rm) 463 newResponses.put(code, transformResponse(toJson5Map(rm), produces)); 464 else 465 newResponses.put(code, re.getValue()); 466 } 467 newOp.put("responses", newResponses); 468 } 469 return newOp; 470 } 471 472 private static Json5Map bodyParameterToRequestBody(Json5Map p, List<String> consumes) { 473 var rb = new Json5Map(); 474 if (p.containsKey("description")) 475 rb.put("description", p.get("description")); 476 if (isTrue(p.get("required"))) 477 rb.put("required", Boolean.TRUE); 478 var schema = p.get("schema"); 479 if (schema == null && p.containsKey("type")) 480 schema = extractInlineSchema(p); 481 var content = new Json5Map(); 482 var media = consumes.isEmpty() ? List.of("application/json") : consumes; 483 for (var mt : media) { 484 var entry = new Json5Map(); 485 if (schema != null) 486 entry.put("schema", schema); 487 content.put(mt, entry); 488 } 489 rb.put("content", content); 490 return rb; 491 } 492 493 private static Json5Map transformResponse(Json5Map response, List<String> produces) { 494 var newResp = new Json5Map(); 495 for (var e : response.entrySet()) { 496 var k = e.getKey(); 497 if (eqa(k, "schema", "examples")) 498 continue; 499 newResp.put(k, e.getValue()); 500 } 501 var schema = response.get("schema"); 502 if (schema != null) { 503 var content = new Json5Map(); 504 var media = produces.isEmpty() ? List.of("application/json") : produces; 505 for (var mt : media) { 506 var entry = new Json5Map(); 507 entry.put("schema", schema); 508 addExamplesForMedia(response, mt, entry); 509 content.put(mt, entry); 510 } 511 newResp.put("content", content); 512 } else if (response.containsKey("examples")) { 513 // Examples without schema — still surface under content blocks. 514 var content = new Json5Map(); 515 var media = produces.isEmpty() ? List.of("application/json") : produces; 516 for (var mt : media) { 517 var entry = new Json5Map(); 518 addExamplesForMedia(response, mt, entry); 519 if (! entry.isEmpty()) 520 content.put(mt, entry); 521 } 522 if (! content.isEmpty()) 523 newResp.put("content", content); 524 } 525 return newResp; 526 } 527 528 private static void addExamplesForMedia(Json5Map response, String mediaType, Json5Map entry) { 529 var examples = response.get("examples"); 530 if (examples instanceof Map<?,?> examples2 && examples2.containsKey(mediaType)) 531 entry.put("example", examples2.get(mediaType)); 532 } 533 534 private static Json5Map transformQueryHeaderPathParameter(Json5Map p) { 535 var newP = new Json5Map(); 536 var schema = new Json5Map(); 537 for (var e : p.entrySet()) { 538 var k = e.getKey(); 539 if (PARAMETER_SCHEMA_KEYS.contains(k)) { 540 if (neq(k, "collectionFormat")) 541 schema.put(k, e.getValue()); 542 } else { 543 newP.put(k, e.getValue()); 544 } 545 } 546 if (! schema.isEmpty()) { 547 var existing = (Json5Map) newP.get("schema"); 548 if (existing == null) 549 newP.put("schema", schema); 550 else 551 schema.forEach(existing::putIfAbsent); 552 } 553 return newP; 554 } 555 556 private static Json5Map extractInlineSchema(Json5Map p) { 557 var schema = new Json5Map(); 558 for (var e : p.entrySet()) { 559 var k = e.getKey(); 560 if ((PARAMETER_SCHEMA_KEYS.contains(k) && neq(k, "collectionFormat")) || eq(k, "$ref")) 561 schema.put(k, e.getValue()); 562 } 563 return schema; 564 } 565 566 private static List<Object> buildServers(Json5Map swagger) { 567 var servers = new ArrayList<Object>(); 568 var host = String.valueOf(swagger.getOrDefault("host", "")); 569 var basePath = String.valueOf(swagger.getOrDefault("basePath", "")); 570 var schemes = listOfStrings(swagger.get("schemes")); 571 if (host.isEmpty() && basePath.isEmpty() && schemes.isEmpty()) 572 return servers; 573 if (schemes.isEmpty()) 574 schemes = List.of("http"); 575 for (var scheme : schemes) { 576 var url = scheme + "://" + host + basePath; 577 var server = new Json5Map(); 578 server.put("url", url); 579 servers.add(server); 580 } 581 return servers; 582 } 583 584 private static List<String> listOfStrings(Object o) { 585 if (! (o instanceof List<?> o2)) 586 return List.of(); 587 return o2.stream().map(String::valueOf).collect(Collectors.toCollection(ArrayList::new)); 588 } 589 590 private static Json5Map toJson5Map(Map<?,?> m) { 591 if (m instanceof Json5Map m2) 592 return m2; 593 var out = new Json5Map(); 594 for (var e : m.entrySet()) 595 out.put(String.valueOf(e.getKey()), e.getValue()); 596 return out; 597 } 598 599 private static void copyIfPresent(Json5Map src, Json5Map dst, String key) { 600 if (src.containsKey(key)) 601 dst.put(key, src.get(key)); 602 } 603 604 /** 605 * Walks any nested {@code Map}/{@code List} and rewrites {@code $ref: "#/definitions/Foo"} 606 * references to the OpenAPI 3.x form {@code "#/components/schemas/Foo"}. 607 * 608 * @param o The input object. 609 * @return A copy with all {@code $ref} entries rewritten. 610 */ 611 static Object rewriteRefs(Object o) { 612 if (o instanceof Map<?,?> o2) { 613 var copy = new Json5Map(); 614 for (var e : o2.entrySet()) { 615 var k = String.valueOf(e.getKey()); 616 var v = e.getValue(); 617 if (eq(k, "$ref") && v instanceof String v2 && v2.startsWith("#/definitions/")) 618 copy.put(k, "#/components/schemas/" + v2.substring("#/definitions/".length())); 619 else 620 copy.put(k, rewriteRefs(v)); 621 } 622 return copy; 623 } 624 if (o instanceof List<?> o2) { 625 return o2.stream().map(BasicOpenApiProviderSession::rewriteRefs).collect(Collectors.toCollection(ArrayList::new)); 626 } 627 if (nn(o)) 628 return o; 629 return null; 630 } 631}