From 90565b5ff0c08d5e3388f2c5aa8c6d7cc1aa12bd Mon Sep 17 00:00:00 2001 From: Sander van den Hoek Date: Mon, 28 Oct 2024 14:29:30 +0100 Subject: [PATCH] fix(RDF API): Incorrect internal IRIs (#4383) * Possible fix for RDF API using incorrect base URL * Revert "Possible fix for RDF API using incorrect base URL" This reverts commit 77c5c38a9fb1c21ea774d6bc1461fb61788b7631. * Removed unused returns * fixed variable naming * Updated tests to validate on bug * fix for incorrect internal IRI + some refactoring * Removed unneeded code (test on PR server shows no port in IRI while localhost did) * Revert "fixed variable naming" This reverts commit de0650ceec472e0076dc62b8733f6218e5902d1c. * Reverted wildcard imports done by IDE * Added test (+ some refactoring to add tests properly) * formatting * auto-formatting * Tests do not use actual ports but only override value due to failing Azure (no permission to use port 80) * Some code adjustments for more coverage --- .../java/org/molgenis/emx2/web/RDFApi.java | 55 +++------ .../WebApiSmokeTests.java | 32 +++++- backend/molgenis-emx2/build.gradle | 1 + .../org/molgenis/emx2/utils/URLUtils.java | 25 +++++ .../org/molgenis/emx2/utils/URLUtilsTest.java | 106 ++++++++++++++++++ 5 files changed, 174 insertions(+), 45 deletions(-) create mode 100644 backend/molgenis-emx2/src/main/java/org/molgenis/emx2/utils/URLUtils.java create mode 100644 backend/molgenis-emx2/src/test/java/org/molgenis/emx2/utils/URLUtilsTest.java diff --git a/backend/molgenis-emx2-webapi/src/main/java/org/molgenis/emx2/web/RDFApi.java b/backend/molgenis-emx2-webapi/src/main/java/org/molgenis/emx2/web/RDFApi.java index 9bdd3dde2f..2c253039e5 100644 --- a/backend/molgenis-emx2-webapi/src/main/java/org/molgenis/emx2/web/RDFApi.java +++ b/backend/molgenis-emx2-webapi/src/main/java/org/molgenis/emx2/web/RDFApi.java @@ -4,7 +4,8 @@ import io.javalin.Javalin; import io.javalin.http.Context; -import java.io.*; +import java.io.IOException; +import java.io.OutputStream; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -15,6 +16,7 @@ import org.molgenis.emx2.Schema; import org.molgenis.emx2.Table; import org.molgenis.emx2.rdf.RDFService; +import org.molgenis.emx2.utils.URLUtils; public class RDFApi { public static final String FORMAT = "format"; @@ -58,12 +60,11 @@ private static void defineApiRoutes(Javalin app, String apiLocation, RDFFormat f app.head("{schema}" + apiLocation + "/{table}column/{column}/", (ctx) -> rdfHead(ctx, format)); } - private static String rdfHead(Context ctx, RDFFormat format) { + private static void rdfHead(Context ctx, RDFFormat format) { ctx.contentType(selectFormat(ctx, format).getDefaultMIMEType()); - return ""; } - private static int rdfForDatabase(Context ctx, RDFFormat format) throws IOException { + private static void rdfForDatabase(Context ctx, RDFFormat format) throws IOException { format = selectFormat(ctx, format); // defines format if null Database db = sessionManager.getSession(ctx.req()).getDatabase(); @@ -84,7 +85,7 @@ private static int rdfForDatabase(Context ctx, RDFFormat format) throws IOExcept String[] schemaNamesArr = schemaNames.toArray(new String[schemaNames.size()]); Schema[] schemas = new Schema[schemaNames.size()]; - final String baseURL = extractBaseURL(ctx); + final String baseURL = URLUtils.extractBaseURL(ctx); final RDFService rdf = new RDFService(ctx.url().split("/api/")[0], baseURL, format); ctx.contentType(rdf.getMimeType()); @@ -99,17 +100,16 @@ private static int rdfForDatabase(Context ctx, RDFFormat format) throws IOExcept outputStream.flush(); outputStream.close(); - return 200; } - private static int rdfForSchema(Context ctx, RDFFormat format) throws IOException { + private static void rdfForSchema(Context ctx, RDFFormat format) throws IOException { format = selectFormat(ctx, format); // defines format if null Schema schema = getSchema(ctx); if (schema == null) { throw new MolgenisException("Schema " + ctx.pathParam("schema") + " was not found"); } - final String baseURL = extractBaseURL(ctx); + final String baseURL = URLUtils.extractBaseURL(ctx); RDFService rdf = new RDFService(baseURL, RDF_API_LOCATION, format); ctx.contentType(rdf.getMimeType()); @@ -118,10 +118,9 @@ private static int rdfForSchema(Context ctx, RDFFormat format) throws IOExceptio rdf.describeAsRDF(outputStream, null, null, null, schema); outputStream.flush(); outputStream.close(); - return 200; } - private static int rdfForTable(Context ctx, RDFFormat format) throws IOException { + private static void rdfForTable(Context ctx, RDFFormat format) throws IOException { format = selectFormat(ctx, format); // defines format if null Table table = getTableByIdOrName(ctx); @@ -129,7 +128,7 @@ private static int rdfForTable(Context ctx, RDFFormat format) throws IOException if (ctx.queryString() != null && !ctx.queryString().isBlank()) { rowId = ctx.queryString(); } - final String baseURL = extractBaseURL(ctx); + final String baseURL = URLUtils.extractBaseURL(ctx); RDFService rdf = new RDFService(baseURL, RDF_API_LOCATION, format); ctx.contentType(rdf.getMimeType()); @@ -138,15 +137,14 @@ private static int rdfForTable(Context ctx, RDFFormat format) throws IOException rdf.describeAsRDF(outputStream, table, rowId, null, table.getSchema()); outputStream.flush(); outputStream.close(); - return 200; } - private static int rdfForRow(Context ctx, RDFFormat format) throws IOException { + private static void rdfForRow(Context ctx, RDFFormat format) throws IOException { format = selectFormat(ctx, format); // defines format if null Table table = getTableByIdOrName(ctx); String rowId = sanitize(ctx.pathParam("row")); - final String baseURL = extractBaseURL(ctx); + final String baseURL = URLUtils.extractBaseURL(ctx); RDFService rdf = new RDFService(baseURL, RDF_API_LOCATION, format); ctx.contentType(rdf.getMimeType()); @@ -154,16 +152,15 @@ private static int rdfForRow(Context ctx, RDFFormat format) throws IOException { rdf.describeAsRDF(outputStream, table, rowId, null, table.getSchema()); outputStream.flush(); outputStream.close(); - return 200; } - private static int rdfForColumn(Context ctx, RDFFormat format) throws IOException { + private static void rdfForColumn(Context ctx, RDFFormat format) throws IOException { format = selectFormat(ctx, format); // defines format if null Table table = getTableByIdOrName(ctx); String columnName = sanitize(ctx.pathParam("column")); - final String baseURL = extractBaseURL(ctx); + final String baseURL = URLUtils.extractBaseURL(ctx); RDFService rdf = new RDFService(baseURL, RDF_API_LOCATION, format); ctx.contentType(rdf.getMimeType()); @@ -172,30 +169,6 @@ private static int rdfForColumn(Context ctx, RDFFormat format) throws IOExceptio rdf.describeAsRDF(outputStream, table, null, columnName, table.getSchema()); outputStream.flush(); outputStream.close(); - return 200; - } - - private static String extractBaseURL(Context ctx) { - // NOTE: The request.host() already includes the server port! - String scheme = ctx.scheme(); - String port = null; - var parts = ctx.host().split(":", 2); - String host = parts[0]; - if (parts.length == 2) { - if (!isWellKnownPort(scheme, parts[1])) { - port = parts[1]; - } - } - return scheme - + "://" - + host - + (port != null ? ":" + port : "") - + (!ctx.path().isEmpty() ? "/" + ctx.path() + "/" : "/"); - } - - private static boolean isWellKnownPort(String scheme, String port) { - return (scheme.equals("http") && port.equals("80")) - || (scheme.equals("https") && port.equals("443")); } private static RDFFormat selectFormat(Context ctx, RDFFormat format) { diff --git a/backend/molgenis-emx2-webapi/src/test/java/org.molgenis.emx2.web/WebApiSmokeTests.java b/backend/molgenis-emx2-webapi/src/test/java/org.molgenis.emx2.web/WebApiSmokeTests.java index 6c26487689..57c9651a2d 100644 --- a/backend/molgenis-emx2-webapi/src/test/java/org.molgenis.emx2.web/WebApiSmokeTests.java +++ b/backend/molgenis-emx2-webapi/src/test/java/org.molgenis.emx2.web/WebApiSmokeTests.java @@ -822,7 +822,7 @@ public void testMolgenisWebservice_robotsDotTxt() { } @Test - public void testRdfApi() { + public void testRdfApiRequest() { final String urlPrefix = "http://localhost:" + PORT; final String defaultContentType = "text/turtle"; @@ -865,16 +865,40 @@ public void testRdfApi() { .head(urlPrefix + "/pet store/api/jsonld"); rdfApiContentTypeRequest(200, jsonldContentType, ttlContentType) .head(urlPrefix + "/pet store/api/ttl"); + } - // Validate actual output. - String result = + @Test + void testRdfApiContent() { + // Output from global API call. + String resultBase = given() .sessionId(SESSION_ID) .when() .get("http://localhost:" + PORT + "/api/rdf?schemas=pet store") .getBody() .asString(); - assertFalse(result.contains("CatalogueOntologies")); + + // Output schema API call. + String resultSchema = + given() + .sessionId(SESSION_ID) + .when() + .get("http://localhost:" + PORT + "/pet store/api/rdf") + .getBody() + .asString(); + + assertAll( + // Validate base API. + () -> assertFalse(resultBase.contains("CatalogueOntologies")), + () -> + assertTrue( + resultBase.contains( + "http://localhost:" + PORT + "/pet%20store/api/rdf/Category/column/name")), + // Validate schema API. + () -> + assertTrue( + resultSchema.contains( + "http://localhost:" + PORT + "/pet%20store/api/rdf/Category/column/name"))); } /** diff --git a/backend/molgenis-emx2/build.gradle b/backend/molgenis-emx2/build.gradle index 6815ae658a..9083f322ad 100644 --- a/backend/molgenis-emx2/build.gradle +++ b/backend/molgenis-emx2/build.gradle @@ -1,4 +1,5 @@ dependencies { implementation 'org.graalvm.js:js:23.0.0' implementation 'org.graalvm.js:js-scriptengine:23.0.0' + testImplementation 'io.javalin:javalin-testtools:6.2.0' } diff --git a/backend/molgenis-emx2/src/main/java/org/molgenis/emx2/utils/URLUtils.java b/backend/molgenis-emx2/src/main/java/org/molgenis/emx2/utils/URLUtils.java new file mode 100644 index 0000000000..3999fe6e94 --- /dev/null +++ b/backend/molgenis-emx2/src/main/java/org/molgenis/emx2/utils/URLUtils.java @@ -0,0 +1,25 @@ +package org.molgenis.emx2.utils; + +import io.javalin.http.Context; +import java.util.Objects; + +public class URLUtils { + public static String extractBaseURL(Context ctx) { + String host = Objects.requireNonNull(ctx.host()); + String[] hostSplit = host.split(":", 2); + if (hostSplit.length == 2 && isDefaultPort(ctx.scheme(), hostSplit[1])) { + host = hostSplit[0]; + } + + return ctx.scheme() + + "://" + + host + // ctx.contextPath() should start with "/" + + (!ctx.contextPath().isEmpty() ? ctx.contextPath() + "/" : "/"); + } + + public static boolean isDefaultPort(String scheme, String port) { + return (scheme.equals("http") && port.equals("80")) + || (scheme.equals("https") && port.equals("443")); + } +} diff --git a/backend/molgenis-emx2/src/test/java/org/molgenis/emx2/utils/URLUtilsTest.java b/backend/molgenis-emx2/src/test/java/org/molgenis/emx2/utils/URLUtilsTest.java new file mode 100644 index 0000000000..e174b0af2e --- /dev/null +++ b/backend/molgenis-emx2/src/test/java/org/molgenis/emx2/utils/URLUtilsTest.java @@ -0,0 +1,106 @@ +package org.molgenis.emx2.utils; + +import static org.junit.jupiter.api.Assertions.*; + +import io.javalin.Javalin; +import io.javalin.http.Context; +import io.javalin.http.Handler; +import io.javalin.testtools.JavalinTest; +import jakarta.servlet.ServletOutputStream; +import java.io.IOException; +import org.junit.jupiter.api.Test; + +/** + * Original test used `app.start(port)` instead to actually test when a port is set if the behaviour + * was correct or not, but simplified the tests as Azure failed to build due to no permission to use + * port 80. Current tests only validate by setting `config.contextResolver.host`. + */ +class URLUtilsTest { + private void runTestConfig(Handler handler, String expected, String host, String contextPath) { + Javalin app = + Javalin.create( + config -> { + config.router.ignoreTrailingSlashes = true; + config.router.treatMultipleSlashesAsSingleSlash = true; + if (host != null) config.contextResolver.host = ctx -> host; + if (contextPath != null) config.router.contextPath = contextPath; + }); + app.get("/test", handler); + + JavalinTest.test( + app, + ((server, client) -> { + if (contextPath != null) { + assertEquals(expected, client.get(contextPath + "/test").body().string()); + } else { + assertEquals(expected, client.get("/test").body().string()); + } + })); + } + + private void contextWrapper(Context ctx, byte[] bytes) { + ServletOutputStream outputStream = ctx.outputStream(); + try { + outputStream.write(bytes); + outputStream.flush(); + outputStream.close(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Test + void testBaseUrlMolgenis() { + runTestConfig( + (ctx) -> contextWrapper(ctx, URLUtils.extractBaseURL(ctx).getBytes()), + "http://molgenis.org/", + "molgenis.org", + null); + } + + @Test + void testBaseUrlMolgenis80() { + runTestConfig( + (ctx) -> contextWrapper(ctx, URLUtils.extractBaseURL(ctx).getBytes()), + "http://molgenis.org/", + "molgenis.org:80", + null); + } + + @Test + void testBaseUrlMolgenis8080() { + runTestConfig( + (ctx) -> contextWrapper(ctx, URLUtils.extractBaseURL(ctx).getBytes()), + "http://molgenis.org:8080/", + "molgenis.org:8080", + null); + } + + /** Most complex scenario. */ + @Test + void testBaseUrlMolgenisSubdir8080() { + runTestConfig( + (ctx) -> contextWrapper(ctx, URLUtils.extractBaseURL(ctx).getBytes()), + "http://molgenis.org:8080/subdir/", + "molgenis.org:8080", + "/subdir"); + } + + @Test + void testContextPathTrailingSlash() { + runTestConfig( + (ctx) -> contextWrapper(ctx, URLUtils.extractBaseURL(ctx).getBytes()), + "http://molgenis.org:8080/subdir/", + "molgenis.org:8080", + "/subdir/"); + } + + @Test + void testDefaultPort() { + assertAll( + () -> assertTrue(URLUtils.isDefaultPort("http", "80")), + () -> assertFalse(URLUtils.isDefaultPort("http", "8080")), + () -> assertTrue(URLUtils.isDefaultPort("https", "443")), + () -> assertFalse(URLUtils.isDefaultPort("https", "80"))); + } +}