Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ function setMenuNavBasedOnUrl(){
<ul>
<li><a href="/practice-modes/simpleapi">About Simple API</a>
<li><a href="/simpleapi/docs">API Docs</a>
<li><a href="/simpleapi/docs/swagger-ui">Swagger UI</a>
<li><a href="/simpleapi/gui/entities">Data Explorer</a></li>
<li><a href="/practice-modes/simpleapi-openapi">OpenAPI File</a>
</ul>
Expand All @@ -124,6 +125,7 @@ function setMenuNavBasedOnUrl(){
<ul>
<li><a href="/apichallenges">About API Challenges</a></li>
<li><a href="/docs">API Docs</a></li>
<li><a href="/docs/swagger-ui">Swagger UI</a></li>
<li><a href="/gui/challenges">Progress</a></li>
<li><a href="/gui/entities">Data Explorer</a></li>
<li><a href="/apichallenges/solutions">Solutions</a></li>
Expand All @@ -135,6 +137,7 @@ function setMenuNavBasedOnUrl(){
<ul>
<li><a href="/practice-modes/simulation">About API Simulator</a></li>
<li><a href="/sim/docs">API Docs</a></li>
<li><a href="/sim/docs/swagger-ui">Swagger UI</a></li>
<li><a href="/sim/docs/swagger">[Download Open API File]</a></li>

</ul>
Expand All @@ -144,6 +147,7 @@ function setMenuNavBasedOnUrl(){
<ul>
<li><a href="/practice-modes/mirror">About HTTP Mirror</a></li>
<li><a href="/mirror/docs">Mirror API Docs</a></li>
<li><a href="/mirror/docs/swagger-ui">Swagger UI</a></li>
<li><a href="/mirror/docs/swagger">[Download Open API File]</a></li>

</ul>
Expand Down Expand Up @@ -260,6 +264,9 @@ function setMenuNavBasedOnUrl(){
}
siteMap.addUrl("https://apichallenges.eviltester.com", SEO_FIXED_LASTMOD.toString());
siteMap.addUrl("https://apichallenges.eviltester.com/docs", SEO_FIXED_LASTMOD.toString());
siteMap.addUrl(
"https://apichallenges.eviltester.com/docs/swagger-ui",
SEO_FIXED_LASTMOD.toString());
siteMap.addUrl(
"https://apichallenges.eviltester.com/gui/challenges",
SEO_FIXED_LASTMOD.toString());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,11 +175,92 @@ void canDownloadSwaggerFile() {
Assertions.assertTrue(response.body.contains("\"openapi\" : \"3.0.1\","));
}

@Test
void canFetchDefaultOpenApiJsonForSwaggerUi() {

final HttpResponseDetails response = http.send("/docs/openapi.json", "get");

Assertions.assertEquals(200, response.statusCode);
Assertions.assertNotNull(response.getHeader("Content-Type"));
Assertions.assertTrue(response.getHeader("Content-Type").contains("application/json"));
Assertions.assertTrue(response.body.contains("\"openapi\" : \"3.0.1\","));
Assertions.assertTrue(
response.body.indexOf("\"url\" : \"http://localhost:4567\"")
< response.body.indexOf(
"\"url\" : \"https://apichallenges.eviltester.com\""));
}

@Test
void staticAssetsAreServedBeforeGenericFallbackRoutes() {

HttpResponseDetails response = http.send("/css/default.css", "get");
Assertions.assertEquals(200, response.statusCode);
Assertions.assertTrue(response.getHeader("Content-Type").contains("text/css"));
Assertions.assertTrue(response.body.contains(".rootmenu"));

response = http.send("/js/toc.js", "get");
Assertions.assertEquals(200, response.statusCode);
Assertions.assertTrue(response.getHeader("Content-Type").contains("javascript"));
Assertions.assertTrue(response.body.contains("htmlTableOfContents"));

response = http.send("/favicon/site.webmanifest", "get");
Assertions.assertEquals(200, response.statusCode);
Assertions.assertTrue(response.getHeader("Content-Type").contains("manifest+json"));
Assertions.assertTrue(response.body.contains("icons"));
}

static Stream<Arguments> swaggerUiPageRoutes() {
List<Arguments> args = new ArrayList<>();
args.add(Arguments.of("/docs/swagger-ui", "/docs/openapi.json"));
args.add(Arguments.of("/simpleapi/docs/swagger-ui", "/simpleapi/docs/openapi.json"));
args.add(Arguments.of("/sim/docs/swagger-ui", "/sim/docs/openapi.json"));
args.add(Arguments.of("/mirror/docs/swagger-ui", "/mirror/docs/openapi.json"));
return args.stream();
}

@ParameterizedTest(name = "swagger ui page {0} references {1}")
@MethodSource("swaggerUiPageRoutes")
void swaggerUiPagesRenderAndReferenceMatchingOpenApiJson(
final String swaggerUiPath, final String openApiJsonPath) {

final HttpResponseDetails response = http.send(swaggerUiPath, "get");

Assertions.assertEquals(200, response.statusCode);
Assertions.assertNotNull(response.getHeader("Content-Type"));
Assertions.assertTrue(response.getHeader("Content-Type").contains("text/html"));
assertContainsHeaderAndFooter(response);
Assertions.assertTrue(
response.body.contains("https://unpkg.com/swagger-ui-dist/swagger-ui.css"));
Assertions.assertTrue(
response.body.contains("https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"));
Assertions.assertTrue(
response.body.contains(
"https://unpkg.com/swagger-ui-dist/swagger-ui-standalone-preset.js"));
Assertions.assertTrue(response.body.contains("color-scheme:light"));
Assertions.assertTrue(response.body.contains("syntaxHighlight: {activated: false}"));
Assertions.assertTrue(response.body.contains("SwaggerUIBundle"));
Assertions.assertTrue(response.body.contains("url: \"" + openApiJsonPath + "\""));
}

@Test
void challengerMenuContainsSwaggerUiLinks() {

final HttpResponseDetails response = http.send("/", "get");

Assertions.assertEquals(200, response.statusCode);
Assertions.assertTrue(response.body.contains("href=\"/docs/swagger-ui\""));
Assertions.assertTrue(response.body.contains("href=\"/simpleapi/docs/swagger-ui\""));
Assertions.assertTrue(response.body.contains("href=\"/sim/docs/swagger-ui\""));
Assertions.assertTrue(response.body.contains("href=\"/mirror/docs/swagger-ui\""));
}

@Test
void docsPagesRenderPerApiSeoMetadata() {

final HttpResponseDetails docsResponse = http.send("/docs", "get");
Assertions.assertEquals(200, docsResponse.statusCode);
Assertions.assertTrue(docsResponse.body.contains("Open Swagger UI"));
Assertions.assertTrue(docsResponse.body.contains("href='/docs/swagger-ui'"));
Assertions.assertTrue(
docsResponse.body.contains(
"<title>API Challenges API Documentation | API Challenges</title>"));
Expand Down Expand Up @@ -381,6 +462,9 @@ void sitemapUsesFixedLastmodForPhaseOneUrls() {
response.body.contains("<loc>https://apichallenges.eviltester.com</loc>"));
Assertions.assertTrue(
response.body.contains("<loc>https://apichallenges.eviltester.com/docs</loc>"));
Assertions.assertTrue(
response.body.contains(
"<loc>https://apichallenges.eviltester.com/docs/swagger-ui</loc>"));
Assertions.assertTrue(
response.body.contains(
"<loc>https://apichallenges.eviltester.com/gui/challenges</loc>"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import uk.co.compendiumdev.thingifier.api.docgen.ThingifierApiDocumentationDefn;
import uk.co.compendiumdev.thingifier.htmlgui.htmlgen.DefaultGUIHTML;
import uk.co.compendiumdev.thingifier.htmlgui.htmlgen.RestApiDocumentationGenerator;
import uk.co.compendiumdev.thingifier.swaggerizer.SwaggerUiPage;
import uk.co.compendiumdev.thingifier.swaggerizer.Swaggerizer;

public class ThingifierAutoDocGenRouting {
Expand All @@ -20,12 +21,17 @@ public ThingifierAutoDocGenRouting(
// configure it based on a thingifier
ApiRoutingDefinition routingDefinitions =
new ApiRoutingDefinitionDocGenerator(thingifier).generate(apiDefn.getPathPrefix());
final String pathPrefix = apiDefn.getPathPrefix();
final String docsPath = "%s/docs".formatted(pathPrefix);
final String swaggerDownloadPath = "%s/docs/swagger".formatted(pathPrefix);
final String openApiPath = "%s/docs/openapi.json".formatted(pathPrefix);
final String swaggerUiPath = "%s/docs/swagger-ui".formatted(pathPrefix);

// TODO: config to enable docs and configure the URL and add a meta tag for description and
// additional headers
// / - default for documentation
get(
"%s/docs".formatted(apiDefn.getPathPrefix()),
docsPath,
(request, response) -> {
response.type("text/html");
response.status(200);
Expand All @@ -35,16 +41,40 @@ public ThingifierAutoDocGenRouting(
apiDefn.getAdditionalRoutes(),
apiDefn,
apiDefn.getPathPrefix(),
"%s/docs".formatted(apiDefn.getPathPrefix()));
docsPath);
});

// guiManagement.appendMenuItem("API documentation","/docs");

get(
openApiPath,
(request, response) -> {
response.type("application/json");
response.status(200);
return new Swaggerizer(apiDefn)
.asJsonWithPreferredServer(requestOrigin(request));
});

get(
swaggerUiPath,
(request, response) -> {
response.type("text/html");
response.status(200);
return new SwaggerUiPage(
apiDefn,
guiManagement,
openApiPath,
docsPath,
swaggerDownloadPath,
swaggerUiPath)
.html();
});

// TODO: api config to enable swagger and configure the URL
// TODO: move into swagger package
// now that we have an api definition we should be able to generate swagger
get(
"%s/docs/swagger".formatted(apiDefn.getPathPrefix()),
swaggerDownloadPath,
(request, response) -> {
String permissive = request.queryParam("permissive");

Expand All @@ -68,11 +98,12 @@ public ThingifierAutoDocGenRouting(

// TODO: the swaggerizer could be stored at a class level and allow caching to
// be used for the output
if (permissive == null) {
return new Swaggerizer(apiDefn).asJson();
} else {
return new Swaggerizer(apiDefn).asJson(true);
}
return new Swaggerizer(apiDefn)
.asJsonWithPreferredServer(permissive != null, requestOrigin(request));
});
}

private String requestOrigin(final HttpServerRequest request) {
return "%s://%s".formatted(request.scheme(), request.host());
Comment on lines +106 to +107

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the externally visible scheme for OpenAPI servers

When the application is deployed behind an HTTPS-terminating reverse proxy, request.scheme() is the backend connection scheme (normally http), while the browser is on https. This places http://<public-host> first in the generated servers list, so Swagger UI's Try it out requests become mixed-content requests and are blocked by browsers. Derive the public origin from a trusted forwarded-proto configuration/header or retain the configured HTTPS server instead.

Useful? React with 👍 / 👎.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import io.javalin.http.Context;
import io.javalin.http.HandlerType;
import io.javalin.http.staticfiles.Location;
import java.io.InputStream;
import java.net.URLConnection;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
Expand All @@ -16,6 +18,11 @@
import uk.co.compendiumdev.thingifier.api.response.ApiResponseError;

public final class JavalinHttpServer implements AutoCloseable {
private static final String[] STATIC_ASSET_PREFIXES = {
"/css/", "/js/", "/favicon/", "/images/"
};
private static final String[] STATIC_ASSET_FILES = {"/robots.txt", "/sitemap.bak"};

private final int port;
private final String staticFilePath;
private final HttpRouteRegistry registry;
Expand All @@ -39,6 +46,7 @@ public void start() {
staticFiles.directory = staticFilePath;
staticFiles.location = Location.CLASSPATH;
});
config.routes.before(this::serveClasspathStaticAsset);
for (HttpBeforeHandler beforeHandler : registry.beforeHandlers()) {
config.routes.before(ctx -> runBefore(ctx, beforeHandler));
}
Expand Down Expand Up @@ -78,6 +86,93 @@ public void start() {
app.start(port);
}

private void serveClasspathStaticAsset(final Context ctx) throws Exception {
if (ctx.method() != HandlerType.GET && ctx.method() != HandlerType.HEAD) {
return;
}

final String path = ctx.path();
if (!isStaticAssetPath(path)) {
return;
}

final InputStream resource =
Thread.currentThread()
.getContextClassLoader()
.getResourceAsStream(classpathStaticResource(path));
if (resource == null) {
return;
}

ctx.status(200);
ctx.header("Cache-Control", "max-age=0");
String contentType = contentTypeFor(path);
if (contentType != null) {
ctx.contentType(contentType);
}
if (ctx.method() == HandlerType.HEAD) {
resource.close();
ctx.result(new byte[0]);
} else {
ctx.result(resource);
}
ctx.skipRemainingHandlers();
}

private boolean isStaticAssetPath(final String path) {
for (String prefix : STATIC_ASSET_PREFIXES) {
if (path.startsWith(prefix)) {
return true;
}
}
for (String file : STATIC_ASSET_FILES) {
if (path.equals(file)) {
return true;
}
}
return false;
}

private String classpathStaticResource(final String path) {
String base = staticFilePath == null ? "" : staticFilePath.trim();
while (base.startsWith("/")) {
base = base.substring(1);
}
while (base.endsWith("/")) {
base = base.substring(0, base.length() - 1);
}
String resourcePath = path.startsWith("/") ? path.substring(1) : path;
if (base.isEmpty()) {
return resourcePath;
}
return base + "/" + resourcePath;
}

private String contentTypeFor(final String path) {
if (path.endsWith(".css")) {
return "text/css";
}
if (path.endsWith(".js")) {
return "application/javascript";
}
if (path.endsWith(".webmanifest")) {
return "application/manifest+json";
}
if (path.endsWith(".png")) {
return "image/png";
}
if (path.endsWith(".ico")) {
return "image/x-icon";
}
if (path.endsWith(".svg")) {
return "image/svg+xml";
}
if (path.endsWith(".txt")) {
return "text/plain";
}
return URLConnection.guessContentTypeFromName(path);
}

public void stop() {
if (app != null) {
app.stop();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,7 @@ public String getApiDocumentation(
}
}

output.append(paragraph(href("Open Swagger UI", prependPath + "/docs/swagger-ui")));
output.append(
paragraph(href("[download normal swagger file]", prependPath + "/docs/swagger")));
output.append(
Expand Down
Loading
Loading