diff --git a/README.md b/README.md index c5ebb40..d6155de 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,16 @@ library, not a framework, with utilities such as: ## Main Verticle -The [Vert.x OpenAPI](https://vertx.io/docs/vertx-web-openapi/java/) unlike +The [Vert.x OpenAPI](https://vertx.io/docs/vertx-openapi/java/) unlike many OpenAPI implementations does not generate any code for you. Everything happens at run-time. Only requests are validated, not responses. +The OpenAPI implementaion of Vert.x 5 does not allow external references - even +if they are local files [ref](https://vertx.io/docs/vertx-openapi/java/#_openapicontract). +If the OpenAPI spec in use has local file references the YAML must be preprocessed with the +openapi-deref-plugin. See the [openapi-deref-plugin](#plugin-openapi-deref-plugin) section +for details about handling external references in OpenAPI specifications. + Place your OpenAPI specification and auxiliary files somewhere in `resources`, such as `resources/openapi`. @@ -89,24 +95,25 @@ For an OpenAPI based implementation it could look as follows: public MyApi implements RouterCreator, TenantInitHooks { @Override public Future createRouter(Vertx vertx) { - return RouterBuilder.create(vertx, "openapi/myapi-1.0.yaml") - .map(routerBuilder -> { - handlers(vertx, routerBuilder); - return routerBuilder.createRouter(); - }); + return OpenAPIContract.from(vertx, "openapi/myapi-1.0.yaml") + .map(contract -> { + RouterBuilder routerBuilder = RouterBuilder.create(vertx, contract); + handlers(vertx, routerBuilder); + return routerBuilder.createRouter(); + }); } private void handlers(Vertx vertx, RouterBuilder routerBuilder) { routerBuilder - .operation("postTitles") // operationId in spec - .handler(ctx -> { + .getRoute("postTitles") // operationId in spec + .addHandler(ctx -> { // doesn't do anything at the moment! ctx.response().setStatusCode(204); ctx.response().end(); }); routerBuilder - .operation("getTitles") - .handler(ctx -> getTitles(vertx, ctx) + .getRoute("getTitles") + .addHandler(ctx -> getTitles(vertx, ctx) .onFailure(cause -> { ctx.response().setStatusCode(500); ctx.response().end(cause.getMessage()); @@ -128,6 +135,54 @@ The Tenant2Api implementation deals with purge (removes schema with cascade). Your implementation should only consider upgrade/downgrade. On purge, `preInit` is called, but `postInit` is not. +## Plugin openapi-deref-plugin + +The purpose of the openapi-deref-plugin is to de-reference `$ref` references in the OpenAPI +specification. The result is one YAML file with all resources embedded. If there are +only references to components inside the OpenAPI YAML file from the beginning, it is not +necessary to use this plugin. + +If the OpenAPI specification is located in `resources/openapi` (recommened), then +the minimal way to use the plugin is to use: + +``` + + org.folio + openapi-deref-plugin + 4.0.0 + + + dereference-books + + dereference + + generate-resources + + + +``` + +The configuration has the following properties: + + * `input` : glob-path for input files to search. Default value is `${basedir}/src/main/resources/openapi/*.yaml` + * `output` : output directory. Default value is `${project.build.directory}/classes/openapi`. + +As an example if there are OpenAPI specs in test resources, the `extensions` list could be extended with: + +``` + + dereference-echo + + dereference + + generate-resources + + ${project.basedir}/src/test/resources/openapi/*.yaml + ${project.build.directory}/test-classes/openapi + + +``` + ## PostgreSQL The PostgreSQL support is minimal. There's just enough to perform tenant diff --git a/core/pom.xml b/core/pom.xml index 4a7e79b..f8acad2 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -3,7 +3,7 @@ org.folio folio-vertx-lib - 3.5.0-SNAPSHOT + 4.0.0-SNAPSHOT vertx-lib @@ -22,15 +22,19 @@ io.vertx - vertx-web-openapi + vertx-openapi io.vertx - vertx-rx-java2 + vertx-web-openapi-router + + + io.vertx + vertx-web-validation io.vertx - vertx-web-api-contract + vertx-rx-java2 io.vertx @@ -148,7 +152,31 @@ - + + org.folio + openapi-deref-plugin + 4.0.0-SNAPSHOT + + + dereference-tenant + + dereference + + generate-resources + + + dereference-echo + + dereference + + generate-resources + + ${project.basedir}/src/test/resources/openapi/*.yaml + ${project.build.directory}/test-classes/openapi + + + + org.apache.maven.plugins maven-jar-plugin diff --git a/core/src/main/java/org/folio/tlib/RouterCreator.java b/core/src/main/java/org/folio/tlib/RouterCreator.java index 6c6b282..7a9330a 100644 --- a/core/src/main/java/org/folio/tlib/RouterCreator.java +++ b/core/src/main/java/org/folio/tlib/RouterCreator.java @@ -5,6 +5,7 @@ import io.vertx.core.Vertx; import io.vertx.ext.web.Router; import org.folio.okapi.common.XOkapiHeaders; +import org.folio.okapi.common.logging.FolioLocal; import org.folio.okapi.common.logging.FolioLoggingContext; /** @@ -40,13 +41,13 @@ static Future mountAll(Vertx vertx, RouterCreator [] routerCreators, Str Future future = Future.succeededFuture(); Router router = Router.router(vertx); router.route().handler(ctx -> { - FolioLoggingContext.put(FolioLoggingContext.MODULE_ID_LOGGING_VAR_NAME, module); + FolioLoggingContext.put(FolioLocal.MODULE_ID, module); MultiMap headers = ctx.request().headers(); - FolioLoggingContext.put(FolioLoggingContext.TENANT_ID_LOGGING_VAR_NAME, + FolioLoggingContext.put(FolioLocal.TENANT_ID, headers.get(XOkapiHeaders.TENANT)); - FolioLoggingContext.put(FolioLoggingContext.REQUEST_ID_LOGGING_VAR_NAME, + FolioLoggingContext.put(FolioLocal.REQUEST_ID, headers.get(XOkapiHeaders.REQUEST_ID)); - FolioLoggingContext.put(FolioLoggingContext.USER_ID_LOGGING_VAR_NAME, + FolioLoggingContext.put(FolioLocal.USER_ID, headers.get(XOkapiHeaders.USER_ID)); ctx.next(); }); diff --git a/core/src/main/java/org/folio/tlib/api/Tenant2Api.java b/core/src/main/java/org/folio/tlib/api/Tenant2Api.java index db5aaf5..188876a 100644 --- a/core/src/main/java/org/folio/tlib/api/Tenant2Api.java +++ b/core/src/main/java/org/folio/tlib/api/Tenant2Api.java @@ -8,10 +8,9 @@ import io.vertx.core.json.JsonObject; import io.vertx.ext.web.Router; import io.vertx.ext.web.RoutingContext; -import io.vertx.ext.web.openapi.RouterBuilder; -import io.vertx.ext.web.validation.RequestParameter; -import io.vertx.ext.web.validation.RequestParameters; -import io.vertx.ext.web.validation.ValidationHandler; +import io.vertx.ext.web.openapi.router.RouterBuilder; +import io.vertx.openapi.contract.OpenAPIContract; +import io.vertx.openapi.validation.ValidatedRequest; import io.vertx.sqlclient.Tuple; import java.util.HashMap; import java.util.LinkedList; @@ -20,7 +19,6 @@ import java.util.UUID; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.folio.okapi.common.XOkapiHeaders; import org.folio.tlib.RouterCreator; import org.folio.tlib.TenantInitHooks; import org.folio.tlib.postgres.impl.TenantPgPoolImpl; @@ -62,8 +60,12 @@ static void failHandler(RoutingContext ctx, int code, Throwable e) { failHandler(ctx, ctx.statusCode(), HttpResponseStatus.valueOf(ctx.statusCode()).reasonPhrase()); } else { - log.error("{}", e.getMessage()); - failHandler(ctx, code, e.getMessage()); + Throwable t = e.getCause(); + if (t == null) { + t = e; + } + log.error("{}", e.getMessage(), e); + failHandler(ctx, code, t.getMessage()); } } @@ -187,12 +189,13 @@ private static Future saveJob(Vertx vertx, JsonObject tenantJob) { private void handlers(Vertx vertx, RouterBuilder routerBuilder) { log.info("setting up tenant handlers ... begin"); - routerBuilder - .operation("postTenant") - .handler(ctx -> { - RequestParameters params = ctx.get(ValidationHandler.REQUEST_CONTEXT_KEY); - log.info("postTenant handler {}", params.toJson().encode()); - JsonObject tenantAttributes = ctx.body().asJsonObject(); + + routerBuilder.getRoute("postTenant") + .addHandler(ctx -> { + ValidatedRequest validatedRequest = + ctx.get(RouterBuilder.KEY_META_DATA_VALIDATED_REQUEST); + JsonObject tenantAttributes = validatedRequest.getBody().getJsonObject(); + log.info("postTenant handler {}", tenantAttributes.encode()); String tenant = TenantUtil.tenant(ctx); createJob(vertx, tenant, tenantAttributes) @@ -218,17 +221,15 @@ private void handlers(Vertx vertx, RouterBuilder routerBuilder) { failHandler(ctx, 500, e); }); }) - .failureHandler(ctx -> Tenant2Api.failHandler(ctx, 400, ctx.failure())); - routerBuilder - .operation("getTenantJob") - .handler(ctx -> { - RequestParameters params = ctx.get(ValidationHandler.REQUEST_CONTEXT_KEY); - String id = params.pathParameter("id").getString(); - String tenant = params.headerParameter(XOkapiHeaders.TENANT).getString(); - RequestParameter waitParameter = params.queryParameter("wait"); - int wait = waitParameter != null ? waitParameter.getInteger() : 0; - log.info("getTenantJob handler id={} wait={}", id, - waitParameter != null ? waitParameter.getInteger() : "null"); + .addFailureHandler(ctx -> Tenant2Api.failHandler(ctx, 400, ctx.failure())); + + routerBuilder.getRoute("getTenantJob") + .addHandler(ctx -> { + String id = ctx.pathParam("id"); + String tenant = TenantUtil.tenant(ctx); + List waitParameter = ctx.queryParam("wait"); + int wait = waitParameter.isEmpty() ? 0 : Integer.parseInt(waitParameter.get(0)); + log.info("getTenantJob handler id={} wait={}", id, wait); getJob(vertx, tenant, UUID.fromString(id), wait) .onSuccess(res -> { if (res == null) { @@ -241,13 +242,12 @@ private void handlers(Vertx vertx, RouterBuilder routerBuilder) { }) .onFailure(e -> failHandler(ctx, 500, e)); }) - .failureHandler(ctx -> Tenant2Api.failHandler(ctx, 400, ctx.failure())); - routerBuilder - .operation("deleteTenantJob") - .handler(ctx -> { - RequestParameters params = ctx.get(ValidationHandler.REQUEST_CONTEXT_KEY); - String id = params.pathParameter("id").getString(); - String tenant = params.headerParameter(XOkapiHeaders.TENANT).getString(); + .addFailureHandler(ctx -> Tenant2Api.failHandler(ctx, 400, ctx.failure())); + + routerBuilder.getRoute("deleteTenantJob") + .addHandler(ctx -> { + String id = ctx.pathParam("id"); + String tenant = TenantUtil.tenant(ctx); log.info("deleteTenantJob handler id={}", id); deleteJob(vertx, tenant, UUID.fromString(id)) .onSuccess(res -> { @@ -260,7 +260,8 @@ private void handlers(Vertx vertx, RouterBuilder routerBuilder) { }) .onFailure(e -> failHandler(ctx, 500, e)); }) - .failureHandler(ctx -> Tenant2Api.failHandler(ctx, 400, ctx.failure())); + .addFailureHandler(ctx -> Tenant2Api.failHandler(ctx, 400, ctx.failure())); + log.info("setting up tenant handlers ... done"); } @@ -272,11 +273,11 @@ private void handlers(Vertx vertx, RouterBuilder routerBuilder) { */ @Override public Future createRouter(Vertx vertx) { - return RouterBuilder.create(vertx, "openapi/tenant-2.0.yaml") - .map(routerBuilder -> { - handlers(vertx, routerBuilder); - return routerBuilder.createRouter(); - }); + return OpenAPIContract.from(vertx, "openapi/tenant-2.0.yaml") + .map(contract -> { + RouterBuilder routerBuilder = RouterBuilder.create(vertx, contract); + handlers(vertx, routerBuilder); + return routerBuilder.createRouter(); + }); } - } diff --git a/core/src/main/java/org/folio/tlib/postgres/TenantPgPool.java b/core/src/main/java/org/folio/tlib/postgres/TenantPgPool.java index 433bb15..b3ddec1 100644 --- a/core/src/main/java/org/folio/tlib/postgres/TenantPgPool.java +++ b/core/src/main/java/org/folio/tlib/postgres/TenantPgPool.java @@ -3,7 +3,6 @@ import io.vertx.core.Future; import io.vertx.core.Vertx; import io.vertx.pgclient.PgConnectOptions; -import io.vertx.pgclient.PgPool; import io.vertx.sqlclient.Pool; import io.vertx.sqlclient.PoolOptions; import io.vertx.sqlclient.Row; @@ -14,9 +13,9 @@ import org.folio.tlib.postgres.impl.TenantPgPoolImpl; /** - * The {@link PgPool} for a tenant. + * The {@link Pool} for a tenant. */ -public interface TenantPgPool extends PgPool { +public interface TenantPgPool extends Pool { /** * create tenant pool for tenant. diff --git a/core/src/main/java/org/folio/tlib/postgres/impl/TenantPgPoolImpl.java b/core/src/main/java/org/folio/tlib/postgres/impl/TenantPgPoolImpl.java index b5500db..579c112 100644 --- a/core/src/main/java/org/folio/tlib/postgres/impl/TenantPgPoolImpl.java +++ b/core/src/main/java/org/folio/tlib/postgres/impl/TenantPgPoolImpl.java @@ -1,16 +1,13 @@ package org.folio.tlib.postgres.impl; -import io.vertx.core.AsyncResult; -import io.vertx.core.Context; import io.vertx.core.Future; -import io.vertx.core.Handler; import io.vertx.core.Vertx; import io.vertx.core.buffer.Buffer; import io.vertx.core.json.JsonObject; -import io.vertx.core.net.OpenSSLEngineOptions; +import io.vertx.core.net.ClientSSLOptions; import io.vertx.core.net.PemTrustOptions; +import io.vertx.pgclient.PgBuilder; import io.vertx.pgclient.PgConnectOptions; -import io.vertx.pgclient.PgPool; import io.vertx.pgclient.SslMode; import io.vertx.sqlclient.Pool; import io.vertx.sqlclient.PoolOptions; @@ -26,19 +23,17 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.function.Function; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.folio.okapi.common.GenericCompositeFuture; import org.folio.tlib.postgres.TenantPgPool; /** - * The {@link PgPool} for a tenant. + * The {@link Pool} for a tenant. */ public class TenantPgPoolImpl implements TenantPgPool { private static final Logger log = LogManager.getLogger(TenantPgPoolImpl.class); - static Map pgPoolMap = new HashMap<>(); + static Map pgPoolMap = new HashMap<>(); static String host = System.getenv("DB_HOST"); static String port = System.getenv("DB_PORT"); @@ -53,7 +48,7 @@ public class TenantPgPoolImpl implements TenantPgPool { static PgConnectOptions pgConnectOptions = new PgConnectOptions(); final String tenant; - PgPool pgPool; + Pool pgPool; JsonObject config; final PoolOptions poolOptions; @@ -99,13 +94,13 @@ private TenantPgPoolImpl(Vertx vertx, String tenant, PoolOptions poolOptions) { /** * Create pool for Tenant. * - *

