diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/SwaggerUiPage.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/SwaggerUiPage.java index 9244bcbd..0b3f3b50 100644 --- a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/SwaggerUiPage.java +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/SwaggerUiPage.java @@ -20,6 +20,7 @@ public String html() { + " Swagger UI\n" + " \n" + " \n" + + " \n" + " \n" + "\n" + "\n" @@ -41,6 +42,8 @@ public String html() { + " });\n" + "};\n" + "\n" + + "\n" + + "\n" + "\n" + "\n"; } diff --git a/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/CrudUiControllerTest.java b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/CrudUiControllerTest.java index 83a7ad8b..b8f4e160 100644 --- a/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/CrudUiControllerTest.java +++ b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/CrudUiControllerTest.java @@ -356,6 +356,10 @@ public void swaggerUiPageEmbedsUnpkgSwaggerUiForCurrentOpenApiJson() { Assertions.assertTrue(response.body().contains("href=\"/favicon.svg\"")); Assertions.assertTrue(response.body().contains("SwaggerUIBundle")); Assertions.assertTrue(response.body().contains("/openapi.json")); + Assertions.assertTrue(response.body().contains("/css/swagger-copy-for-ai.css")); + Assertions.assertTrue(response.body().contains("/js/swagger-copy-for-ai.js")); + Assertions.assertTrue(response.body().contains("window.thingifierSwaggerCopyForAi")); + Assertions.assertTrue(response.body().contains("openApiUrl: \"/openapi.json\"")); } } diff --git a/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/CrudUiApiIT.java b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/CrudUiApiIT.java index 069bb9fe..53899a8f 100644 --- a/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/CrudUiApiIT.java +++ b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/CrudUiApiIT.java @@ -35,6 +35,7 @@ public void staticPagesAndGeneratedDocumentationAreReachable() { assertStatusContains("/schema", 200, "Thingifier Schema Edit"); assertStatusContains("/docs", 200, "API Documentation"); assertStatusContains("/swagger", 200, "SwaggerUIBundle"); + assertStatusContains("/swagger", 200, "swagger-copy-for-ai.js"); assertStatusContains("/openapi.json", 200, "\"openapi\""); assertStatusContains("/docs/swagger", 200, "\"openapi\""); } diff --git a/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/SwaggerCopyForAiUiIT.java b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/SwaggerCopyForAiUiIT.java new file mode 100644 index 00000000..8ac2d0f0 --- /dev/null +++ b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/SwaggerCopyForAiUiIT.java @@ -0,0 +1,52 @@ +package uk.co.compendiumdev.thingifier.crudui.e2e; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import uk.co.compendiumdev.thingifier.crudui.e2e.pages.SwaggerPage; + +public class SwaggerCopyForAiUiIT extends BrowserTestBase { + + private SwaggerPage swagger; + + @BeforeEach + void openSwagger() { + resetToProjectTasks(); + swagger = new SwaggerPage(page, server().baseUrl()); + swagger.copy().installClipboardStub(); + swagger.open(); + } + + @Test + public void operationCopyForAiCopiesMarkdownWithCurrentOriginAndApiPrefix() { + assertThat(swagger.copy().fullApiButton()).isVisible(); + assertThat(swagger.copy().operationButton("POST", "/projects")).isVisible(); + + swagger.copy().operationButton("POST", "/projects").click(); + swagger.copy().waitForCopiedTextContaining("POST " + server().baseUrl() + "/api/projects"); + + final String copied = swagger.copy().copiedText(); + Assertions.assertTrue(copied.contains("# POST " + server().baseUrl() + "/api/projects")); + Assertions.assertTrue(copied.contains("- OpenAPI path: `/projects`")); + Assertions.assertTrue(copied.contains("## Request Body")); + Assertions.assertTrue(copied.contains("## Responses")); + Assertions.assertTrue(copied.contains("## Referenced Schemas")); + } + + @Test + public void fullApiCopyForAiCopiesAllOperationsAndSchemas() { + assertThat(swagger.copy().firstOperationButton()).isVisible(); + + swagger.copy().fullApiButton().click(); + swagger.copy().waitForCopiedTextContaining("## Component Schemas"); + + final String copied = swagger.copy().copiedText(); + Assertions.assertTrue(copied.contains("# Project Tasks")); + Assertions.assertTrue(copied.contains("- Base URL: `" + server().baseUrl() + "/api`")); + Assertions.assertTrue(copied.contains("GET " + server().baseUrl() + "/api/projects")); + Assertions.assertTrue(copied.contains("POST " + server().baseUrl() + "/api/todos")); + Assertions.assertTrue(copied.contains("## Component Schemas")); + } +} diff --git a/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/components/SwaggerCopyComponent.java b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/components/SwaggerCopyComponent.java new file mode 100644 index 00000000..887588cf --- /dev/null +++ b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/components/SwaggerCopyComponent.java @@ -0,0 +1,47 @@ +package uk.co.compendiumdev.thingifier.crudui.e2e.components; + +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; + +public final class SwaggerCopyComponent { + + private final Page page; + + public SwaggerCopyComponent(final Page page) { + this.page = page; + } + + public void installClipboardStub() { + page.addInitScript( + "window.__copiedText = '';\n" + + "Object.defineProperty(navigator, 'clipboard', {\n" + + " configurable: true,\n" + + " value: { writeText: async function(text) { window.__copiedText = text; } }\n" + + "});"); + } + + public Locator fullApiButton() { + return TestSelectors.byTestId(page, "swagger-copy-full-ai"); + } + + public Locator firstOperationButton() { + return TestSelectors.byTestId(page, "swagger-copy-operation-ai").first(); + } + + public Locator operationButton(final String method, final String path) { + return page.locator(".opblock") + .filter(new Locator.FilterOptions().setHasText(method.toUpperCase())) + .filter(new Locator.FilterOptions().setHasText(path)) + .locator("[data-testid='swagger-copy-operation-ai']") + .first(); + } + + public String copiedText() { + return String.valueOf(page.evaluate("() => window.__copiedText || ''")); + } + + public void waitForCopiedTextContaining(final String expectedText) { + page.waitForFunction( + "expected => (window.__copiedText || '').includes(expected)", expectedText); + } +} diff --git a/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/pages/SwaggerPage.java b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/pages/SwaggerPage.java new file mode 100644 index 00000000..85b781fc --- /dev/null +++ b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/e2e/pages/SwaggerPage.java @@ -0,0 +1,29 @@ +package uk.co.compendiumdev.thingifier.crudui.e2e.pages; + +import com.microsoft.playwright.Page; +import uk.co.compendiumdev.thingifier.crudui.e2e.components.SwaggerCopyComponent; +import uk.co.compendiumdev.thingifier.crudui.e2e.components.TestSelectors; + +public final class SwaggerPage { + + private final Page page; + private final String baseUrl; + private final SwaggerCopyComponent copy; + + public SwaggerPage(final Page page, final String baseUrl) { + this.page = page; + this.baseUrl = baseUrl; + this.copy = new SwaggerCopyComponent(page); + } + + public SwaggerPage open() { + page.navigate(baseUrl + "/swagger"); + TestSelectors.byTestId(page, "swagger-copy-full-ai").waitFor(); + TestSelectors.byTestId(page, "swagger-copy-operation-ai").first().waitFor(); + return this; + } + + public SwaggerCopyComponent copy() { + return copy; + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerUiPage.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerUiPage.java index e270fe26..e0b174d0 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerUiPage.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerUiPage.java @@ -11,6 +11,8 @@ public final class SwaggerUiPage { "https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"; private static final String SWAGGER_UI_PRESET = "https://unpkg.com/swagger-ui-dist/swagger-ui-standalone-preset.js"; + private static final String COPY_FOR_AI_CSS = "/css/swagger-copy-for-ai.css"; + private static final String COPY_FOR_AI_SCRIPT = "/js/swagger-copy-for-ai.js"; private final ThingifierApiDocumentationDefn apiDefn; private final DefaultGUIHTML guiManagement; @@ -74,6 +76,14 @@ public String html() { html.append("}, 0);"); html.append("};"); html.append(""); + html.append(""); + html.append(""); html.append(""); html.append(guiManagement.getEndOfMainContentMarker()); html.append(guiManagement.getPageFooter()); @@ -85,6 +95,9 @@ private String headInject() { return "" + + "" + ""; diff --git a/thingifier/src/main/resources/public/css/swagger-copy-for-ai.css b/thingifier/src/main/resources/public/css/swagger-copy-for-ai.css new file mode 100644 index 00000000..8391de61 --- /dev/null +++ b/thingifier/src/main/resources/public/css/swagger-copy-for-ai.css @@ -0,0 +1,44 @@ +.swagger-copy-ai-toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + margin: 1rem 0; + font-family: Arial, Helvetica, sans-serif; +} + +.swagger-copy-ai-button { + border: 1px solid #777; + border-radius: 4px; + background: #fff; + color: #222; + cursor: pointer; + font-size: 12px; + font-weight: 700; + line-height: 1.2; + padding: 0.45rem 0.7rem; +} + +.swagger-copy-ai-button:hover, +.swagger-copy-ai-button:focus { + border-color: #1f5f8b; + color: #1f5f8b; +} + +.swagger-copy-ai-button:disabled { + cursor: wait; + opacity: 0.75; +} + +.swagger-copy-ai-operation { + margin-left: auto; + white-space: nowrap; +} + +.swagger-copy-ai-status { + color: #555; + font-size: 12px; +} + +.swagger-ui .opblock .opblock-summary { + gap: 0.35rem; +} diff --git a/thingifier/src/main/resources/public/js/swagger-copy-for-ai.js b/thingifier/src/main/resources/public/js/swagger-copy-for-ai.js new file mode 100644 index 00000000..8e644b00 --- /dev/null +++ b/thingifier/src/main/resources/public/js/swagger-copy-for-ai.js @@ -0,0 +1,426 @@ +(function () { + "use strict"; + + const BUTTON_TEXT = "Copy for AI"; + const FULL_BUTTON_TEXT = "Copy full API for AI"; + const COPIED_TEXT = "Copied"; + const FAILED_TEXT = "Copy failed"; + const DEFAULT_OPEN_API_URL = "/docs/openapi.json"; + const HTTP_METHODS = ["get", "put", "post", "delete", "patch", "head", "options", "trace"]; + + const config = window.thingifierSwaggerCopyForAi || {}; + let openApiSpec; + let openApiPromise; + let operationIndex = {}; + + window.addEventListener("load", function () { + const swaggerRoot = document.querySelector("#swagger-ui"); + if (!swaggerRoot) { + return; + } + addFullApiToolbar(swaggerRoot); + loadOpenApi().then(enhanceOperationRows).catch(reportLoadFailure); + const observer = new MutationObserver(function () { + if (openApiSpec) { + enhanceOperationRows(); + } + }); + observer.observe(swaggerRoot, {childList: true, subtree: true}); + }); + + function loadOpenApi() { + if (!openApiPromise) { + openApiPromise = fetch(config.openApiUrl || DEFAULT_OPEN_API_URL) + .then(function (response) { + if (!response.ok) { + throw new Error("Could not load OpenAPI JSON: " + response.status); + } + return response.json(); + }) + .then(function (spec) { + openApiSpec = spec; + operationIndex = indexOperations(spec); + return spec; + }); + } + return openApiPromise; + } + + function addFullApiToolbar(swaggerRoot) { + if (document.querySelector("[data-testid='swagger-copy-full-ai']")) { + return; + } + + const toolbar = document.createElement("div"); + toolbar.className = "swagger-copy-ai-toolbar"; + toolbar.setAttribute("data-testid", "swagger-copy-ai-toolbar"); + + const button = document.createElement("button"); + button.type = "button"; + button.className = "swagger-copy-ai-button"; + button.textContent = FULL_BUTTON_TEXT; + button.setAttribute("data-testid", "swagger-copy-full-ai"); + button.addEventListener("click", function () { + loadOpenApi() + .then(function (spec) { + return copyText(fullApiMarkdown(spec), button); + }) + .catch(function () { + showTemporaryText(button, FAILED_TEXT); + }); + }); + + const status = document.createElement("span"); + status.className = "swagger-copy-ai-status"; + status.textContent = "Copies clean Markdown for Codex, Claude, Cursor, or other AI tools."; + + toolbar.appendChild(button); + toolbar.appendChild(status); + swaggerRoot.parentNode.insertBefore(toolbar, swaggerRoot); + } + + function enhanceOperationRows() { + document.querySelectorAll(".swagger-ui .opblock").forEach(function (block) { + if (block.querySelector("[data-testid='swagger-copy-operation-ai']")) { + return; + } + + const operation = operationFromBlock(block); + if (!operation) { + return; + } + + const summary = block.querySelector(".opblock-summary"); + if (!summary) { + return; + } + + const button = document.createElement("button"); + button.type = "button"; + button.className = "swagger-copy-ai-button swagger-copy-ai-operation"; + button.textContent = BUTTON_TEXT; + button.setAttribute("data-testid", "swagger-copy-operation-ai"); + button.addEventListener("click", function (event) { + event.preventDefault(); + event.stopPropagation(); + copyText(operationMarkdown(operation.path, operation.method, operation.operation), button); + }); + + summary.appendChild(button); + }); + } + + function operationFromBlock(block) { + const methodElement = block.querySelector(".opblock-summary-method"); + const pathElement = block.querySelector(".opblock-summary-path"); + if (!methodElement || !pathElement) { + return null; + } + + const method = methodElement.textContent.trim().toLowerCase(); + const path = pathElement.getAttribute("data-path") || pathElement.textContent.trim(); + const normalizedPath = normalizePath(path); + return operationIndex[operationKey(normalizedPath, method)] || null; + } + + function indexOperations(spec) { + const index = {}; + const paths = spec.paths || {}; + Object.keys(paths).forEach(function (path) { + const pathItem = paths[path] || {}; + HTTP_METHODS.forEach(function (method) { + if (pathItem[method]) { + index[operationKey(path, method)] = { + path: path, + method: method, + operation: pathItem[method] + }; + } + }); + }); + return index; + } + + function operationKey(path, method) { + return method.toLowerCase() + " " + normalizePath(path); + } + + function normalizePath(path) { + if (!path) { + return "/"; + } + let normalized = path.trim(); + if (!normalized.startsWith("/")) { + normalized = "/" + normalized; + } + return normalized.replace(/\s+/g, ""); + } + + function fullApiMarkdown(spec) { + const info = spec.info || {}; + const lines = [ + "# " + (info.title || "OpenAPI documentation"), + "", + "- Version: `" + (info.version || "") + "`", + "- Base URL: `" + baseUrl() + "`", + "- OpenAPI: `" + (spec.openapi || spec.swagger || "") + "`" + ]; + + if (info.description) { + lines.push("", "## Description", "", info.description); + } + + lines.push("", "## Operations"); + Object.keys(spec.paths || {}).sort().forEach(function (path) { + const pathItem = spec.paths[path] || {}; + HTTP_METHODS.forEach(function (method) { + if (pathItem[method]) { + lines.push("", operationMarkdown(path, method, pathItem[method])); + } + }); + }); + + lines.push("", "## Component Schemas"); + const schemas = ((spec.components || {}).schemas) || {}; + if (Object.keys(schemas).length === 0) { + lines.push("", "No component schemas documented."); + } else { + Object.keys(schemas).sort().forEach(function (name) { + lines.push("", "### " + name, "", jsonBlock(schemas[name])); + }); + } + + return lines.join("\n").trim(); + } + + function operationMarkdown(path, method, operation) { + const fullUrl = fullOperationUrl(path); + const lines = [ + "# " + method.toUpperCase() + " " + fullUrl, + "", + "- Method: `" + method.toUpperCase() + "`", + "- OpenAPI path: `" + path + "`", + "- Full URL: `" + fullUrl + "`" + ]; + + if (operation.summary) { + lines.push("- Summary: " + operation.summary); + } + if (operation.tags && operation.tags.length > 0) { + lines.push("- Tags: " + operation.tags.map(code).join(", ")); + } + + if (operation.description) { + lines.push("", "## Description", "", operation.description); + } + + appendParameters(lines, operation.parameters || []); + appendRequestBody(lines, operation.requestBody); + appendResponses(lines, operation.responses || {}); + appendSecurity(lines, operation.security); + appendReferencedSchemas(lines, operation); + + return lines.join("\n").trim(); + } + + function appendParameters(lines, parameters) { + lines.push("", "## Parameters"); + if (!parameters.length) { + lines.push("", "No parameters documented."); + return; + } + + parameters.forEach(function (parameter) { + lines.push( + "", + "- `" + (parameter.name || "") + "` in `" + (parameter.in || "") + "`" + + (parameter.required ? " required" : "") + + (parameter.description ? ": " + parameter.description : "")); + if (parameter.schema) { + lines.push(" - Schema: `" + compactJson(parameter.schema) + "`"); + } + if (parameter.example !== undefined) { + lines.push(" - Example: `" + String(parameter.example) + "`"); + } + }); + } + + function appendRequestBody(lines, requestBody) { + lines.push("", "## Request Body"); + if (!requestBody) { + lines.push("", "No request body documented."); + return; + } + + if (requestBody.description) { + lines.push("", requestBody.description); + } + lines.push("", jsonBlock(requestBody)); + } + + function appendResponses(lines, responses) { + lines.push("", "## Responses"); + const codes = Object.keys(responses); + if (!codes.length) { + lines.push("", "No responses documented."); + return; + } + + codes.sort().forEach(function (status) { + const response = responses[status] || {}; + lines.push("", "### " + status, "", response.description || ""); + if (response.content) { + lines.push("", jsonBlock(response.content)); + } + }); + } + + function appendSecurity(lines, security) { + if (!security || !security.length) { + return; + } + + lines.push("", "## Security", "", jsonBlock(security)); + } + + function appendReferencedSchemas(lines, operation) { + const refs = referencedSchemaNames(operation); + if (!refs.length) { + return; + } + + lines.push("", "## Referenced Schemas"); + const schemas = ((openApiSpec.components || {}).schemas) || {}; + refs.forEach(function (name) { + if (schemas[name]) { + lines.push("", "### " + name, "", jsonBlock(schemas[name])); + } + }); + } + + function referencedSchemaNames(value) { + const refs = []; + collectRefs(value, refs); + return refs.filter(function (name, index) { + return refs.indexOf(name) === index; + }).sort(); + } + + function collectRefs(value, refs) { + if (!value || typeof value !== "object") { + return; + } + if (typeof value.$ref === "string") { + const match = value.$ref.match(/^#\/components\/schemas\/(.+)$/); + if (match) { + refs.push(match[1]); + } + } + Object.keys(value).forEach(function (key) { + collectRefs(value[key], refs); + }); + } + + function baseUrl() { + const serverUrl = firstServerUrl(); + let serverPath = ""; + try { + serverPath = new URL(serverUrl || "/", window.location.origin).pathname; + } catch (e) { + serverPath = ""; + } + return joinUrlParts(window.location.origin, serverPath === "/" ? "" : serverPath); + } + + function firstServerUrl() { + if (!openApiSpec || !openApiSpec.servers || !openApiSpec.servers.length) { + return ""; + } + return openApiSpec.servers[0].url || ""; + } + + function fullOperationUrl(path) { + return joinUrlParts(baseUrl(), path); + } + + function joinUrlParts(left, right) { + const lhs = String(left || "").replace(/\/+$/, ""); + const rhs = String(right || "").replace(/^\/+/, ""); + if (!rhs) { + return lhs || "/"; + } + return lhs + "/" + rhs; + } + + function copyText(text, button) { + button.disabled = true; + return copyToClipboard(text) + .then(function () { + showTemporaryText(button, COPIED_TEXT); + }) + .catch(function () { + showTemporaryText(button, FAILED_TEXT); + }); + } + + function copyToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text); + } + return fallbackCopy(text); + } + + function fallbackCopy(text) { + return new Promise(function (resolve, reject) { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", "readonly"); + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + textarea.select(); + try { + if (document.execCommand("copy")) { + resolve(); + } else { + reject(new Error("Copy command failed")); + } + } finally { + document.body.removeChild(textarea); + } + }); + } + + function showTemporaryText(button, text) { + const original = button.getAttribute("data-original-text") || button.textContent; + button.setAttribute("data-original-text", original); + button.textContent = text; + setTimeout(function () { + button.disabled = false; + button.textContent = original; + }, 1400); + } + + function reportLoadFailure() { + const toolbar = document.querySelector("[data-testid='swagger-copy-ai-toolbar']"); + if (!toolbar || toolbar.querySelector(".swagger-copy-ai-error")) { + return; + } + const error = document.createElement("span"); + error.className = "swagger-copy-ai-status swagger-copy-ai-error"; + error.textContent = "Copy for AI could not load the OpenAPI document."; + toolbar.appendChild(error); + } + + function jsonBlock(value) { + return "```json\n" + JSON.stringify(value, null, 2) + "\n```"; + } + + function compactJson(value) { + return JSON.stringify(value); + } + + function code(value) { + return "`" + String(value) + "`"; + } +})(); diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerCopyForAiAssetTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerCopyForAiAssetTest.java new file mode 100644 index 00000000..25a8d236 --- /dev/null +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerCopyForAiAssetTest.java @@ -0,0 +1,43 @@ +package uk.co.compendiumdev.thingifier.swaggerizer; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class SwaggerCopyForAiAssetTest { + + @Test + void javascriptContainsCopyForAiBehaviour() { + final String script = resource("/public/js/swagger-copy-for-ai.js"); + + Assertions.assertTrue(script.contains("window.location.origin")); + Assertions.assertTrue(script.contains("navigator.clipboard.writeText")); + Assertions.assertTrue(script.contains("MutationObserver")); + Assertions.assertTrue(script.contains("Copy for AI")); + Assertions.assertTrue(script.contains("Copy full API for AI")); + Assertions.assertTrue(script.contains("fullApiMarkdown")); + Assertions.assertTrue(script.contains("operationMarkdown")); + } + + @Test + void cssContainsCopyForAiStyles() { + final String css = resource("/public/css/swagger-copy-for-ai.css"); + + Assertions.assertTrue(css.contains(".swagger-copy-ai-toolbar")); + Assertions.assertTrue(css.contains(".swagger-copy-ai-button")); + Assertions.assertTrue(css.contains(".swagger-copy-ai-operation")); + } + + private String resource(final String path) { + try (InputStream stream = getClass().getResourceAsStream(path)) { + if (stream == null) { + throw new AssertionError("Missing resource " + path); + } + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new AssertionError("Could not read resource " + path, e); + } + } +} diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerUiPageTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerUiPageTest.java new file mode 100644 index 00000000..6cbdef9f --- /dev/null +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerUiPageTest.java @@ -0,0 +1,30 @@ +package uk.co.compendiumdev.thingifier.swaggerizer; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import uk.co.compendiumdev.thingifier.api.docgen.ThingifierApiDocumentationDefn; +import uk.co.compendiumdev.thingifier.htmlgui.htmlgen.DefaultGUIHTML; + +class SwaggerUiPageTest { + + @Test + void includesCopyForAiAssetsAndOpenApiConfiguration() { + final ThingifierApiDocumentationDefn apiDefn = new ThingifierApiDocumentationDefn(); + apiDefn.setSwaggerUiTitle("Thingifier API"); + + final String html = + new SwaggerUiPage( + apiDefn, + new DefaultGUIHTML(), + "/mirror/docs/openapi.json", + "/mirror/docs", + "/mirror/docs/swagger", + "/mirror/docs/swagger-ui") + .html(); + + Assertions.assertTrue(html.contains("/css/swagger-copy-for-ai.css")); + Assertions.assertTrue(html.contains("/js/swagger-copy-for-ai.js")); + Assertions.assertTrue(html.contains("window.thingifierSwaggerCopyForAi")); + Assertions.assertTrue(html.contains("openApiUrl: \"/mirror/docs/openapi.json\"")); + } +}