From 5fe6fb46c88307566c0906401099e81dec26ec75 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 23 Sep 2026 16:34:13 +0000 Subject: [PATCH] fix(ext-openapi): one shared answer per status, not a numbered family Hoisting named a shared response after its status and disambiguated with a counter, so a document with three wordings for 403 grew Forbidden, Forbidden2 and Forbidden3 in its components. Numbered names say nothing and move as soon as a route is added. The answer a status is usually given is now the one hoisted, under that status's own name, and a route that answers the same status differently keeps its wording inline where it belongs. Co-Authored-By: Claude Opus 5 --- .../flash/ext/openapi/OpenApiBuilder.java | 48 +++++++++---------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java index 5cacaf4..4941178 100644 --- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java @@ -383,53 +383,49 @@ public final class OpenApiBuilder { // ── Shared responses ────────────────────────────────────────────────────── /** - * Whatever answer more than one operation gives identically is written once under - * {@code components.responses} and referenced. Authentication and rate limiting say the same - * thing on every route they guard; the document should say it once. + * The answer a status is usually given is written once under {@code components.responses} and + * referenced. Authentication and rate limiting say the same thing on every route they guard; + * the document should say it once, under that status's own name. A route that answers the same + * status differently keeps its own wording, inline, rather than pushing a second name into the + * components. */ @SuppressWarnings("unchecked") private Map hoistSharedResponses(Map renderedPaths) { - Map seen = new HashMap<>(); + Map> seen = new LinkedHashMap<>(); for (Object pathItem : renderedPaths.values()) { for (Object operation : ((Map) pathItem).values()) { Map responses = (Map) ((Map) operation).get("responses"); - responses.forEach((status, response) -> seen.merge(key(status, response), 1, Integer::sum)); + responses.forEach((status, response) -> + seen.computeIfAbsent(status, s -> new LinkedHashMap<>()).merge(response, 1, Integer::sum)); } } - Map names = new LinkedHashMap<>(); Map shared = new LinkedHashMap<>(); + Map hoisted = new LinkedHashMap<>(); // status to the one body that is shared + seen.forEach((status, bodies) -> { + Map.Entry commonest = bodies.entrySet().stream() + .max(Map.Entry.comparingByValue()).orElseThrow(); + if (commonest.getValue() < 2) return; + String name = reasonFor(Integer.parseInt(status)).replace(" ", ""); + if (name.isEmpty() || shared.containsKey(name)) name = "Status" + status; + shared.put(name, commonest.getKey()); + hoisted.put(status, name); + }); + for (Object pathItem : renderedPaths.values()) { for (Object operation : ((Map) pathItem).values()) { Map responses = (Map) ((Map) operation).get("responses"); for (var response : responses.entrySet()) { - Object key = key(response.getKey(), response.getValue()); - if (seen.getOrDefault(key, 0) < 2) continue; - - String name = names.get(key); - if (name == null) { - name = uniqueName(reasonFor(Integer.parseInt(response.getKey())), shared); - names.put(key, name); - shared.put(name, response.getValue()); + String name = (String) hoisted.get(response.getKey()); + if (name != null && shared.get(name).equals(response.getValue())) { + response.setValue(Map.of("$ref", RESPONSE_REF + name)); } - response.setValue(Map.of("$ref", RESPONSE_REF + name)); } } } return shared; } - private static Object key(String status, Object response) { - return status + response; - } - - private static String uniqueName(String reason, Map taken) { - String base = reason.isEmpty() ? "Response" : reason.replace(" ", ""); - String name = base; - for (int i = 2; taken.containsKey(name); i++) name = base + i; - return name; - } - // ── Odds and ends ───────────────────────────────────────────────────────── private static Map arrayOf(Map items) {