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/&lt;name&gt;"} 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&lt;N&gt;} 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}