The returned pool implements PgPool interface so this cab be used like PgPool as usual. - * PgPool.setModule *must* be called before the queries are executed, since schema is based - * on module name. + *

The returned pool implements Pool interface so this can be used like Pool as usual. + * TenantPgPool.setModule *must* be called before the queries are executed, since schema is + * based on module name. * * @param vertx Vert.x handle * @param tenant Tenant - * @return pool with PgPool semantics + * @return pool with Pool semantics */ public static TenantPgPoolImpl tenantPgPool(Vertx vertx, String tenant) { if (module == null) { @@ -138,11 +133,11 @@ public static TenantPgPoolImpl tenantPgPool(Vertx vertx, String tenant) { } if (serverPem != null) { connectOptions.setSslMode(SslMode.VERIFY_FULL); - connectOptions.setHostnameVerificationAlgorithm("HTTPS"); - connectOptions.setPemTrustOptions( - new PemTrustOptions().addCertValue(Buffer.buffer(serverPem))); - connectOptions.setEnabledSecureTransportProtocols(Collections.singleton("TLSv1.3")); - connectOptions.setOpenSslEngineOptions(new OpenSSLEngineOptions()); + ClientSSLOptions cso = new ClientSSLOptions(); + cso.setHostnameVerificationAlgorithm("HTTPS"); + cso.setTrustOptions(new PemTrustOptions().addCertValue(Buffer.buffer(serverPem))); + cso.setEnabledSecureTransportProtocols(Collections.singleton("TLSv1.3")); + connectOptions.setSslOptions(cso); } PoolOptions poolOptions = new PoolOptions(); if (maxPoolSize != null) { @@ -150,7 +145,7 @@ public static TenantPgPoolImpl tenantPgPool(Vertx vertx, String tenant) { } TenantPgPoolImpl tenantPgPool = new TenantPgPoolImpl(vertx, sanitize(tenant), poolOptions); tenantPgPool.pgPool = pgPoolMap.computeIfAbsent(connectOptions, key -> - PgPool.pool(vertx, connectOptions, poolOptions)); + PgBuilder.pool().using(vertx).connectingTo(connectOptions).with(poolOptions).build()); return tenantPgPool; } @@ -169,10 +164,6 @@ public Pool getPool() { return pgPool; } - @Override - public void getConnection(Handler> handler) { - pgPool.getConnection(handler); - } @Override public Future getConnection() { @@ -196,13 +187,6 @@ public PreparedQuery> preparedQuery(String s, PrepareOptions prepare return pgPool.preparedQuery(s, prepareOptions); } - @Override - public void close(Handler> handler) { - // release our pool from the map - while (pgPoolMap.values().remove(pgPool)) { } - pgPool.close(handler); - } - @Override public Future close() { // release our pool from the map @@ -254,16 +238,6 @@ Future explainAnalyze(String sql, Tuple tuple) { }).mapEmpty(); } - @Override - public PgPool connectHandler(Handler handler) { - return pgPool.connectHandler(handler); - } - - @Override - public PgPool connectionProvider(Function> function) { - return pgPool.connectionProvider(function); - } - @Override public int size() { return pgPool.size(); @@ -277,7 +251,7 @@ public int size() { public static Future closeAll() { List> futures = new ArrayList<>(pgPoolMap.size()); pgPoolMap.forEach((a, b) -> futures.add(b.close())); - return GenericCompositeFuture.all(futures) + return Future.all(futures) .onComplete(x -> pgPoolMap.clear()) .mapEmpty(); } diff --git a/core/src/main/resources/log4j2.properties b/core/src/main/resources/log4j2.properties index 48964d0..38032d8 100644 --- a/core/src/main/resources/log4j2.properties +++ b/core/src/main/resources/log4j2.properties @@ -1,19 +1,27 @@ status = error name = PropertiesConfig -packages = org.folio.okapi.common.logging filters = threshold filter.threshold.type = ThresholdFilter filter.threshold.level = info -appenders = console +appender.full.type = Console +appender.full.name = FULL +appender.full.layout.type = PatternLayout +appender.full.layout.pattern = %d{HH:mm:ss} [${map:requestid}] [${map:tenantid}] [${map:userid}] [${map:moduleid}] %-5p %-20.20C{1} %m%n -appender.console.type = Console -appender.console.name = STDOUT -appender.console.layout.type = PatternLayout -appender.console.layout.pattern = %d{HH:mm:ss} [$${FolioLoggingContext:requestid}] [$${FolioLoggingContext:tenantid}] [$${FolioLoggingContext:userid}] [$${FolioLoggingContext:moduleid}] %-5p %-20.20C{1} %m%n +appender.empty.type = Console +appender.empty.name = EMPTY +appender.empty.layout.type = PatternLayout +appender.empty.layout.pattern = %d{HH:mm:ss} [] [] [] [] %-5p %-20.20C{1} %m%n rootLogger.level = info -rootLogger.appenderRefs = info -rootLogger.appenderRef.stdout.ref = STDOUT +rootLogger.appenderRefs = empty +rootLogger.appenderRef.basic.ref = EMPTY + +logger.full.level = info +logger.full.appenderRefs = full +logger.full.appenderRef.full.ref = FULL +logger.full.name = full +logger.full.additivity = false diff --git a/core/src/main/resources/openapi/schemas/parameters.json b/core/src/main/resources/openapi/schemas/parameters.json index 23d8eac..8188d05 100644 --- a/core/src/main/resources/openapi/schemas/parameters.json +++ b/core/src/main/resources/openapi/schemas/parameters.json @@ -1,5 +1,5 @@ { - "description": "List of key/value parameters of an error", + "description": "List of key/value parameters", "type": "array", "items": { "type": "object", diff --git a/core/src/main/resources/openapi/tenant-2.0.yaml b/core/src/main/resources/openapi/tenant-2.0.yaml index 653fbcf..c295818 100644 --- a/core/src/main/resources/openapi/tenant-2.0.yaml +++ b/core/src/main/resources/openapi/tenant-2.0.yaml @@ -16,8 +16,6 @@ paths: application/json: schema: $ref: "#/components/schemas/tenantAttributes" - example: - $ref: "examples/tenantAttributes.sample" required: true responses: "204": @@ -96,7 +94,7 @@ components: content: application/json: schema: - $ref: "#/components/schemas/errors" + $ref: schemas/errors.json examples: response: value: examples/errors.sample @@ -132,3 +130,5 @@ components: $ref: schemas/tenantJob.json errors: $ref: schemas/errors.json + parameters: + $ref: schemas/parameters.json diff --git a/core/src/test/java/org/folio/tlib/api/EchoApi.java b/core/src/test/java/org/folio/tlib/api/EchoApi.java index add8d54..12f2670 100644 --- a/core/src/test/java/org/folio/tlib/api/EchoApi.java +++ b/core/src/test/java/org/folio/tlib/api/EchoApi.java @@ -6,7 +6,9 @@ import io.vertx.ext.web.Router; import io.vertx.ext.web.RoutingContext; import io.vertx.ext.web.handler.BodyHandler; -import io.vertx.ext.web.openapi.RouterBuilder; +import io.vertx.ext.web.openapi.router.OpenAPIRoute; +import io.vertx.ext.web.openapi.router.RouterBuilder; +import io.vertx.openapi.contract.OpenAPIContract; import org.folio.tlib.RouterCreator; public class EchoApi implements RouterCreator { @@ -29,18 +31,20 @@ static void handleError(RoutingContext ctx, int status, String msg) { @Override public Future createRouter(Vertx vertx) { - return RouterBuilder.create(vertx, "openapi/echo.yaml") - .map(routerBuilder -> { - // https://vertx.io/docs/vertx-web/java/#_limiting_body_size - routerBuilder.rootHandler(BodyHandler.create().setBodyLimit(BODY_LIMIT)); - routerBuilder - .operation("echo") // operationId in spec - .handler(ctx -> echo(ctx) - .onFailure(e -> handleError(ctx, 500, e)) - ) - .failureHandler(ctx -> handleError(ctx, 400, ctx.failure())); - return routerBuilder.createRouter(); - }); + return OpenAPIContract.from(vertx, "openapi/echo.yaml") + .map(contract -> { + RouterBuilder routerBuilder = RouterBuilder.create(vertx, contract); + // https://vertx.io/docs/vertx-web/java/#_limiting_body_size + routerBuilder.rootHandler(BodyHandler.create().setBodyLimit(BODY_LIMIT)); + OpenAPIRoute openApiRoute = routerBuilder.getRoute("echo"); + openApiRoute.setDoValidation(false); + openApiRoute + .addHandler(ctx -> echo(ctx) + .onFailure(e -> handleError(ctx, 500, e)) + ) + .addFailureHandler(ctx -> handleError(ctx, 400, ctx.failure())); + return routerBuilder.createRouter(); + }); } Future echo(RoutingContext ctx) { diff --git a/core/src/test/java/org/folio/tlib/api/EchoApiTest.java b/core/src/test/java/org/folio/tlib/api/EchoApiTest.java index cd95148..e0fc084 100644 --- a/core/src/test/java/org/folio/tlib/api/EchoApiTest.java +++ b/core/src/test/java/org/folio/tlib/api/EchoApiTest.java @@ -62,7 +62,20 @@ void testHealth() { } @Test - void testEcho200() { + void testEcho200_1() { + String request = "x".repeat(5); + RestAssured.given() + .header("X-Okapi-Tenant", "tenant") + .contentType(ContentType.TEXT) + .body(request) + .post("/echo") + .then().statusCode(200) + .contentType(ContentType.TEXT) + .body(is(request)); + } + + @Test + void testEcho200_2() { String request = "x".repeat(BODY_LIMIT); RestAssured.given() .header("X-Okapi-Tenant", "tenant") diff --git a/core/src/test/java/org/folio/tlib/api/Tenant2ApiTest.java b/core/src/test/java/org/folio/tlib/api/Tenant2ApiTest.java index 9211fa2..dfbb9ec 100644 --- a/core/src/test/java/org/folio/tlib/api/Tenant2ApiTest.java +++ b/core/src/test/java/org/folio/tlib/api/Tenant2ApiTest.java @@ -129,19 +129,18 @@ void testPostTenantBadTenant1() { .post("/_/tenant") .then().statusCode(400) .contentType(ContentType.TEXT) - .body(containsString("X-Okapi-Tenant in location HEADER: provided string should respect pattern")); + .body(containsString("The value of header parameter X-Okapi-Tenant is invalid. Reason: String does not match pattern")); } @Test void testPostTenantBadTenant2() { - String tenant = "test\"lib"; RestAssured.given() .contentType(ContentType.JSON) .body("{\"module_to\" : \"mod-eusage-reports-1.0.0\"}") .post("/_/tenant") .then().statusCode(400) .contentType(ContentType.TEXT) - .body(containsString("Missing parameter X-Okapi-Tenant in HEADER")); + .body(containsString("The related request / response does not contain the required header parameter X-Okapi-Tenant")); } @Test @@ -416,7 +415,7 @@ void testGetBadId(){ .get("/_/tenant/" + id) .then().statusCode(400) .contentType(ContentType.TEXT) - .body(containsString("Validation error for parameter id in location")); + .body(containsString("he value of path parameter id is invalid. Reason: String does not match format \"uuid\"")); } @Test @@ -436,7 +435,7 @@ void testDeleteBadId(){ .delete("/_/tenant/" + id) .then().statusCode(400) .contentType(ContentType.TEXT) - .body(containsString("Validation error for parameter id in location")); + .body(containsString("The value of path parameter id is invalid. Reason: String does not match format \"uuid\"")); } @Test diff --git a/core/src/test/java/org/folio/tlib/postgres/PgCqlStorageTest.java b/core/src/test/java/org/folio/tlib/postgres/PgCqlStorageTest.java index 95e056b..d0858f6 100644 --- a/core/src/test/java/org/folio/tlib/postgres/PgCqlStorageTest.java +++ b/core/src/test/java/org/folio/tlib/postgres/PgCqlStorageTest.java @@ -5,8 +5,9 @@ import io.vertx.core.json.JsonObject; import io.vertx.junit5.VertxExtension; import io.vertx.junit5.VertxTestContext; +import io.vertx.pgclient.PgBuilder; import io.vertx.pgclient.PgConnectOptions; -import io.vertx.pgclient.PgPool; +import io.vertx.sqlclient.Pool; import io.vertx.sqlclient.PoolOptions; import java.util.HashSet; import java.util.Iterator; @@ -42,7 +43,7 @@ class PgCqlStorageTest { @Container static final PostgreSQLContainer container = TenantPgPoolContainer.create(); - public static PgPool pgPool; + public static Pool pgPool; static List batch = List.of( Tuple.of(UUID.randomUUID(), "On the road with Bob Dylan", "Larry \"Ratso\" Sloman"), @@ -53,14 +54,16 @@ class PgCqlStorageTest { @BeforeAll static void beforeAll(Vertx vertx, VertxTestContext context) { - pgPool = PgPool.pool(vertx, - new PgConnectOptions() + PgConnectOptions connectOptions = new PgConnectOptions() .setPort(container.getFirstMappedPort()) .setHost(container.getHost()) .setDatabase(container.getDatabaseName()) .setUser(container.getUsername()) - .setPassword(container.getPassword()), - new PoolOptions().setMaxSize(2)); + .setPassword(container.getPassword()); + + PoolOptions poolOptions = new PoolOptions().setMaxSize(2); + pgPool = PgBuilder.pool().using(vertx).connectingTo(connectOptions).with(poolOptions).build(); + pgPool.query("CREATE TABLE entries (id UUID, title TEXT, author TEXT)") .execute() .compose(x -> insertSample()) diff --git a/core/src/test/java/org/folio/tlib/postgres/TenantPgPoolTest.java b/core/src/test/java/org/folio/tlib/postgres/TenantPgPoolTest.java index 314ff08..88cb1b6 100644 --- a/core/src/test/java/org/folio/tlib/postgres/TenantPgPoolTest.java +++ b/core/src/test/java/org/folio/tlib/postgres/TenantPgPoolTest.java @@ -8,7 +8,6 @@ import io.vertx.junit5.VertxExtension; import io.vertx.junit5.VertxTestContext; import io.vertx.sqlclient.PrepareOptions; -import io.vertx.sqlclient.SqlConnection; import io.vertx.sqlclient.Tuple; import io.vertx.sqlclient.templates.SqlTemplate; import java.io.IOException; @@ -101,7 +100,7 @@ void after(Vertx vertx, VertxTestContext context) { private Future withPool(Vertx vertx, Function> mapper) { TenantPgPool pool = TenantPgPool.pool(vertx, "diku"); Future future = mapper.apply(pool); - return future.eventually(x -> pool.close()); + return future.onComplete(x -> pool.close()); } @Test @@ -186,7 +185,7 @@ void getConnection1(Vertx vertx, VertxTestContext context) { @SuppressWarnings("squid:S2699") // "Add at least one assertion" SQ does not know about context.* void getConnection2(Vertx vertx, VertxTestContext context) { withPool(vertx, pool -> - Future.future(promise -> pool.getConnection(promise)) + pool.getConnection() .compose(con -> con.query("SELECT count(*) FROM pg_database").execute())) .onComplete(context.succeedingThenComplete()); } @@ -265,7 +264,7 @@ void size(Vertx vertx, VertxTestContext context) { @SuppressWarnings("squid:S2699") // "Add at least one assertion" SQ does not know about context.* void close(Vertx vertx, VertxTestContext context) { TenantPgPool pool = TenantPgPool.pool(vertx, "diku"); - pool.close(context.succeedingThenComplete()); + pool.close().onComplete(context.succeedingThenComplete()); } @Test @@ -275,18 +274,4 @@ void closeAll(Vertx vertx, VertxTestContext context) { TenantPgPool.closeAll().onComplete(context.succeedingThenComplete()); } - @Test - void connectHandler(Vertx vertx, VertxTestContext context) { - TenantPgPool pool = TenantPgPool.pool(vertx, "diku"); - pool.connectHandler(conn -> - conn.query("CREATE TEMP TABLE connecthandler()") - .execute() - .eventually(x -> conn.close()) - ); - pool.withConnection(conn -> conn.preparedQuery("SELECT * FROM connecthandler").execute()) - .onComplete(context.succeeding(rowSet -> { - assertThat(rowSet.size(), is(0)); - context.completeNow(); - })); - } } diff --git a/core/src/test/resources/openapi/reftest.yaml b/core/src/test/resources/openapi/reftest.yaml new file mode 100644 index 0000000..4d5b73d --- /dev/null +++ b/core/src/test/resources/openapi/reftest.yaml @@ -0,0 +1,42 @@ +openapi: 3.0.0 +info: + title: ref test + version: v1 +paths: + /echo: + parameters: + - $ref: "#/components/parameters/okapi_tenant" + - $ref: headers/okapi-token.yaml + get: + description: echo request + operationId: refTestEcho + responses: + "200": + description: echo ok + content: + text/plain: + schema: + type: string + "400": + $ref: "#/components/responses/trait_400" +components: + responses: + trait_400: + description: Bad request + content: + text/plain: + schema: + type: string + example: Invalid JSON in request + application/json: + schema: + type: object + example: {"error":"Invalid JSON in request"} + parameters: + okapi_tenant: + name: X-Okapi-Tenant + in: header + required: true + schema: + type: string + pattern: '^[_a-z][_a-z0-9]*$' diff --git a/mod-example/pom.xml b/mod-example/pom.xml index 55d4ee2..8080175 100644 --- a/mod-example/pom.xml +++ b/mod-example/pom.xml @@ -4,40 +4,48 @@ org.folio folio-vertx-lib - 3.5.0-SNAPSHOT + 4.0.0-SNAPSHOT mod-example org.folio vertx-lib - 3.5.0-SNAPSHOT + 4.0.0-SNAPSHOT org.folio vertx-lib-pg-testing - 3.5.0-SNAPSHOT + 4.0.0-SNAPSHOT test io.vertx vertx-core + + io.vertx + vertx-launcher-application + io.vertx vertx-web io.vertx - vertx-web-openapi + vertx-openapi io.vertx - vertx-rx-java2 + vertx-web-openapi-router + + + io.vertx + vertx-web-validation io.vertx - vertx-web-api-contract + vertx-rx-java2 io.vertx @@ -78,6 +86,10 @@ netty-tcnative-boringssl-static runtime + + com.ongres.scram + client + junit @@ -118,10 +130,25 @@ + + org.folio + openapi-deref-plugin + 4.0.0-SNAPSHOT + + + dereference-books + + dereference + + generate-resources + + + + org.apache.maven.plugins maven-shade-plugin - 3.5.1 + 3.6.0 package @@ -132,7 +159,7 @@ - io.vertx.core.Launcher + org.folio.okapi.common.MainLauncher org.folio.tlib.example.MainVerticle true diff --git a/mod-example/src/main/java/org/folio/tlib/example/data/Book.java b/mod-example/src/main/java/org/folio/tlib/example/data/Book.java index 0738010..86aadef 100644 --- a/mod-example/src/main/java/org/folio/tlib/example/data/Book.java +++ b/mod-example/src/main/java/org/folio/tlib/example/data/Book.java @@ -2,12 +2,13 @@ import com.fasterxml.jackson.annotation.JsonInclude; import io.vertx.codegen.annotations.DataObject; +import io.vertx.sqlclient.Row; import io.vertx.sqlclient.templates.annotations.Column; import io.vertx.sqlclient.templates.annotations.RowMapped; import java.util.UUID; /** - * Book DAO. + * Book object. */ @DataObject @RowMapped @@ -21,6 +22,17 @@ public class Book { @Column(name = "index_title") private String indexTitle; + /** + * The BookRowMapper class is not generated by vertx-codegen, so we use this for now. + */ + public static Book fromRow(Row row) { + Book book = new Book(); + book.setId(row.get(UUID.class, "id")); + book.setTitle(row.getString("title")); + book.setIndexTitle(row.getString("index_title")); + return book; + } + public UUID getId() { return id; } diff --git a/mod-example/src/main/java/org/folio/tlib/example/service/BookService.java b/mod-example/src/main/java/org/folio/tlib/example/service/BookService.java index 65eb0bf..e176c18 100644 --- a/mod-example/src/main/java/org/folio/tlib/example/service/BookService.java +++ b/mod-example/src/main/java/org/folio/tlib/example/service/BookService.java @@ -8,10 +8,9 @@ import io.vertx.core.json.JsonObject; import io.vertx.ext.web.Router; import io.vertx.ext.web.RoutingContext; -import io.vertx.ext.web.handler.BodyHandler; -import io.vertx.ext.web.openapi.RouterBuilder; -import io.vertx.ext.web.validation.RequestParameters; -import io.vertx.ext.web.validation.ValidationHandler; +import io.vertx.ext.web.openapi.router.RouterBuilder; +import io.vertx.openapi.contract.OpenAPIContract; +import io.vertx.openapi.validation.ValidatedRequest; import java.util.UUID; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -27,26 +26,29 @@ */ public class BookService implements RouterCreator, TenantInitHooks { - public static final int BODY_LIMIT = 65536; // 64 kb - private final Logger log = LogManager.getLogger(BookService.class); @Override public Future createRouter(Vertx vertx) { - return RouterBuilder.create(vertx, "openapi/books-1.0.yaml") - .map(routerBuilder -> { - // https://vertx.io/docs/vertx-web/java/#_limiting_body_size - routerBuilder.rootHandler(BodyHandler.create().setBodyLimit(BODY_LIMIT)); - handlers(vertx, routerBuilder); - return routerBuilder.createRouter(); - }) - .onSuccess(res -> log.info("OpenAPI parsed OK")); + return OpenAPIContract.from(vertx, "openapi/books-1.0.yaml") + .map(contract -> { + RouterBuilder routerBuilder = RouterBuilder.create(vertx, contract); + handlers(vertx, routerBuilder); + return routerBuilder.createRouter(); + }) + .onSuccess(res -> log.info("OpenAPI parsed OK")); } private void handleContextFailure(RoutingContext ctx) { ctx.response().setStatusCode(ctx.statusCode()); - String msg = ctx.failure() != null ? ctx.failure().getMessage() - : HttpResponseStatus.valueOf(ctx.statusCode()).reasonPhrase(); + String msg; + if (ctx.failure() == null) { + msg = HttpResponseStatus.valueOf(ctx.statusCode()).reasonPhrase(); + } else if (ctx.failure().getCause() == null) { + msg = ctx.failure().getMessage(); + } else { + msg = ctx.failure().getCause().getMessage(); + } ctx.response().putHeader(HttpHeaders.CONTENT_TYPE, "text/plain"); ctx.response().end(msg); } @@ -66,24 +68,21 @@ private void handleContextFailure(RoutingContext ctx) { * @param routerBuilder OpenAPI router builder */ private void handlers(Vertx vertx, RouterBuilder routerBuilder) { - routerBuilder - .operation("postBook") // operationId in spec - .handler(ctx -> postBook(vertx, ctx) + routerBuilder.getRoute("postBook") // operationId in spec + .addHandler(ctx -> postBook(vertx, ctx) .onFailure(cause -> HttpResponse.responseError(ctx, 500, cause.getMessage())) ) - .failureHandler(this::handleContextFailure); - routerBuilder - .operation("getBook") - .handler(ctx -> getBook(vertx, ctx) + .addFailureHandler(this::handleContextFailure); + routerBuilder.getRoute("getBook") + .addHandler(ctx -> getBook(vertx, ctx) .onFailure(cause -> HttpResponse.responseError(ctx, 500, cause.getMessage())) ) - .failureHandler(this::handleContextFailure); - routerBuilder - .operation("getBooks") - .handler(ctx -> getBooks(vertx, ctx) + .addFailureHandler(this::handleContextFailure); + routerBuilder.getRoute("getBooks") + .addHandler(ctx -> getBooks(vertx, ctx) .onFailure(cause -> HttpResponse.responseError(ctx, 500, cause.getMessage())) ) - .failureHandler(this::handleContextFailure); + .addFailureHandler(this::handleContextFailure); } @Override @@ -109,9 +108,7 @@ private Future getBooks(Vertx vertx, RoutingContext ctx) { private Future getBook(Vertx vertx, RoutingContext ctx) { String tenant = TenantUtil.tenant(ctx); - - RequestParameters params = ctx.get(ValidationHandler.REQUEST_CONTEXT_KEY); - UUID id = UUID.fromString(params.pathParameter("id").getString()); + UUID id = UUID.fromString(ctx.pathParam("id")); BookStorage storage = new BookStorage(vertx, tenant); return storage.getBook(id) .map(book -> { @@ -126,8 +123,10 @@ private Future getBook(Vertx vertx, RoutingContext ctx) { private Future postBook(Vertx vertx, RoutingContext ctx) { String tenant = TenantUtil.tenant(ctx); + ValidatedRequest validatedRequest = + ctx.get(RouterBuilder.KEY_META_DATA_VALIDATED_REQUEST); + Book book = JsonObject.mapFrom(validatedRequest.getBody().getJsonObject()).mapTo(Book.class); BookStorage storage = new BookStorage(vertx, tenant); - Book book = ctx.body().asPojo(Book.class); return storage.postBook(book) .map(res -> { ctx.response().setStatusCode(204).end(); diff --git a/mod-example/src/main/java/org/folio/tlib/example/storage/BookStorage.java b/mod-example/src/main/java/org/folio/tlib/example/storage/BookStorage.java index ea4e610..732acb5 100644 --- a/mod-example/src/main/java/org/folio/tlib/example/storage/BookStorage.java +++ b/mod-example/src/main/java/org/folio/tlib/example/storage/BookStorage.java @@ -5,9 +5,6 @@ import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import io.vertx.ext.web.RoutingContext; -import io.vertx.ext.web.validation.RequestParameter; -import io.vertx.ext.web.validation.RequestParameters; -import io.vertx.ext.web.validation.ValidationHandler; import io.vertx.sqlclient.RowIterator; import io.vertx.sqlclient.templates.SqlTemplate; import java.util.Collections; @@ -15,7 +12,6 @@ import java.util.List; import java.util.UUID; import org.folio.tlib.example.data.Book; -import org.folio.tlib.example.data.BookRowMapper; import org.folio.tlib.postgres.PgCqlDefinition; import org.folio.tlib.postgres.PgCqlQuery; import org.folio.tlib.postgres.TenantPgPool; @@ -81,7 +77,7 @@ public Future init(JsonObject tenantAttributes) { public Future getBook(UUID id) { return SqlTemplate.forQuery(pool.getPool(), "SELECT * FROM " + getMyTable(pool) + " WHERE id=#{id}") - .mapTo(BookRowMapper.INSTANCE) + .mapTo(Book::fromRow) .execute(Collections.singletonMap("id", id)) .map(rowSet -> { RowIterator iterator = rowSet.iterator(); @@ -116,9 +112,8 @@ private String createQueryMyTable(RoutingContext ctx, TenantPgPool pool) { pgCqlDefinition.addField("id", new PgCqlFieldUuid()); pgCqlDefinition.addField("title", new PgCqlFieldText().withFullText()); - RequestParameters params = ctx.get(ValidationHandler.REQUEST_CONTEXT_KEY); - RequestParameter query = params.queryParameter("query"); - PgCqlQuery pgCqlQuery = pgCqlDefinition.parse(query == null ? null : query.getString()); + List query = ctx.queryParam("query"); + PgCqlQuery pgCqlQuery = pgCqlDefinition.parse(query.isEmpty() ? null : query.get(0)); String sql = "SELECT * FROM " + getMyTable(pool); String where = pgCqlQuery.getWhereClause(); if (where != null) { @@ -141,7 +136,7 @@ private String createQueryMyTable(RoutingContext ctx, TenantPgPool pool) { public Future> getBooks(RoutingContext ctx) { String sql = createQueryMyTable(ctx, pool); return SqlTemplate.forQuery(pool.getPool(), sql) - .mapTo(BookRowMapper.INSTANCE) + .mapTo(Book::fromRow) .execute(Collections.emptyMap()) .map(rowSet -> { List books = new LinkedList<>(); diff --git a/mod-example/src/main/resources/openapi/books-1.0.yaml b/mod-example/src/main/resources/openapi/books-1.0.yaml index e26f57a..297815f 100644 --- a/mod-example/src/main/resources/openapi/books-1.0.yaml +++ b/mod-example/src/main/resources/openapi/books-1.0.yaml @@ -15,7 +15,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/book" + $ref: schemas/book.json required: true responses: "204": @@ -42,7 +42,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/books" + $ref: schemas/books.json "400": $ref: "#/components/responses/trait_400" "500": @@ -68,7 +68,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/book" + $ref: schemas/book.json "400": $ref: "#/components/responses/trait_400" "404": @@ -110,8 +110,4 @@ components: schema: type: string example: Internal server error, contact administrator - schemas: - books: - $ref: schemas/books.json - book: - $ref: schemas/book.json + diff --git a/mod-example/src/test/java/org/folio/tlib/example/MainVerticleTest.java b/mod-example/src/test/java/org/folio/tlib/example/MainVerticleTest.java index a64a27c..be27e87 100644 --- a/mod-example/src/test/java/org/folio/tlib/example/MainVerticleTest.java +++ b/mod-example/src/test/java/org/folio/tlib/example/MainVerticleTest.java @@ -22,7 +22,6 @@ import org.junit.runner.RunWith; import org.testcontainers.containers.PostgreSQLContainer; -import static org.folio.tlib.example.service.BookService.BODY_LIMIT; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.hasSize; @@ -209,22 +208,6 @@ public void testGetBooks() { .put("purge", true), null); } - @Test - public void testBodyLimitBooks() { - Book a = new Book(); - a.setTitle("x".repeat(BODY_LIMIT)); - a.setId(UUID.randomUUID()); - - RestAssured.given() - .header(XOkapiHeaders.TENANT, TENANT) - .contentType(ContentType.JSON) - .body(JsonObject.mapFrom(a).encode()) - .post("/books") - .then().statusCode(413) - .contentType(ContentType.TEXT) - .body(is("Request Entity Too Large")); - } - @Test public void testValidationError() { Book book = new Book(); @@ -241,6 +224,6 @@ public void testValidationError() { .post("/books") .then().statusCode(400) .contentType(ContentType.TEXT) - .body(containsString("Provided object contains unexpected additional property: extra")); + .body(containsString(" Reason: Property \"extra\" does not match additional properties schema")); } -} \ No newline at end of file +} diff --git a/openapi-deref-plugin/pom.xml b/openapi-deref-plugin/pom.xml new file mode 100644 index 0000000..74907ea --- /dev/null +++ b/openapi-deref-plugin/pom.xml @@ -0,0 +1,86 @@ + + 4.0.0 + + + org.folio + folio-vertx-lib + 4.0.0-SNAPSHOT + + + org.folio + openapi-deref-plugin + 4.0.0-SNAPSHOT + maven-plugin + OpenAPI Dereference Maven Plugin + Maven plugin to dereference OpenAPI $ref and produce a single file + https://github.com/folio-org/folio-vertx-lib + + + + com.fasterxml.jackson.core + jackson-databind + 2.18.2 + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + 2.18.2 + + + io.swagger.parser.v3 + swagger-parser + 2.1.32 + + + org.apache.maven.plugin-tools + maven-plugin-annotations + 3.15.1 + provided + + + org.junit.jupiter + junit-jupiter + test + + + org.apache.maven + maven-plugin-api + 3.9.11 + provided + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.0 + + 21 + UTF-8 + + + + + org.apache.maven.plugins + maven-plugin-plugin + 3.12.0 + + openapi-deref + + + + default-descriptor + + descriptor + + + + + + + + diff --git a/openapi-deref-plugin/src/main/java/org/folio/tlib/OpenApiDeref.java b/openapi-deref-plugin/src/main/java/org/folio/tlib/OpenApiDeref.java new file mode 100644 index 0000000..d3db716 --- /dev/null +++ b/openapi-deref-plugin/src/main/java/org/folio/tlib/OpenApiDeref.java @@ -0,0 +1,91 @@ +package org.folio.tlib; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.parser.OpenAPIV3Parser; +import io.swagger.v3.parser.core.models.ParseOptions; +import io.swagger.v3.parser.core.models.SwaggerParseResult; +import java.io.File; +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Class for resolving OpenAPI $ref references. + */ +public class OpenApiDeref { + private OpenApiDeref() { + throw new IllegalStateException("OpenApiDeref"); + } + + static List mapFilesWithPattern(String inputPattern, String outputPath) + throws IOException { + File inputDir = new File(inputPattern).getParentFile(); + if (inputDir == null) { + throw new IOException("no path in " + inputPattern); + } + Path dirPath = inputDir.toPath(); + DirectoryStream.Filter filter = entry -> + java.nio.file.FileSystems.getDefault() + .getPathMatcher("glob:" + new File(inputPattern).getName()) + .matches(entry.getFileName()); + + List files = new ArrayList<>(); + try (DirectoryStream stream = Files.newDirectoryStream(dirPath, filter)) { + for (Path entry : stream) { + String inputFile = entry.toFile().getAbsolutePath(); + files.add(inputFile); + String outputFile = inputFile.replace(inputDir.getAbsolutePath(), outputPath); + files.add(outputFile); + } + } + return files; + } + + static void fix(String inputPath, String outputPath) throws IOException { + ParseOptions parseOptions = new ParseOptions(); + parseOptions.setResolve(true); + SwaggerParseResult result = new OpenAPIV3Parser().readLocation(inputPath, null, parseOptions); + OpenAPI openApi = result.getOpenAPI(); + if (openApi == null) { + throw new IOException("Failed to parse OpenAPI: " + result.getMessages()); + } + ObjectMapper mapper; + if (outputPath.endsWith(".yaml")) { + mapper = new ObjectMapper(new YAMLFactory()); + } else { + mapper = new ObjectMapper(); // JSON output + } + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + + // Convert OpenAPI object to a tree for post-processing + JsonNode tree = mapper.valueToTree(openApi); + // swagger parser produces some properties that Vert.x OpenApi does not recognize. Omit these. + removeKeysRecursive(tree, + "exampleSetFlag", "extensions", "jsonSchema", "servers", "style", "types", "valueSetFlag"); + + new File(outputPath).getParentFile().mkdirs(); + mapper.writerWithDefaultPrettyPrinter().writeValue(new File(outputPath), tree); + } + + private static void removeKeysRecursive(JsonNode node, String... keys) { + if (node.isObject()) { + ObjectNode obj = (ObjectNode) node; + for (String key : keys) { + obj.remove(key); + } + obj.fields().forEachRemaining(entry -> removeKeysRecursive(entry.getValue(), keys)); + } else if (node.isArray()) { + for (JsonNode item : node) { + removeKeysRecursive(item, keys); + } + } + } +} diff --git a/openapi-deref-plugin/src/main/java/org/folio/tlib/OpenApiDerefMojo.java b/openapi-deref-plugin/src/main/java/org/folio/tlib/OpenApiDerefMojo.java new file mode 100644 index 0000000..ae4fce5 --- /dev/null +++ b/openapi-deref-plugin/src/main/java/org/folio/tlib/OpenApiDerefMojo.java @@ -0,0 +1,52 @@ +package org.folio.tlib; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; + +/** + * Mojo to dereference OpenAPI $ref and produce a single file. + */ +@Mojo(name = "dereference", defaultPhase = LifecyclePhase.GENERATE_RESOURCES) +public class OpenApiDerefMojo extends AbstractMojo { + + /** + * Path to the input OpenAPI YAML file. + */ + @Parameter(property = "input", required = false, + defaultValue = "${basedir}/src/main/resources/openapi/*.yaml") + private String input; + + /** + * Path to the output file (YAML or JSON). + */ + @Parameter(property = "output", required = false, + defaultValue = "${project.build.directory}/classes/openapi") + private String output; + + @Override + public void execute() throws MojoExecutionException { + getLog().info("Dereferencing OpenAPI: " + input + " -> " + output); + List files = new ArrayList<>(); + try { + files = OpenApiDeref.mapFilesWithPattern(input, output); + } catch (IOException e) { + throw new MojoExecutionException("Failed to map OpenAPI files", e); + } + for (int i = 0; i < files.size(); i += 2) { + String inputFile = files.get(i); + String outputFile = files.get(i + 1); + try { + OpenApiDeref.fix(inputFile, outputFile); + } catch (IOException e) { + throw new MojoExecutionException("Failed to dereference OpenAPI file " + inputFile, e); + } + getLog().info("Dereferenced OpenAPI written to: " + outputFile); + } + } +} diff --git a/openapi-deref-plugin/src/test/java/org/folio/tlib/OpenApiDerefTest.java b/openapi-deref-plugin/src/test/java/org/folio/tlib/OpenApiDerefTest.java new file mode 100644 index 0000000..7e947c5 --- /dev/null +++ b/openapi-deref-plugin/src/test/java/org/folio/tlib/OpenApiDerefTest.java @@ -0,0 +1,80 @@ +package org.folio.tlib; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List; +import org.junit.jupiter.api.Test; + +class OpenApiDerefTest { + + @Test + void testMapFilesWithPattern1() throws Exception{ + List files = OpenApiDeref.mapFilesWithPattern("src/test/resources/openapi/*.yaml", "target/generated-resources/openapi"); + assertEquals(2, files.size()); + assertTrue(files.get(0).endsWith("src/test/resources/openapi/reftest.yaml"), "Input file not right: " + files.get(0)); + assertTrue(files.get(1).endsWith("target/generated-resources/openapi/reftest.yaml"), "Output file not right: " + files.get(1)); + } + + @Test + void testMapFilesWithPattern2() throws Exception { + List files = OpenApiDeref.mapFilesWithPattern("src/test/resources/openapi/*.nope", "target/generated-resources/openapi"); + assertEquals(0, files.size()); + } + + @Test + void testMapFilesWithPattern3() { + assertThrows(IOException.class, () -> { + OpenApiDeref.mapFilesWithPattern("src1/test1/1.nope", "target/generated-resources/openapi"); + }); + } + + @Test + void testMapFilesWithPattern4() { + assertThrows(IOException.class, () -> { + OpenApiDeref.mapFilesWithPattern("onlyfilename.yaml", "target/generated-resources/openapi"); + }); + } + + @Test + void testDerefYaml() throws Exception { + String input = "src/test/resources/openapi/reftest.yaml"; + String output = "target/generated-resources/openapi/reftest.deref.yaml"; + OpenApiDeref.fix(input, output); + String outputContent = new String(Files.readAllBytes(Paths.get(output)), StandardCharsets.UTF_8); + assertNotNull(outputContent); + assertFalse(outputContent.contains("$ref")); + } + + @Test + void testDerefJson() throws Exception { + String input = "src/test/resources/openapi/reftest.yaml"; + String output = "target/generated-resources/openapi/reftest.deref.json"; + OpenApiDeref.fix(input, output); + String outputContent = new String(Files.readAllBytes(Paths.get(output)), StandardCharsets.UTF_8); + assertNotNull(outputContent); + assertFalse(outputContent.contains("$ref")); + } + + @Test + void testFailToParse() { + String input = "src/test/resources/openapi/headers/okapi-token.yaml"; + String output = "target/generated-resources/openapi/okapi-token.deref.json"; + assertThrows(java.io.IOException.class, () -> { + OpenApiDeref.fix(input, output); + }); + } + + @Test + void testNoFile() { + String input = "src/test/resources/openapi/headers/no-file-en.yaml"; + String output = "target/generated-resources/openapi/no-file.deref.json"; + assertThrows(java.io.IOException.class, () -> { + OpenApiDeref.fix(input, output); + }); + } + +} diff --git a/openapi-deref-plugin/src/test/resources/openapi/headers/okapi-token.yaml b/openapi-deref-plugin/src/test/resources/openapi/headers/okapi-token.yaml new file mode 100644 index 0000000..6de6cb2 --- /dev/null +++ b/openapi-deref-plugin/src/test/resources/openapi/headers/okapi-token.yaml @@ -0,0 +1,6 @@ +in: header +name: X-Okapi-Token +description: Okapi Token +required: false +schema: + type: string diff --git a/openapi-deref-plugin/src/test/resources/openapi/reftest.yaml b/openapi-deref-plugin/src/test/resources/openapi/reftest.yaml new file mode 100644 index 0000000..b622ca9 --- /dev/null +++ b/openapi-deref-plugin/src/test/resources/openapi/reftest.yaml @@ -0,0 +1,28 @@ +openapi: 3.0.0 +info: + title: ref test + version: v1 +paths: + /echo: + parameters: + - $ref: "#/components/parameters/okapi_tenant" + - $ref: headers/okapi-token.yaml + get: + description: echo request + operationId: refTestEcho + responses: + "200": + description: echo ok + content: + text/plain: + schema: + type: string +components: + parameters: + okapi_tenant: + name: X-Okapi-Tenant + in: header + required: true + schema: + type: string + pattern: '^[_a-z][_a-z0-9]*$' diff --git a/pg-testing/pom.xml b/pg-testing/pom.xml index 6fc9eac..7767d10 100644 --- a/pg-testing/pom.xml +++ b/pg-testing/pom.xml @@ -3,7 +3,7 @@ org.folio folio-vertx-lib - 3.5.0-SNAPSHOT + 4.0.0-SNAPSHOT vertx-lib-pg-testing @@ -11,7 +11,7 @@ org.folio vertx-lib - 3.5.0-SNAPSHOT + 4.0.0-SNAPSHOT org.testcontainers diff --git a/pom.xml b/pom.xml index 99252ba..64dd45e 100644 --- a/pom.xml +++ b/pom.xml @@ -2,7 +2,7 @@ 4.0.0 org.folio folio-vertx-lib - 3.5.0-SNAPSHOT + 4.0.0-SNAPSHOT pom FOLIO Vert.x library @@ -29,10 +29,11 @@ UTF-8 - 6.2.0 - 4.5.13 + 7.0.0 + 5.0.3 + openapi-deref-plugin core pg-testing mod-example @@ -42,7 +43,7 @@ org.apache.logging.log4j log4j-bom - 2.24.1 + 2.24.3 pom import @@ -146,6 +147,14 @@ io.vertx.codegen.CodeGenProcessor + + + io.vertx + vertx-codegen + ${vertx.version} + processor + + @@ -247,7 +256,6 @@ 3.5.0 google_checks.xml - UTF-8 warning false true