From 3d6e133fdadda94f9b603e267cd913b1b782ad2a Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 3 Nov 2025 21:15:22 -0300 Subject: [PATCH 01/31] v4.0.11 --- jooby/pom.xml | 2 +- modules/jooby-apt/pom.xml | 2 +- modules/jooby-avaje-inject/pom.xml | 2 +- modules/jooby-avaje-jsonb/pom.xml | 2 +- modules/jooby-avaje-validator/pom.xml | 2 +- modules/jooby-awssdk-v1/pom.xml | 2 +- modules/jooby-awssdk-v2/pom.xml | 2 +- modules/jooby-bom/pom.xml | 4 ++-- modules/jooby-caffeine/pom.xml | 2 +- modules/jooby-camel/pom.xml | 2 +- modules/jooby-cli/pom.xml | 2 +- modules/jooby-commons-email/pom.xml | 2 +- modules/jooby-conscrypt/pom.xml | 2 +- modules/jooby-db-scheduler/pom.xml | 2 +- modules/jooby-distribution/pom.xml | 2 +- modules/jooby-ebean/pom.xml | 2 +- modules/jooby-flyway/pom.xml | 2 +- modules/jooby-freemarker/pom.xml | 2 +- modules/jooby-gradle-setup/pom.xml | 2 +- modules/jooby-graphiql/pom.xml | 2 +- modules/jooby-graphql/pom.xml | 2 +- modules/jooby-gson/pom.xml | 2 +- modules/jooby-guice/pom.xml | 2 +- modules/jooby-handlebars/pom.xml | 2 +- modules/jooby-hibernate-validator/pom.xml | 2 +- modules/jooby-hibernate/pom.xml | 2 +- modules/jooby-hikari/pom.xml | 2 +- modules/jooby-jackson/pom.xml | 2 +- modules/jooby-jasypt/pom.xml | 2 +- modules/jooby-jdbi/pom.xml | 2 +- modules/jooby-jetty/pom.xml | 2 +- modules/jooby-jstachio/pom.xml | 2 +- modules/jooby-jte/pom.xml | 2 +- modules/jooby-jwt/pom.xml | 2 +- modules/jooby-kafka/pom.xml | 2 +- modules/jooby-kotlin/pom.xml | 2 +- modules/jooby-log4j/pom.xml | 2 +- modules/jooby-logback/pom.xml | 2 +- modules/jooby-maven-plugin/pom.xml | 2 +- modules/jooby-metrics/pom.xml | 2 +- modules/jooby-mutiny/pom.xml | 2 +- modules/jooby-netty/pom.xml | 2 +- modules/jooby-openapi/pom.xml | 2 +- modules/jooby-pac4j/pom.xml | 2 +- modules/jooby-pebble/pom.xml | 2 +- modules/jooby-quartz/pom.xml | 2 +- modules/jooby-reactor/pom.xml | 2 +- modules/jooby-redis/pom.xml | 2 +- modules/jooby-redoc/pom.xml | 2 +- modules/jooby-rocker/pom.xml | 2 +- modules/jooby-run/pom.xml | 2 +- modules/jooby-rxjava3/pom.xml | 2 +- modules/jooby-stork/pom.xml | 2 +- modules/jooby-swagger-ui/pom.xml | 2 +- modules/jooby-test/pom.xml | 2 +- modules/jooby-thymeleaf/pom.xml | 2 +- modules/jooby-undertow/pom.xml | 2 +- modules/jooby-vertx-mysql-client/pom.xml | 2 +- modules/jooby-vertx-pg-client/pom.xml | 2 +- modules/jooby-vertx-sql-client/pom.xml | 2 +- modules/jooby-vertx/pom.xml | 2 +- modules/jooby-whoops/pom.xml | 2 +- modules/jooby-yasson/pom.xml | 2 +- modules/pom.xml | 2 +- pom.xml | 4 ++-- tests/pom.xml | 2 +- 66 files changed, 68 insertions(+), 68 deletions(-) diff --git a/jooby/pom.xml b/jooby/pom.xml index e70e6185a2..8034b72c0a 100644 --- a/jooby/pom.xml +++ b/jooby/pom.xml @@ -6,7 +6,7 @@ io.jooby jooby-project - 4.0.11-SNAPSHOT + 4.0.11 jooby jooby diff --git a/modules/jooby-apt/pom.xml b/modules/jooby-apt/pom.xml index 48d3280234..a2d684aa6c 100644 --- a/modules/jooby-apt/pom.xml +++ b/modules/jooby-apt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-apt jooby-apt diff --git a/modules/jooby-avaje-inject/pom.xml b/modules/jooby-avaje-inject/pom.xml index cec636e5eb..2dd9fe30a8 100644 --- a/modules/jooby-avaje-inject/pom.xml +++ b/modules/jooby-avaje-inject/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-avaje-inject jooby-avaje-inject diff --git a/modules/jooby-avaje-jsonb/pom.xml b/modules/jooby-avaje-jsonb/pom.xml index 6f66e2244b..423b5a51bc 100644 --- a/modules/jooby-avaje-jsonb/pom.xml +++ b/modules/jooby-avaje-jsonb/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-avaje-jsonb jooby-avaje-jsonb diff --git a/modules/jooby-avaje-validator/pom.xml b/modules/jooby-avaje-validator/pom.xml index 5d1b099090..eaa43f5213 100644 --- a/modules/jooby-avaje-validator/pom.xml +++ b/modules/jooby-avaje-validator/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-avaje-validator jooby-avaje-validator diff --git a/modules/jooby-awssdk-v1/pom.xml b/modules/jooby-awssdk-v1/pom.xml index 5d12b3d5f5..fe406c740d 100644 --- a/modules/jooby-awssdk-v1/pom.xml +++ b/modules/jooby-awssdk-v1/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-awssdk-v1 jooby-awssdk-v1 diff --git a/modules/jooby-awssdk-v2/pom.xml b/modules/jooby-awssdk-v2/pom.xml index 04582a81cb..40e77c9c5a 100644 --- a/modules/jooby-awssdk-v2/pom.xml +++ b/modules/jooby-awssdk-v2/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-awssdk-v2 jooby-awssdk-v2 diff --git a/modules/jooby-bom/pom.xml b/modules/jooby-bom/pom.xml index 5eb0f3db04..f1c5c6569f 100644 --- a/modules/jooby-bom/pom.xml +++ b/modules/jooby-bom/pom.xml @@ -7,14 +7,14 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 io.jooby jooby-bom jooby-bom pom - 4.0.11-SNAPSHOT + 4.0.11 Jooby (Bill of Materials) https://jooby.io diff --git a/modules/jooby-caffeine/pom.xml b/modules/jooby-caffeine/pom.xml index 422b8e6baa..0adf4d6fe4 100644 --- a/modules/jooby-caffeine/pom.xml +++ b/modules/jooby-caffeine/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-caffeine jooby-caffeine diff --git a/modules/jooby-camel/pom.xml b/modules/jooby-camel/pom.xml index 881a18178a..6cfb667dc5 100644 --- a/modules/jooby-camel/pom.xml +++ b/modules/jooby-camel/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-camel jooby-camel diff --git a/modules/jooby-cli/pom.xml b/modules/jooby-cli/pom.xml index da8a8a9581..d135f2236d 100644 --- a/modules/jooby-cli/pom.xml +++ b/modules/jooby-cli/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-cli jooby-cli diff --git a/modules/jooby-commons-email/pom.xml b/modules/jooby-commons-email/pom.xml index 32a7e81735..8baa88812a 100644 --- a/modules/jooby-commons-email/pom.xml +++ b/modules/jooby-commons-email/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-commons-email jooby-commons-email diff --git a/modules/jooby-conscrypt/pom.xml b/modules/jooby-conscrypt/pom.xml index de3fdf2f22..0ac4b2afed 100644 --- a/modules/jooby-conscrypt/pom.xml +++ b/modules/jooby-conscrypt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-conscrypt jooby-conscrypt diff --git a/modules/jooby-db-scheduler/pom.xml b/modules/jooby-db-scheduler/pom.xml index f4beeb2f1c..9e82c5e90f 100644 --- a/modules/jooby-db-scheduler/pom.xml +++ b/modules/jooby-db-scheduler/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-db-scheduler jooby-db-scheduler diff --git a/modules/jooby-distribution/pom.xml b/modules/jooby-distribution/pom.xml index 44a44c78ac..03fe49fa91 100644 --- a/modules/jooby-distribution/pom.xml +++ b/modules/jooby-distribution/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-distribution jooby-distribution diff --git a/modules/jooby-ebean/pom.xml b/modules/jooby-ebean/pom.xml index 24901cdcbc..b8ee6f7d99 100644 --- a/modules/jooby-ebean/pom.xml +++ b/modules/jooby-ebean/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-ebean jooby-ebean diff --git a/modules/jooby-flyway/pom.xml b/modules/jooby-flyway/pom.xml index f304d728dd..7d7d6bd32c 100644 --- a/modules/jooby-flyway/pom.xml +++ b/modules/jooby-flyway/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-flyway jooby-flyway diff --git a/modules/jooby-freemarker/pom.xml b/modules/jooby-freemarker/pom.xml index c65080cb99..c474ce6782 100644 --- a/modules/jooby-freemarker/pom.xml +++ b/modules/jooby-freemarker/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-freemarker jooby-freemarker diff --git a/modules/jooby-gradle-setup/pom.xml b/modules/jooby-gradle-setup/pom.xml index db0b50996e..fa3388c8ff 100644 --- a/modules/jooby-gradle-setup/pom.xml +++ b/modules/jooby-gradle-setup/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-gradle-setup jooby-gradle-setup diff --git a/modules/jooby-graphiql/pom.xml b/modules/jooby-graphiql/pom.xml index 3ad4a53317..eefbd16922 100644 --- a/modules/jooby-graphiql/pom.xml +++ b/modules/jooby-graphiql/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-graphiql jooby-graphiql diff --git a/modules/jooby-graphql/pom.xml b/modules/jooby-graphql/pom.xml index 7e9894a0e4..596f418a65 100644 --- a/modules/jooby-graphql/pom.xml +++ b/modules/jooby-graphql/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-graphql jooby-graphql diff --git a/modules/jooby-gson/pom.xml b/modules/jooby-gson/pom.xml index a3d6ea75d0..09970cea8b 100644 --- a/modules/jooby-gson/pom.xml +++ b/modules/jooby-gson/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-gson jooby-gson diff --git a/modules/jooby-guice/pom.xml b/modules/jooby-guice/pom.xml index 8a54b6e355..624a5f4cec 100644 --- a/modules/jooby-guice/pom.xml +++ b/modules/jooby-guice/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-guice jooby-guice diff --git a/modules/jooby-handlebars/pom.xml b/modules/jooby-handlebars/pom.xml index 08c460eeb3..0e93a4ab99 100644 --- a/modules/jooby-handlebars/pom.xml +++ b/modules/jooby-handlebars/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-handlebars jooby-handlebars diff --git a/modules/jooby-hibernate-validator/pom.xml b/modules/jooby-hibernate-validator/pom.xml index ad4525b1bf..635fe4ab31 100644 --- a/modules/jooby-hibernate-validator/pom.xml +++ b/modules/jooby-hibernate-validator/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-hibernate-validator jooby-hibernate-validator diff --git a/modules/jooby-hibernate/pom.xml b/modules/jooby-hibernate/pom.xml index 24ba34f88e..cb455460f0 100644 --- a/modules/jooby-hibernate/pom.xml +++ b/modules/jooby-hibernate/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-hibernate jooby-hibernate diff --git a/modules/jooby-hikari/pom.xml b/modules/jooby-hikari/pom.xml index f8bdf7a5df..5a5acec7ca 100644 --- a/modules/jooby-hikari/pom.xml +++ b/modules/jooby-hikari/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-hikari jooby-hikari diff --git a/modules/jooby-jackson/pom.xml b/modules/jooby-jackson/pom.xml index 6777e6428a..df9c9a5b39 100644 --- a/modules/jooby-jackson/pom.xml +++ b/modules/jooby-jackson/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-jackson jooby-jackson diff --git a/modules/jooby-jasypt/pom.xml b/modules/jooby-jasypt/pom.xml index 788cadfdfe..13d8ac850c 100644 --- a/modules/jooby-jasypt/pom.xml +++ b/modules/jooby-jasypt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-jasypt jooby-jasypt diff --git a/modules/jooby-jdbi/pom.xml b/modules/jooby-jdbi/pom.xml index 756de3794b..132bcde877 100644 --- a/modules/jooby-jdbi/pom.xml +++ b/modules/jooby-jdbi/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-jdbi jooby-jdbi diff --git a/modules/jooby-jetty/pom.xml b/modules/jooby-jetty/pom.xml index e9b52f9784..bf9fbddfbb 100644 --- a/modules/jooby-jetty/pom.xml +++ b/modules/jooby-jetty/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-jetty jooby-jetty diff --git a/modules/jooby-jstachio/pom.xml b/modules/jooby-jstachio/pom.xml index 6a59288926..e76c04eda9 100644 --- a/modules/jooby-jstachio/pom.xml +++ b/modules/jooby-jstachio/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-jstachio jooby-jstachio diff --git a/modules/jooby-jte/pom.xml b/modules/jooby-jte/pom.xml index fe6e2b2af0..9ada034d3a 100644 --- a/modules/jooby-jte/pom.xml +++ b/modules/jooby-jte/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-jte jooby-jte diff --git a/modules/jooby-jwt/pom.xml b/modules/jooby-jwt/pom.xml index 17ce8885cc..bad47ff10e 100644 --- a/modules/jooby-jwt/pom.xml +++ b/modules/jooby-jwt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-jwt jooby-jwt diff --git a/modules/jooby-kafka/pom.xml b/modules/jooby-kafka/pom.xml index ce842f4110..ff798abade 100644 --- a/modules/jooby-kafka/pom.xml +++ b/modules/jooby-kafka/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-kafka jooby-kafka diff --git a/modules/jooby-kotlin/pom.xml b/modules/jooby-kotlin/pom.xml index 82ed070df0..e5c5edeecd 100644 --- a/modules/jooby-kotlin/pom.xml +++ b/modules/jooby-kotlin/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-kotlin jooby-kotlin diff --git a/modules/jooby-log4j/pom.xml b/modules/jooby-log4j/pom.xml index 4cacd8f75b..f65d1e8a9b 100644 --- a/modules/jooby-log4j/pom.xml +++ b/modules/jooby-log4j/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-log4j jooby-log4j diff --git a/modules/jooby-logback/pom.xml b/modules/jooby-logback/pom.xml index 787c4ee816..c33fb46e41 100644 --- a/modules/jooby-logback/pom.xml +++ b/modules/jooby-logback/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-logback jooby-logback diff --git a/modules/jooby-maven-plugin/pom.xml b/modules/jooby-maven-plugin/pom.xml index 501d84d92b..08daec4452 100644 --- a/modules/jooby-maven-plugin/pom.xml +++ b/modules/jooby-maven-plugin/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-maven-plugin jooby-maven-plugin diff --git a/modules/jooby-metrics/pom.xml b/modules/jooby-metrics/pom.xml index 498573f4ff..effed58cad 100644 --- a/modules/jooby-metrics/pom.xml +++ b/modules/jooby-metrics/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-metrics jooby-metrics diff --git a/modules/jooby-mutiny/pom.xml b/modules/jooby-mutiny/pom.xml index 956f72ba07..111ce6cd9e 100644 --- a/modules/jooby-mutiny/pom.xml +++ b/modules/jooby-mutiny/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-mutiny jooby-mutiny diff --git a/modules/jooby-netty/pom.xml b/modules/jooby-netty/pom.xml index 36c6779102..37d4e9617d 100644 --- a/modules/jooby-netty/pom.xml +++ b/modules/jooby-netty/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-netty jooby-netty diff --git a/modules/jooby-openapi/pom.xml b/modules/jooby-openapi/pom.xml index 510876775f..96fa17e2bc 100644 --- a/modules/jooby-openapi/pom.xml +++ b/modules/jooby-openapi/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-openapi jooby-openapi diff --git a/modules/jooby-pac4j/pom.xml b/modules/jooby-pac4j/pom.xml index 2c22d59e3a..243c342cd4 100644 --- a/modules/jooby-pac4j/pom.xml +++ b/modules/jooby-pac4j/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-pac4j jooby-pac4j diff --git a/modules/jooby-pebble/pom.xml b/modules/jooby-pebble/pom.xml index c38925170f..794ea24796 100644 --- a/modules/jooby-pebble/pom.xml +++ b/modules/jooby-pebble/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-pebble jooby-pebble diff --git a/modules/jooby-quartz/pom.xml b/modules/jooby-quartz/pom.xml index 4440b3e5ff..212b4b542e 100644 --- a/modules/jooby-quartz/pom.xml +++ b/modules/jooby-quartz/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-quartz jooby-quartz diff --git a/modules/jooby-reactor/pom.xml b/modules/jooby-reactor/pom.xml index 34dcb64a85..12d75aa126 100644 --- a/modules/jooby-reactor/pom.xml +++ b/modules/jooby-reactor/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-reactor jooby-reactor diff --git a/modules/jooby-redis/pom.xml b/modules/jooby-redis/pom.xml index 48db254f8c..5eff113b4e 100644 --- a/modules/jooby-redis/pom.xml +++ b/modules/jooby-redis/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-redis jooby-redis diff --git a/modules/jooby-redoc/pom.xml b/modules/jooby-redoc/pom.xml index 1dda58ba62..9473d17df5 100644 --- a/modules/jooby-redoc/pom.xml +++ b/modules/jooby-redoc/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-redoc jooby-redoc diff --git a/modules/jooby-rocker/pom.xml b/modules/jooby-rocker/pom.xml index 01bdab70ff..ad837b1f0e 100644 --- a/modules/jooby-rocker/pom.xml +++ b/modules/jooby-rocker/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-rocker jooby-rocker diff --git a/modules/jooby-run/pom.xml b/modules/jooby-run/pom.xml index 262b4d951f..4611dd723b 100644 --- a/modules/jooby-run/pom.xml +++ b/modules/jooby-run/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-run jooby-run diff --git a/modules/jooby-rxjava3/pom.xml b/modules/jooby-rxjava3/pom.xml index ec0c5f3521..33847ab1ab 100644 --- a/modules/jooby-rxjava3/pom.xml +++ b/modules/jooby-rxjava3/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-rxjava3 jooby-rxjava3 diff --git a/modules/jooby-stork/pom.xml b/modules/jooby-stork/pom.xml index 72ac695eef..13c48414a0 100644 --- a/modules/jooby-stork/pom.xml +++ b/modules/jooby-stork/pom.xml @@ -4,7 +4,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-stork diff --git a/modules/jooby-swagger-ui/pom.xml b/modules/jooby-swagger-ui/pom.xml index 9f0ac3b0ce..05d6982288 100644 --- a/modules/jooby-swagger-ui/pom.xml +++ b/modules/jooby-swagger-ui/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-swagger-ui jooby-swagger-ui diff --git a/modules/jooby-test/pom.xml b/modules/jooby-test/pom.xml index 5a83db3474..4b1635600e 100644 --- a/modules/jooby-test/pom.xml +++ b/modules/jooby-test/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-test jooby-test diff --git a/modules/jooby-thymeleaf/pom.xml b/modules/jooby-thymeleaf/pom.xml index adf37c8800..1e979cdf61 100644 --- a/modules/jooby-thymeleaf/pom.xml +++ b/modules/jooby-thymeleaf/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-thymeleaf jooby-thymeleaf diff --git a/modules/jooby-undertow/pom.xml b/modules/jooby-undertow/pom.xml index 118f364ddf..f54c0fde86 100644 --- a/modules/jooby-undertow/pom.xml +++ b/modules/jooby-undertow/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-undertow jooby-undertow diff --git a/modules/jooby-vertx-mysql-client/pom.xml b/modules/jooby-vertx-mysql-client/pom.xml index 87a7cb5d6d..202ae5ebe4 100644 --- a/modules/jooby-vertx-mysql-client/pom.xml +++ b/modules/jooby-vertx-mysql-client/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-vertx-mysql-client jooby-vertx-mysql-client diff --git a/modules/jooby-vertx-pg-client/pom.xml b/modules/jooby-vertx-pg-client/pom.xml index 291c0ae25b..688a480793 100644 --- a/modules/jooby-vertx-pg-client/pom.xml +++ b/modules/jooby-vertx-pg-client/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-vertx-pg-client jooby-vertx-pg-client diff --git a/modules/jooby-vertx-sql-client/pom.xml b/modules/jooby-vertx-sql-client/pom.xml index c24efa6e07..d990d6fb86 100644 --- a/modules/jooby-vertx-sql-client/pom.xml +++ b/modules/jooby-vertx-sql-client/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-vertx-sql-client jooby-vertx-sql-client diff --git a/modules/jooby-vertx/pom.xml b/modules/jooby-vertx/pom.xml index b890dc4bd9..1da28cd113 100644 --- a/modules/jooby-vertx/pom.xml +++ b/modules/jooby-vertx/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-vertx jooby-vertx diff --git a/modules/jooby-whoops/pom.xml b/modules/jooby-whoops/pom.xml index 4c6077a9d2..239f864e2a 100644 --- a/modules/jooby-whoops/pom.xml +++ b/modules/jooby-whoops/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-whoops jooby-whoops diff --git a/modules/jooby-yasson/pom.xml b/modules/jooby-yasson/pom.xml index a78ff24ba7..452bec4f8d 100644 --- a/modules/jooby-yasson/pom.xml +++ b/modules/jooby-yasson/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11-SNAPSHOT + 4.0.11 jooby-yasson jooby-yasson diff --git a/modules/pom.xml b/modules/pom.xml index 31e6da0203..3bdceb6a17 100644 --- a/modules/pom.xml +++ b/modules/pom.xml @@ -4,7 +4,7 @@ io.jooby jooby-project - 4.0.11-SNAPSHOT + 4.0.11 modules diff --git a/pom.xml b/pom.xml index bec1e05b2c..d8e97d8395 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.jooby jooby-project - 4.0.11-SNAPSHOT + 4.0.11 pom jooby-project @@ -210,7 +210,7 @@ 21 21 yyyy-MM-dd HH:mm:ssa - 2025-10-27T16:26:58Z + 2025-11-04T00:15:14Z UTF-8 etc${file.separator}source${file.separator}formatter.sh diff --git a/tests/pom.xml b/tests/pom.xml index 0a6406b8e9..81416b91bb 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -6,7 +6,7 @@ io.jooby jooby-project - 4.0.11-SNAPSHOT + 4.0.11 tests tests From 30fa9b3e8c687a855446ce5aa5b7bcb6e7056a50 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Tue, 4 Nov 2025 13:21:22 -0300 Subject: [PATCH 02/31] prepare for next development cycle --- jooby/pom.xml | 2 +- modules/jooby-apt/pom.xml | 2 +- modules/jooby-avaje-inject/pom.xml | 2 +- modules/jooby-avaje-jsonb/pom.xml | 2 +- modules/jooby-avaje-validator/pom.xml | 2 +- modules/jooby-awssdk-v1/pom.xml | 2 +- modules/jooby-awssdk-v2/pom.xml | 2 +- modules/jooby-bom/pom.xml | 4 ++-- modules/jooby-caffeine/pom.xml | 2 +- modules/jooby-camel/pom.xml | 2 +- modules/jooby-cli/pom.xml | 2 +- modules/jooby-commons-email/pom.xml | 2 +- modules/jooby-conscrypt/pom.xml | 2 +- modules/jooby-db-scheduler/pom.xml | 2 +- modules/jooby-distribution/pom.xml | 2 +- modules/jooby-ebean/pom.xml | 2 +- modules/jooby-flyway/pom.xml | 2 +- modules/jooby-freemarker/pom.xml | 2 +- modules/jooby-gradle-setup/pom.xml | 2 +- modules/jooby-graphiql/pom.xml | 2 +- modules/jooby-graphql/pom.xml | 2 +- modules/jooby-gson/pom.xml | 2 +- modules/jooby-guice/pom.xml | 2 +- modules/jooby-handlebars/pom.xml | 2 +- modules/jooby-hibernate-validator/pom.xml | 2 +- modules/jooby-hibernate/pom.xml | 2 +- modules/jooby-hikari/pom.xml | 2 +- modules/jooby-jackson/pom.xml | 2 +- modules/jooby-jasypt/pom.xml | 2 +- modules/jooby-jdbi/pom.xml | 2 +- modules/jooby-jetty/pom.xml | 2 +- modules/jooby-jstachio/pom.xml | 2 +- modules/jooby-jte/pom.xml | 2 +- modules/jooby-jwt/pom.xml | 2 +- modules/jooby-kafka/pom.xml | 2 +- modules/jooby-kotlin/pom.xml | 2 +- modules/jooby-log4j/pom.xml | 2 +- modules/jooby-logback/pom.xml | 2 +- modules/jooby-maven-plugin/pom.xml | 2 +- modules/jooby-metrics/pom.xml | 2 +- modules/jooby-mutiny/pom.xml | 2 +- modules/jooby-netty/pom.xml | 2 +- modules/jooby-openapi/pom.xml | 2 +- modules/jooby-pac4j/pom.xml | 2 +- modules/jooby-pebble/pom.xml | 2 +- modules/jooby-quartz/pom.xml | 2 +- modules/jooby-reactor/pom.xml | 2 +- modules/jooby-redis/pom.xml | 2 +- modules/jooby-redoc/pom.xml | 2 +- modules/jooby-rocker/pom.xml | 2 +- modules/jooby-run/pom.xml | 2 +- modules/jooby-rxjava3/pom.xml | 2 +- modules/jooby-stork/pom.xml | 2 +- modules/jooby-swagger-ui/pom.xml | 2 +- modules/jooby-test/pom.xml | 2 +- modules/jooby-thymeleaf/pom.xml | 2 +- modules/jooby-undertow/pom.xml | 2 +- modules/jooby-vertx-mysql-client/pom.xml | 2 +- modules/jooby-vertx-pg-client/pom.xml | 2 +- modules/jooby-vertx-sql-client/pom.xml | 2 +- modules/jooby-vertx/pom.xml | 2 +- modules/jooby-whoops/pom.xml | 2 +- modules/jooby-yasson/pom.xml | 2 +- modules/pom.xml | 2 +- pom.xml | 4 ++-- tests/pom.xml | 2 +- 66 files changed, 68 insertions(+), 68 deletions(-) diff --git a/jooby/pom.xml b/jooby/pom.xml index 8034b72c0a..fcaa4fab30 100644 --- a/jooby/pom.xml +++ b/jooby/pom.xml @@ -6,7 +6,7 @@ io.jooby jooby-project - 4.0.11 + 4.0.12-SNAPSHOT jooby jooby diff --git a/modules/jooby-apt/pom.xml b/modules/jooby-apt/pom.xml index a2d684aa6c..198493dc04 100644 --- a/modules/jooby-apt/pom.xml +++ b/modules/jooby-apt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-apt jooby-apt diff --git a/modules/jooby-avaje-inject/pom.xml b/modules/jooby-avaje-inject/pom.xml index 2dd9fe30a8..dee1e09015 100644 --- a/modules/jooby-avaje-inject/pom.xml +++ b/modules/jooby-avaje-inject/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-avaje-inject jooby-avaje-inject diff --git a/modules/jooby-avaje-jsonb/pom.xml b/modules/jooby-avaje-jsonb/pom.xml index 423b5a51bc..d8e8fba7c0 100644 --- a/modules/jooby-avaje-jsonb/pom.xml +++ b/modules/jooby-avaje-jsonb/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-avaje-jsonb jooby-avaje-jsonb diff --git a/modules/jooby-avaje-validator/pom.xml b/modules/jooby-avaje-validator/pom.xml index eaa43f5213..c88fe7d1de 100644 --- a/modules/jooby-avaje-validator/pom.xml +++ b/modules/jooby-avaje-validator/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-avaje-validator jooby-avaje-validator diff --git a/modules/jooby-awssdk-v1/pom.xml b/modules/jooby-awssdk-v1/pom.xml index fe406c740d..0cf6a31014 100644 --- a/modules/jooby-awssdk-v1/pom.xml +++ b/modules/jooby-awssdk-v1/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-awssdk-v1 jooby-awssdk-v1 diff --git a/modules/jooby-awssdk-v2/pom.xml b/modules/jooby-awssdk-v2/pom.xml index 40e77c9c5a..a819f6a9ad 100644 --- a/modules/jooby-awssdk-v2/pom.xml +++ b/modules/jooby-awssdk-v2/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-awssdk-v2 jooby-awssdk-v2 diff --git a/modules/jooby-bom/pom.xml b/modules/jooby-bom/pom.xml index f1c5c6569f..fce2ece48c 100644 --- a/modules/jooby-bom/pom.xml +++ b/modules/jooby-bom/pom.xml @@ -7,14 +7,14 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT io.jooby jooby-bom jooby-bom pom - 4.0.11 + 4.0.12-SNAPSHOT Jooby (Bill of Materials) https://jooby.io diff --git a/modules/jooby-caffeine/pom.xml b/modules/jooby-caffeine/pom.xml index 0adf4d6fe4..091683c998 100644 --- a/modules/jooby-caffeine/pom.xml +++ b/modules/jooby-caffeine/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-caffeine jooby-caffeine diff --git a/modules/jooby-camel/pom.xml b/modules/jooby-camel/pom.xml index 6cfb667dc5..dd19494128 100644 --- a/modules/jooby-camel/pom.xml +++ b/modules/jooby-camel/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-camel jooby-camel diff --git a/modules/jooby-cli/pom.xml b/modules/jooby-cli/pom.xml index d135f2236d..17251648f5 100644 --- a/modules/jooby-cli/pom.xml +++ b/modules/jooby-cli/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-cli jooby-cli diff --git a/modules/jooby-commons-email/pom.xml b/modules/jooby-commons-email/pom.xml index 8baa88812a..3bc7d000e1 100644 --- a/modules/jooby-commons-email/pom.xml +++ b/modules/jooby-commons-email/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-commons-email jooby-commons-email diff --git a/modules/jooby-conscrypt/pom.xml b/modules/jooby-conscrypt/pom.xml index 0ac4b2afed..c263bed1bc 100644 --- a/modules/jooby-conscrypt/pom.xml +++ b/modules/jooby-conscrypt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-conscrypt jooby-conscrypt diff --git a/modules/jooby-db-scheduler/pom.xml b/modules/jooby-db-scheduler/pom.xml index 9e82c5e90f..3a85a83200 100644 --- a/modules/jooby-db-scheduler/pom.xml +++ b/modules/jooby-db-scheduler/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-db-scheduler jooby-db-scheduler diff --git a/modules/jooby-distribution/pom.xml b/modules/jooby-distribution/pom.xml index 03fe49fa91..a6af7e9d6c 100644 --- a/modules/jooby-distribution/pom.xml +++ b/modules/jooby-distribution/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-distribution jooby-distribution diff --git a/modules/jooby-ebean/pom.xml b/modules/jooby-ebean/pom.xml index b8ee6f7d99..433f9826ef 100644 --- a/modules/jooby-ebean/pom.xml +++ b/modules/jooby-ebean/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-ebean jooby-ebean diff --git a/modules/jooby-flyway/pom.xml b/modules/jooby-flyway/pom.xml index 7d7d6bd32c..62b5ecf644 100644 --- a/modules/jooby-flyway/pom.xml +++ b/modules/jooby-flyway/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-flyway jooby-flyway diff --git a/modules/jooby-freemarker/pom.xml b/modules/jooby-freemarker/pom.xml index c474ce6782..e9ebe611b9 100644 --- a/modules/jooby-freemarker/pom.xml +++ b/modules/jooby-freemarker/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-freemarker jooby-freemarker diff --git a/modules/jooby-gradle-setup/pom.xml b/modules/jooby-gradle-setup/pom.xml index fa3388c8ff..84e846db15 100644 --- a/modules/jooby-gradle-setup/pom.xml +++ b/modules/jooby-gradle-setup/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-gradle-setup jooby-gradle-setup diff --git a/modules/jooby-graphiql/pom.xml b/modules/jooby-graphiql/pom.xml index eefbd16922..12480dbbf5 100644 --- a/modules/jooby-graphiql/pom.xml +++ b/modules/jooby-graphiql/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-graphiql jooby-graphiql diff --git a/modules/jooby-graphql/pom.xml b/modules/jooby-graphql/pom.xml index 596f418a65..e3f1c22ae3 100644 --- a/modules/jooby-graphql/pom.xml +++ b/modules/jooby-graphql/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-graphql jooby-graphql diff --git a/modules/jooby-gson/pom.xml b/modules/jooby-gson/pom.xml index 09970cea8b..9f04d8fb55 100644 --- a/modules/jooby-gson/pom.xml +++ b/modules/jooby-gson/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-gson jooby-gson diff --git a/modules/jooby-guice/pom.xml b/modules/jooby-guice/pom.xml index 624a5f4cec..d20ae4f894 100644 --- a/modules/jooby-guice/pom.xml +++ b/modules/jooby-guice/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-guice jooby-guice diff --git a/modules/jooby-handlebars/pom.xml b/modules/jooby-handlebars/pom.xml index 0e93a4ab99..31f87c6cbb 100644 --- a/modules/jooby-handlebars/pom.xml +++ b/modules/jooby-handlebars/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-handlebars jooby-handlebars diff --git a/modules/jooby-hibernate-validator/pom.xml b/modules/jooby-hibernate-validator/pom.xml index 635fe4ab31..e50499d6ff 100644 --- a/modules/jooby-hibernate-validator/pom.xml +++ b/modules/jooby-hibernate-validator/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-hibernate-validator jooby-hibernate-validator diff --git a/modules/jooby-hibernate/pom.xml b/modules/jooby-hibernate/pom.xml index cb455460f0..b60c0e2b9c 100644 --- a/modules/jooby-hibernate/pom.xml +++ b/modules/jooby-hibernate/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-hibernate jooby-hibernate diff --git a/modules/jooby-hikari/pom.xml b/modules/jooby-hikari/pom.xml index 5a5acec7ca..a8441a2fe8 100644 --- a/modules/jooby-hikari/pom.xml +++ b/modules/jooby-hikari/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-hikari jooby-hikari diff --git a/modules/jooby-jackson/pom.xml b/modules/jooby-jackson/pom.xml index df9c9a5b39..60bcd147de 100644 --- a/modules/jooby-jackson/pom.xml +++ b/modules/jooby-jackson/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-jackson jooby-jackson diff --git a/modules/jooby-jasypt/pom.xml b/modules/jooby-jasypt/pom.xml index 13d8ac850c..3ec7bf40f7 100644 --- a/modules/jooby-jasypt/pom.xml +++ b/modules/jooby-jasypt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-jasypt jooby-jasypt diff --git a/modules/jooby-jdbi/pom.xml b/modules/jooby-jdbi/pom.xml index 132bcde877..b7e78b6012 100644 --- a/modules/jooby-jdbi/pom.xml +++ b/modules/jooby-jdbi/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-jdbi jooby-jdbi diff --git a/modules/jooby-jetty/pom.xml b/modules/jooby-jetty/pom.xml index bf9fbddfbb..fd0fbec848 100644 --- a/modules/jooby-jetty/pom.xml +++ b/modules/jooby-jetty/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-jetty jooby-jetty diff --git a/modules/jooby-jstachio/pom.xml b/modules/jooby-jstachio/pom.xml index e76c04eda9..9ba8bf9668 100644 --- a/modules/jooby-jstachio/pom.xml +++ b/modules/jooby-jstachio/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-jstachio jooby-jstachio diff --git a/modules/jooby-jte/pom.xml b/modules/jooby-jte/pom.xml index 9ada034d3a..111e4e6309 100644 --- a/modules/jooby-jte/pom.xml +++ b/modules/jooby-jte/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-jte jooby-jte diff --git a/modules/jooby-jwt/pom.xml b/modules/jooby-jwt/pom.xml index bad47ff10e..1db46e857a 100644 --- a/modules/jooby-jwt/pom.xml +++ b/modules/jooby-jwt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-jwt jooby-jwt diff --git a/modules/jooby-kafka/pom.xml b/modules/jooby-kafka/pom.xml index ff798abade..1bb0c5fde0 100644 --- a/modules/jooby-kafka/pom.xml +++ b/modules/jooby-kafka/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-kafka jooby-kafka diff --git a/modules/jooby-kotlin/pom.xml b/modules/jooby-kotlin/pom.xml index e5c5edeecd..926506d987 100644 --- a/modules/jooby-kotlin/pom.xml +++ b/modules/jooby-kotlin/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-kotlin jooby-kotlin diff --git a/modules/jooby-log4j/pom.xml b/modules/jooby-log4j/pom.xml index f65d1e8a9b..e309758816 100644 --- a/modules/jooby-log4j/pom.xml +++ b/modules/jooby-log4j/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-log4j jooby-log4j diff --git a/modules/jooby-logback/pom.xml b/modules/jooby-logback/pom.xml index c33fb46e41..b759ddb808 100644 --- a/modules/jooby-logback/pom.xml +++ b/modules/jooby-logback/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-logback jooby-logback diff --git a/modules/jooby-maven-plugin/pom.xml b/modules/jooby-maven-plugin/pom.xml index 08daec4452..b06c863d6d 100644 --- a/modules/jooby-maven-plugin/pom.xml +++ b/modules/jooby-maven-plugin/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-maven-plugin jooby-maven-plugin diff --git a/modules/jooby-metrics/pom.xml b/modules/jooby-metrics/pom.xml index effed58cad..0d934b681f 100644 --- a/modules/jooby-metrics/pom.xml +++ b/modules/jooby-metrics/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-metrics jooby-metrics diff --git a/modules/jooby-mutiny/pom.xml b/modules/jooby-mutiny/pom.xml index 111ce6cd9e..87973814b7 100644 --- a/modules/jooby-mutiny/pom.xml +++ b/modules/jooby-mutiny/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-mutiny jooby-mutiny diff --git a/modules/jooby-netty/pom.xml b/modules/jooby-netty/pom.xml index 37d4e9617d..d4de9eb6bb 100644 --- a/modules/jooby-netty/pom.xml +++ b/modules/jooby-netty/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-netty jooby-netty diff --git a/modules/jooby-openapi/pom.xml b/modules/jooby-openapi/pom.xml index 96fa17e2bc..a5a7f5bb73 100644 --- a/modules/jooby-openapi/pom.xml +++ b/modules/jooby-openapi/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-openapi jooby-openapi diff --git a/modules/jooby-pac4j/pom.xml b/modules/jooby-pac4j/pom.xml index 243c342cd4..7414fbc3ee 100644 --- a/modules/jooby-pac4j/pom.xml +++ b/modules/jooby-pac4j/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-pac4j jooby-pac4j diff --git a/modules/jooby-pebble/pom.xml b/modules/jooby-pebble/pom.xml index 794ea24796..3b03d868f5 100644 --- a/modules/jooby-pebble/pom.xml +++ b/modules/jooby-pebble/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-pebble jooby-pebble diff --git a/modules/jooby-quartz/pom.xml b/modules/jooby-quartz/pom.xml index 212b4b542e..998cb99c3c 100644 --- a/modules/jooby-quartz/pom.xml +++ b/modules/jooby-quartz/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-quartz jooby-quartz diff --git a/modules/jooby-reactor/pom.xml b/modules/jooby-reactor/pom.xml index 12d75aa126..c4d15e2dbf 100644 --- a/modules/jooby-reactor/pom.xml +++ b/modules/jooby-reactor/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-reactor jooby-reactor diff --git a/modules/jooby-redis/pom.xml b/modules/jooby-redis/pom.xml index 5eff113b4e..7c81a3d278 100644 --- a/modules/jooby-redis/pom.xml +++ b/modules/jooby-redis/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-redis jooby-redis diff --git a/modules/jooby-redoc/pom.xml b/modules/jooby-redoc/pom.xml index 9473d17df5..be2c90a870 100644 --- a/modules/jooby-redoc/pom.xml +++ b/modules/jooby-redoc/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-redoc jooby-redoc diff --git a/modules/jooby-rocker/pom.xml b/modules/jooby-rocker/pom.xml index ad837b1f0e..766e860c4a 100644 --- a/modules/jooby-rocker/pom.xml +++ b/modules/jooby-rocker/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-rocker jooby-rocker diff --git a/modules/jooby-run/pom.xml b/modules/jooby-run/pom.xml index 4611dd723b..9ebbda06da 100644 --- a/modules/jooby-run/pom.xml +++ b/modules/jooby-run/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-run jooby-run diff --git a/modules/jooby-rxjava3/pom.xml b/modules/jooby-rxjava3/pom.xml index 33847ab1ab..53871a68ad 100644 --- a/modules/jooby-rxjava3/pom.xml +++ b/modules/jooby-rxjava3/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-rxjava3 jooby-rxjava3 diff --git a/modules/jooby-stork/pom.xml b/modules/jooby-stork/pom.xml index 13c48414a0..cc2330271f 100644 --- a/modules/jooby-stork/pom.xml +++ b/modules/jooby-stork/pom.xml @@ -4,7 +4,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-stork diff --git a/modules/jooby-swagger-ui/pom.xml b/modules/jooby-swagger-ui/pom.xml index 05d6982288..f254b42e49 100644 --- a/modules/jooby-swagger-ui/pom.xml +++ b/modules/jooby-swagger-ui/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-swagger-ui jooby-swagger-ui diff --git a/modules/jooby-test/pom.xml b/modules/jooby-test/pom.xml index 4b1635600e..b8f5d4e9c7 100644 --- a/modules/jooby-test/pom.xml +++ b/modules/jooby-test/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-test jooby-test diff --git a/modules/jooby-thymeleaf/pom.xml b/modules/jooby-thymeleaf/pom.xml index 1e979cdf61..8904d4182a 100644 --- a/modules/jooby-thymeleaf/pom.xml +++ b/modules/jooby-thymeleaf/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-thymeleaf jooby-thymeleaf diff --git a/modules/jooby-undertow/pom.xml b/modules/jooby-undertow/pom.xml index f54c0fde86..5d4e6089f0 100644 --- a/modules/jooby-undertow/pom.xml +++ b/modules/jooby-undertow/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-undertow jooby-undertow diff --git a/modules/jooby-vertx-mysql-client/pom.xml b/modules/jooby-vertx-mysql-client/pom.xml index 202ae5ebe4..cef4f9027b 100644 --- a/modules/jooby-vertx-mysql-client/pom.xml +++ b/modules/jooby-vertx-mysql-client/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-vertx-mysql-client jooby-vertx-mysql-client diff --git a/modules/jooby-vertx-pg-client/pom.xml b/modules/jooby-vertx-pg-client/pom.xml index 688a480793..d299f96c0c 100644 --- a/modules/jooby-vertx-pg-client/pom.xml +++ b/modules/jooby-vertx-pg-client/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-vertx-pg-client jooby-vertx-pg-client diff --git a/modules/jooby-vertx-sql-client/pom.xml b/modules/jooby-vertx-sql-client/pom.xml index d990d6fb86..576c1be170 100644 --- a/modules/jooby-vertx-sql-client/pom.xml +++ b/modules/jooby-vertx-sql-client/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-vertx-sql-client jooby-vertx-sql-client diff --git a/modules/jooby-vertx/pom.xml b/modules/jooby-vertx/pom.xml index 1da28cd113..641b56bb7e 100644 --- a/modules/jooby-vertx/pom.xml +++ b/modules/jooby-vertx/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-vertx jooby-vertx diff --git a/modules/jooby-whoops/pom.xml b/modules/jooby-whoops/pom.xml index 239f864e2a..4950ee8a49 100644 --- a/modules/jooby-whoops/pom.xml +++ b/modules/jooby-whoops/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-whoops jooby-whoops diff --git a/modules/jooby-yasson/pom.xml b/modules/jooby-yasson/pom.xml index 452bec4f8d..61af4f37df 100644 --- a/modules/jooby-yasson/pom.xml +++ b/modules/jooby-yasson/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.11 + 4.0.12-SNAPSHOT jooby-yasson jooby-yasson diff --git a/modules/pom.xml b/modules/pom.xml index 3bdceb6a17..13bc761275 100644 --- a/modules/pom.xml +++ b/modules/pom.xml @@ -4,7 +4,7 @@ io.jooby jooby-project - 4.0.11 + 4.0.12-SNAPSHOT modules diff --git a/pom.xml b/pom.xml index d8e97d8395..9d8c06beb3 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.jooby jooby-project - 4.0.11 + 4.0.12-SNAPSHOT pom jooby-project @@ -210,7 +210,7 @@ 21 21 yyyy-MM-dd HH:mm:ssa - 2025-11-04T00:15:14Z + 2025-11-04T01:02:57Z UTF-8 etc${file.separator}source${file.separator}formatter.sh diff --git a/tests/pom.xml b/tests/pom.xml index 81416b91bb..ddcb385060 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -6,7 +6,7 @@ io.jooby jooby-project - 4.0.11 + 4.0.12-SNAPSHOT tests tests From 4edacc74474bce4544934ba0a942272fe38d9a85 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Tue, 25 Nov 2025 11:46:34 -0300 Subject: [PATCH 03/31] WIP: openapi: asciidoc output #3820 - setup pebble as template engine/ascii doc pre-processor - start of `snippets` which are going to output/print routes in multiple formats --- modules/jooby-openapi/pom.xml | 11 + .../internal/openapi/AsciiDocGenerator.java | 110 +++++++++ .../io/jooby/internal/openapi/OpenAPIExt.java | 22 ++ .../internal/openapi/asciidoc/Filters.java | 109 +++++++++ .../internal/openapi/asciidoc/Functions.java | 76 ++++++ .../openapi/asciidoc/SnippetResolver.java | 56 +++++ .../io/jooby/openapi/OpenAPIGenerator.java | 33 +++ .../src/main/java/module-info.java | 4 + .../templates/asciidoc/default-curl.snippet | 4 + .../asciidoc/default-form-parameters.snippet | 9 + .../asciidoc/default-http-request.snippet | 8 + .../asciidoc/default-http-response.snippet | 8 + .../asciidoc/default-httpie-request.snippet | 4 + .../templates/asciidoc/default-links.snippet | 9 + .../asciidoc/default-path-parameters.snippet | 10 + .../asciidoc/default-query-parameters.snippet | 9 + .../asciidoc/default-request-body.snippet | 4 + .../asciidoc/default-request-cookies.snippet | 9 + .../asciidoc/default-request-fields.snippet | 10 + .../asciidoc/default-request-headers.snippet | 9 + .../default-request-parameters.snippet | 9 + .../default-request-part-body.snippet | 4 + .../default-request-part-fields.snippet | 10 + .../asciidoc/default-request-parts.snippet | 9 + .../asciidoc/default-response-body.snippet | 4 + .../asciidoc/default-response-cookies.snippet | 9 + .../asciidoc/default-response-fields.snippet | 10 + .../asciidoc/default-response-headers.snippet | 9 + .../java/io/jooby/openapi/OpenAPIResult.java | 29 +++ .../issues/i3729/museum/AsciiDocTest.java | 31 +++ .../src/test/resources/adoc/guide.adoc | 217 +++++++++++++++++ .../src/test/resources/adoc/museum.adoc | 18 ++ .../src/test/resources/docs/api-guide.adoc | 228 ++++++++++++++++++ .../resources/docs/getting-started-guide.adoc | 175 ++++++++++++++ 34 files changed, 1276 insertions(+) create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AsciiDocGenerator.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Filters.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/SnippetResolver.java create mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-curl.snippet create mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-form-parameters.snippet create mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-request.snippet create mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-response.snippet create mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-httpie-request.snippet create mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-links.snippet create mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-path-parameters.snippet create mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-query-parameters.snippet create mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-body.snippet create mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-cookies.snippet create mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-fields.snippet create mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-headers.snippet create mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-parameters.snippet create mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-part-body.snippet create mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-part-fields.snippet create mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-parts.snippet create mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-body.snippet create mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-cookies.snippet create mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-fields.snippet create mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-headers.snippet create mode 100644 modules/jooby-openapi/src/test/java/issues/i3729/museum/AsciiDocTest.java create mode 100644 modules/jooby-openapi/src/test/resources/adoc/guide.adoc create mode 100644 modules/jooby-openapi/src/test/resources/adoc/museum.adoc create mode 100644 modules/jooby-openapi/src/test/resources/docs/api-guide.adoc create mode 100644 modules/jooby-openapi/src/test/resources/docs/getting-started-guide.adoc diff --git a/modules/jooby-openapi/pom.xml b/modules/jooby-openapi/pom.xml index a5a7f5bb73..519313da9a 100644 --- a/modules/jooby-openapi/pom.xml +++ b/modules/jooby-openapi/pom.xml @@ -51,6 +51,17 @@ 12.1.1 + + org.asciidoctor + asciidoctorj + 3.0.1 + + + + io.pebbletemplates + pebble + + commons-codec commons-codec diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AsciiDocGenerator.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AsciiDocGenerator.java new file mode 100644 index 0000000000..0ea4e999a8 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AsciiDocGenerator.java @@ -0,0 +1,110 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi; + +import java.io.IOException; +import java.io.StringWriter; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.jooby.internal.openapi.asciidoc.Filters; +import io.jooby.internal.openapi.asciidoc.Functions; +import io.jooby.internal.openapi.asciidoc.SnippetResolver; +import io.pebbletemplates.pebble.PebbleEngine; +import io.pebbletemplates.pebble.attributes.AttributeResolver; +import io.pebbletemplates.pebble.extension.*; +import io.pebbletemplates.pebble.lexer.Syntax; +import io.pebbletemplates.pebble.operator.BinaryOperator; +import io.pebbletemplates.pebble.operator.UnaryOperator; +import io.pebbletemplates.pebble.tokenParser.TokenParser; + +public class AsciiDocGenerator { + + public static String generate(OpenAPIExt openAPI, Path index) throws IOException { + var snippetResolver = new SnippetResolver(index.getParent().resolve("snippet")); + var engine = + newEngine( + new OpenApiSupport(Map.of("openapi", openAPI, "snippetResolver", snippetResolver)), + "${", + "}"); + snippetResolver.setEngine(engine); + + var template = engine.getTemplate(index.toAbsolutePath().toString()); + var writer = new StringWriter(); + var context = new HashMap(); + template.evaluate(writer, context); + return writer.toString(); + } + + private static PebbleEngine newEngine(OpenApiSupport extension, String start, String end) { + // 1. Define the custom syntax using a builder + return new PebbleEngine.Builder() + .extension(extension) + .autoEscaping(false) + .syntax( + new Syntax.Builder() + .setPrintOpenDelimiter(start) + .setPrintCloseDelimiter(end) + .setEnableNewLineTrimming(false) + .build()) + .build(); + } + + private static class OpenApiSupport implements Extension { + private final Map vars; + + public OpenApiSupport(Map vars) { + this.vars = vars; + } + + @Override + public Map getFilters() { + return Filters.fn(); + } + + @Override + public Map getTests() { + return Map.of(); + } + + @Override + public Map getFunctions() { + return Functions.fn(); + } + + @Override + public List getTokenParsers() { + return List.of(); + } + + @Override + public List getBinaryOperators() { + return List.of(); + } + + @Override + public List getUnaryOperators() { + return List.of(); + } + + @Override + public Map getGlobalVariables() { + return vars; + } + + @Override + public List getNodeVisitors() { + return List.of(); + } + + @Override + public List getAttributeResolver() { + return List.of(); + } + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIExt.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIExt.java index 43f5d38a81..622d0eacd4 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIExt.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIExt.java @@ -9,6 +9,7 @@ import java.util.function.BiConsumer; import java.util.function.Consumer; import java.util.function.Function; +import java.util.function.Predicate; import com.fasterxml.jackson.annotation.JsonIgnore; import io.jooby.Router; @@ -233,4 +234,25 @@ private void setProperty(S src, Function getter, S target, BiConsum } } } + + public OperationExt findOperationById(String operationId) { + return getOperations().stream() + .filter(it -> it.getOperationId().equals(operationId)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Operation not found: " + operationId)); + } + + public OperationExt findOperation(String method, String pattern) { + Predicate filter = op -> op.getPattern().equals(pattern); + if (method != null) { + filter = filter.and(op -> op.getMethod().equals(method)); + } + return getOperations().stream() + .filter(filter) + .findFirst() + .orElseThrow( + () -> + new IllegalArgumentException( + "Operation not found: " + (method == null ? "" : method + " ") + pattern)); + } } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Filters.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Filters.java new file mode 100644 index 0000000000..ea89c6dd65 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Filters.java @@ -0,0 +1,109 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.*; + +import io.jooby.internal.openapi.OperationExt; +import io.pebbletemplates.pebble.error.PebbleException; +import io.pebbletemplates.pebble.extension.Filter; +import io.pebbletemplates.pebble.template.EvaluationContext; +import io.pebbletemplates.pebble.template.PebbleTemplate; +import io.swagger.v3.oas.models.Operation; + +public enum Filters implements Filter { + curl { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + try { + if (!(input instanceof OperationExt operation)) { + throw new IllegalArgumentException( + "Argument must be " + Operation.class.getName() + ". Got: " + input); + } + var snippetResolver = (SnippetResolver) context.getVariable("snippetResolver"); + + var options = new LinkedHashMap>(); + options.put("-X", Set.of(null)); + operation + .getProduces() + .forEach( + produces -> { + options + .computeIfAbsent("-H", (key) -> new LinkedHashSet<>()) + .add("'Accept: " + produces + "'"); + }); + + // Convert to map so can override any generated option + var optionList = new ArrayList<>(args.values()); + for (int i = 0; i < optionList.size(); ) { + var key = optionList.get(i).toString(); + String value = null; + if (i + 1 < optionList.size()) { + var next = optionList.get(i + 1); + if (next.toString().startsWith("-")) { + i += 1; + } else { + value = next.toString(); + i += 2; + } + } else { + i += 1; + } + var values = options.computeIfAbsent(key, k -> new LinkedHashSet<>()); + if (value != null) { + values.add(value); + } + } + return snippetResolver.apply( + "curl", Map.of("url", operation.getPattern(), "options", toString(options))); + } catch (PebbleException pebbleException) { + throw pebbleException; + } catch (Exception exception) { + throw new PebbleException(exception, name() + " failed to generate output"); + } + } + + private String toString(Map> options) { + if (options.isEmpty()) { + return ""; + } + var sb = new StringBuilder(); + var separator = " "; + for (var e : options.entrySet()) { + var values = e.getValue(); + if (values.isEmpty()) { + sb.append(e.getKey()).append(separator); + } else { + for (var value : e.getValue()) { + sb.append(e.getKey()).append(separator); + sb.append(value).append(separator); + } + } + } + sb.deleteCharAt(sb.length() - separator.length()); + return sb.toString(); + } + + @Override + public List getArgumentNames() { + return null; + } + }; + + public static Map fn() { + Map functions = new HashMap<>(); + for (var value : values()) { + functions.put(value.name(), value); + } + return functions; + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java new file mode 100644 index 0000000000..3327e5d3e4 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java @@ -0,0 +1,76 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.jooby.internal.openapi.OpenAPIExt; +import io.pebbletemplates.pebble.error.PebbleException; +import io.pebbletemplates.pebble.extension.Function; +import io.pebbletemplates.pebble.template.EvaluationContext; +import io.pebbletemplates.pebble.template.PebbleTemplate; + +public enum Functions implements Function { + operation { + @Override + public List getArgumentNames() { + return List.of("identifier", "pattern"); + } + + @Override + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + try { + var namedArgs = new HashMap(); + var value = (String) args.get("identifier"); + if (isHTTPMethod(value)) { + namedArgs.put("method", value); + } else if (value.startsWith("/")) { + namedArgs.put("pattern", value); + } else { + namedArgs.put("id", value); + } + namedArgs.putIfAbsent("pattern", (String) args.get("pattern")); + OpenAPIExt openApi = (OpenAPIExt) context.getVariable("openapi"); + var operationId = namedArgs.get("id"); + if (operationId == null) { + var method = namedArgs.get("method"); + var path = namedArgs.get("pattern"); + return openApi.findOperation(method, path); + } else { + return openApi.findOperationById(operationId); + } + } catch (Exception cause) { + throw new PebbleException( + cause, name() + " failed to generate output (?:?)", lineNumber, self.getName()); + } + } + + private boolean isHTTPMethod(String value) { + return switch (value.toUpperCase()) { + case "GET" -> true; + case "POST" -> true; + case "PUT" -> true; + case "DELETE" -> true; + case "HEAD" -> true; + case "OPTIONS" -> true; + case "TRACE" -> true; + case "PATCH" -> true; + default -> false; + }; + } + }; + + public static Map fn() { + Map functions = new HashMap<>(); + for (Functions value : values()) { + functions.put(value.name(), value); + } + return functions; + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/SnippetResolver.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/SnippetResolver.java new file mode 100644 index 0000000000..013e3f1e73 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/SnippetResolver.java @@ -0,0 +1,56 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import io.jooby.SneakyThrows; +import io.pebbletemplates.pebble.PebbleEngine; + +public class SnippetResolver { + private final Path baseDir; + private PebbleEngine engine; + + public SnippetResolver(Path baseDir) { + this.baseDir = baseDir; + } + + public String apply(String snippet, Map context) throws IOException { + var writer = new StringWriter(); + var snippetContent = resolve(baseDir, snippet); + engine.getLiteralTemplate(snippetContent).evaluate(writer, context); + return writer.toString(); + } + + private String resolve(Path snippetDir, String name) { + try { + var templatePath = snippetDir.resolve(name + ".snippet"); + if (Files.exists(templatePath)) { + return Files.readString(templatePath); + } else { + var path = "/io/jooby/openapi/templates/asciidoc/default-" + name + ".snippet"; + try (var in = getClass().getResourceAsStream(path)) { + if (in == null) { + throw new FileNotFoundException("classpath:" + path); + } + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + } catch (IOException x) { + throw SneakyThrows.propagate(x); + } + } + + public void setEngine(PebbleEngine engine) { + this.engine = engine; + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java index a9b1ab7116..fe858b2f32 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java @@ -58,6 +58,13 @@ public String toString(OpenAPIGenerator tool, OpenAPI result) { public String toString(OpenAPIGenerator tool, OpenAPI result) { return tool.toYaml(result); } + }, + + ADOC { + @Override + public String toString(OpenAPIGenerator tool, OpenAPI result) { + return tool.toYaml(result); + } }; /** @@ -310,6 +317,32 @@ private void defaults(String classname, String contextPath, OpenAPIExt openapi) } } + /** + * Generates an adoc version of the given model. + * + * @param openAPI Model. + * @return YAML content. + */ + public @NonNull String toAdoc(@NonNull OpenAPI openAPI) { + try { + return AsciiDocGenerator.generate( + (OpenAPIExt) openAPI, + java.nio.file.Paths.get( + "Users", + "edgar", + "Source", + "jooby", + "modules", + "jooby-openapi", + "src", + "test", + "resources", + "adoc")); + } catch (IOException x) { + throw SneakyThrows.propagate(x); + } + } + /** * Generates a JSON version of the given model. * diff --git a/modules/jooby-openapi/src/main/java/module-info.java b/modules/jooby-openapi/src/main/java/module-info.java index deb869c94b..c01866c3a1 100644 --- a/modules/jooby-openapi/src/main/java/module-info.java +++ b/modules/jooby-openapi/src/main/java/module-info.java @@ -1,6 +1,8 @@ /** Open API module. */ module io.jooby.openapi { exports io.jooby.openapi; + exports io.jooby.internal.openapi to + com.fasterxml.jackson.databind; requires io.jooby; requires static com.github.spotbugs.annotations; @@ -17,4 +19,6 @@ requires org.objectweb.asm; requires org.objectweb.asm.tree; requires org.objectweb.asm.util; + requires io.pebbletemplates; + requires jdk.jshell; } diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-curl.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-curl.snippet new file mode 100644 index 0000000000..29a3da60dc --- /dev/null +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-curl.snippet @@ -0,0 +1,4 @@ +[source,bash] +---- +$ curl ${options} '${url}' +---- diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-form-parameters.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-form-parameters.snippet new file mode 100644 index 0000000000..9e6f6888a5 --- /dev/null +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-form-parameters.snippet @@ -0,0 +1,9 @@ +|=== +|Parameter|Description + +{{#parameters}} +|{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}} +|{{#tableCellContent}}{{description}}{{/tableCellContent}} + +{{/parameters}} +|=== \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-request.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-request.snippet new file mode 100644 index 0000000000..9893e2a036 --- /dev/null +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-request.snippet @@ -0,0 +1,8 @@ +[source,http,options="nowrap"] +---- +{{method}} {{path}} HTTP/1.1 +{{#headers}} +{{name}}: {{value}} +{{/headers}} +{{requestBody}} +---- \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-response.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-response.snippet new file mode 100644 index 0000000000..90a25e8514 --- /dev/null +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-response.snippet @@ -0,0 +1,8 @@ +[source,http,options="nowrap"] +---- +HTTP/1.1 {{statusCode}} {{statusReason}} +{{#headers}} +{{name}}: {{value}} +{{/headers}} +{{responseBody}} +---- \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-httpie-request.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-httpie-request.snippet new file mode 100644 index 0000000000..1b690a5f57 --- /dev/null +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-httpie-request.snippet @@ -0,0 +1,4 @@ +[source,bash] +---- +$ {{echoContent}}http {{options}} {{url}}{{requestItems}} +---- \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-links.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-links.snippet new file mode 100644 index 0000000000..fda4f83764 --- /dev/null +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-links.snippet @@ -0,0 +1,9 @@ +|=== +|Relation|Description + +{{#links}} +|{{#tableCellContent}}`+{{rel}}+`{{/tableCellContent}} +|{{#tableCellContent}}{{description}}{{/tableCellContent}} + +{{/links}} +|=== \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-path-parameters.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-path-parameters.snippet new file mode 100644 index 0000000000..6976b7b28b --- /dev/null +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-path-parameters.snippet @@ -0,0 +1,10 @@ +.+{{path}}+ +|=== +|Parameter|Description + +{{#parameters}} +|{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}} +|{{#tableCellContent}}{{description}}{{/tableCellContent}} + +{{/parameters}} +|=== \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-query-parameters.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-query-parameters.snippet new file mode 100644 index 0000000000..9e6f6888a5 --- /dev/null +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-query-parameters.snippet @@ -0,0 +1,9 @@ +|=== +|Parameter|Description + +{{#parameters}} +|{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}} +|{{#tableCellContent}}{{description}}{{/tableCellContent}} + +{{/parameters}} +|=== \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-body.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-body.snippet new file mode 100644 index 0000000000..00da2d0850 --- /dev/null +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-body.snippet @@ -0,0 +1,4 @@ +[source{{#language}},{{language}}{{/language}},options="nowrap"] +---- +{{body}} +---- \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-cookies.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-cookies.snippet new file mode 100644 index 0000000000..0c5315051f --- /dev/null +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-cookies.snippet @@ -0,0 +1,9 @@ +|=== +|Name|Description + +{{#cookies}} +|{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}} +|{{#tableCellContent}}{{description}}{{/tableCellContent}} + +{{/cookies}} +|=== \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-fields.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-fields.snippet new file mode 100644 index 0000000000..0d8f18e934 --- /dev/null +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-fields.snippet @@ -0,0 +1,10 @@ +|=== +|Path|Type|Description + +{{#fields}} +|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}} +|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}} +|{{#tableCellContent}}{{description}}{{/tableCellContent}} + +{{/fields}} +|=== \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-headers.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-headers.snippet new file mode 100644 index 0000000000..5a8593332a --- /dev/null +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-headers.snippet @@ -0,0 +1,9 @@ +|=== +|Name|Description + +{{#headers}} +|{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}} +|{{#tableCellContent}}{{description}}{{/tableCellContent}} + +{{/headers}} +|=== \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-parameters.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-parameters.snippet new file mode 100644 index 0000000000..9e6f6888a5 --- /dev/null +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-parameters.snippet @@ -0,0 +1,9 @@ +|=== +|Parameter|Description + +{{#parameters}} +|{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}} +|{{#tableCellContent}}{{description}}{{/tableCellContent}} + +{{/parameters}} +|=== \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-part-body.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-part-body.snippet new file mode 100644 index 0000000000..00da2d0850 --- /dev/null +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-part-body.snippet @@ -0,0 +1,4 @@ +[source{{#language}},{{language}}{{/language}},options="nowrap"] +---- +{{body}} +---- \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-part-fields.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-part-fields.snippet new file mode 100644 index 0000000000..0d8f18e934 --- /dev/null +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-part-fields.snippet @@ -0,0 +1,10 @@ +|=== +|Path|Type|Description + +{{#fields}} +|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}} +|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}} +|{{#tableCellContent}}{{description}}{{/tableCellContent}} + +{{/fields}} +|=== \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-parts.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-parts.snippet new file mode 100644 index 0000000000..23a23436cd --- /dev/null +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-parts.snippet @@ -0,0 +1,9 @@ +|=== +|Part|Description + +{{#requestParts}} +|{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}} +|{{#tableCellContent}}{{description}}{{/tableCellContent}} + +{{/requestParts}} +|=== \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-body.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-body.snippet new file mode 100644 index 0000000000..00da2d0850 --- /dev/null +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-body.snippet @@ -0,0 +1,4 @@ +[source{{#language}},{{language}}{{/language}},options="nowrap"] +---- +{{body}} +---- \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-cookies.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-cookies.snippet new file mode 100644 index 0000000000..0c5315051f --- /dev/null +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-cookies.snippet @@ -0,0 +1,9 @@ +|=== +|Name|Description + +{{#cookies}} +|{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}} +|{{#tableCellContent}}{{description}}{{/tableCellContent}} + +{{/cookies}} +|=== \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-fields.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-fields.snippet new file mode 100644 index 0000000000..0d8f18e934 --- /dev/null +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-fields.snippet @@ -0,0 +1,10 @@ +|=== +|Path|Type|Description + +{{#fields}} +|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}} +|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}} +|{{#tableCellContent}}{{description}}{{/tableCellContent}} + +{{/fields}} +|=== \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-headers.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-headers.snippet new file mode 100644 index 0000000000..5a8593332a --- /dev/null +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-headers.snippet @@ -0,0 +1,9 @@ +|=== +|Name|Description + +{{#headers}} +|{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}} +|{{#tableCellContent}}{{description}}{{/tableCellContent}} + +{{/headers}} +|=== \ No newline at end of file diff --git a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIResult.java b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIResult.java index 5913874ba9..6004cb8921 100644 --- a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIResult.java +++ b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIResult.java @@ -5,11 +5,13 @@ */ package io.jooby.openapi; +import java.nio.file.Path; import java.util.List; import java.util.stream.Collectors; import com.fasterxml.jackson.databind.ObjectMapper; import io.jooby.SneakyThrows; +import io.jooby.internal.openapi.AsciiDocGenerator; import io.jooby.internal.openapi.OpenAPIExt; import io.swagger.v3.core.util.Json; import io.swagger.v3.core.util.Yaml; @@ -89,6 +91,33 @@ public String toJson(boolean validate) { } } + public String toAsciiDoc(Path index) { + return toAsciiDoc(index, false); + } + + public String toAsciiDoc(Path index, boolean validate) { + if (failure != null) { + throw failure; + } + try { + String json = this.json.writerWithDefaultPrettyPrinter().writeValueAsString(openAPI); + if (validate) { + SwaggerParseResult result = new OpenAPIV3Parser().readContents(json); + if (result.getMessages().isEmpty()) { + return json; + } + throw new IllegalStateException( + "Invalid OpenAPI specification:\n\t- " + + result.getMessages().stream().collect(Collectors.joining("\n\t- ")).trim() + + "\n\n" + + json); + } + return AsciiDocGenerator.generate(openAPI, index); + } catch (Exception x) { + throw SneakyThrows.propagate(x); + } + } + public static OpenAPIResult failure(RuntimeException failure) { var result = new OpenAPIResult(Json.mapper(), Yaml.mapper(), null); result.failure = failure; diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/museum/AsciiDocTest.java b/modules/jooby-openapi/src/test/java/issues/i3729/museum/AsciiDocTest.java new file mode 100644 index 0000000000..8c8df23bfb --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/museum/AsciiDocTest.java @@ -0,0 +1,31 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3729.museum; + +import static io.swagger.v3.oas.models.SpecVersion.V31; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.file.Path; +import java.nio.file.Paths; + +import io.jooby.openapi.OpenAPIResult; +import io.jooby.openapi.OpenAPITest; + +public class AsciiDocTest { + + @OpenAPITest(value = MuseumApp.class, version = V31) + public void shouldGenerateDoc(OpenAPIResult result) { + assertEquals("", result.toAsciiDoc(basedir().resolve("adoc").resolve("museum.adoc"))); + } + + private static Path basedir() { + var baseDir = Paths.get(System.getProperty("user.dir")); + if (!baseDir.getFileName().toString().endsWith("jooby-openapi")) { + baseDir = baseDir.resolve("modules").resolve("jooby-openapi"); + } + return baseDir.resolve("src").resolve("test").resolve("resources"); + } +} diff --git a/modules/jooby-openapi/src/test/resources/adoc/guide.adoc b/modules/jooby-openapi/src/test/resources/adoc/guide.adoc new file mode 100644 index 0000000000..459f441491 --- /dev/null +++ b/modules/jooby-openapi/src/test/resources/adoc/guide.adoc @@ -0,0 +1,217 @@ += RESTful Notes API Guide +Andy Wilkinson; +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 4 +:sectlinks: +:operation-curl-request-title: Example request +:operation-http-response-title: Example response + +[[overview]] += Overview + +[[overview_http_verbs]] +== HTTP verbs + +RESTful notes tries to adhere as closely as possible to standard HTTP and REST conventions in its +use of HTTP verbs. + +|=== +| Verb | Usage + +| `GET` +| Used to retrieve a resource + +| `POST` +| Used to create a new resource + +| `PATCH` +| Used to update an existing resource, including partial updates + +| `DELETE` +| Used to delete an existing resource +|=== + +[[overview_http_status_codes]] +== HTTP status codes + +RESTful notes tries to adhere as closely as possible to standard HTTP and REST conventions in its +use of HTTP status codes. + +|=== +| Status code | Usage + +| `200 OK` +| The request completed successfully + +| `201 Created` +| A new resource has been created successfully. The resource's URI is available from the response's +`Location` header + +| `204 No Content` +| An update to an existing resource has been applied successfully + +| `400 Bad Request` +| The request was malformed. The response body will include an error providing further information + +| `404 Not Found` +| The requested resource did not exist +|=== + +[[overview_errors]] +== Errors + +Whenever an error response (status code >= 400) is returned, the body will contain a JSON object +that describes the problem. The error object has the following structure: + +include::{snippets}/error-example/response-fields.adoc[] + +For example, a request that attempts to apply a non-existent tag to a note will produce a +`400 Bad Request` response: + +include::{snippets}/error-example/http-response.adoc[] + +[[resources]] += Resources + + + +[[resources_index]] +== Index + +The index provides the entry point into the service. + + + +[[resources_index_access]] +=== Accessing the index + +A `GET` request is used to access the index + +operation::index-example[snippets='response-fields,http-response,links'] + +[[resources_notes]] +== Notes + +The Notes resources is used to create and list notes + + + +[[resources_notes_list]] +=== Listing notes + +A `GET` request will list all of the service's notes. + +operation::notes-list-example[snippets='response-fields,curl-request,http-response,links'] + + + +[[resources_notes_create]] +=== Creating a note + +A `POST` request is used to create a note. + +operation::notes-create-example[snippets='request-fields,curl-request,http-response'] + + + +[[resources_tags]] +== Tags + +The Tags resource is used to create and list tags. + + + +[[resources_tags_list]] +=== Listing tags + +A `GET` request will list all of the service's tags. + +operation::tags-list-example[snippets='response-fields,curl-request,http-response,links'] + + + +[[resources_tags_create]] +=== Creating a tag + +A `POST` request is used to create a note + +operation::tags-create-example[snippets='request-fields,curl-request,http-response'] + + + +[[resources_note]] +== Note + +The Note resource is used to retrieve, update, and delete individual notes + + + +[[resources_note_links]] +=== Links + +include::{snippets}/note-get-example/links.adoc[] + + + +[[resources_note_retrieve]] +=== Retrieve a note + +A `GET` request will retrieve the details of a note + +operation::note-get-example[snippets='response-fields,curl-request,http-response'] + + + +[[resources_note_update]] +=== Update a note + +A `PATCH` request is used to update a note + +==== Request structure + +include::{snippets}/note-update-example/request-fields.adoc[] + +To leave an attribute of a note unchanged, any of the above may be omitted from the request. + +==== Example request + +include::{snippets}/note-update-example/curl-request.adoc[] + +==== Example response + +include::{snippets}/note-update-example/http-response.adoc[] + + + +[[resources_tag]] +== Tag + +The Tag resource is used to retrieve, update, and delete individual tags + + + +[[resources_tag_links]] +=== Links + +include::{snippets}/tag-get-example/links.adoc[] + + + +[[resources_tag_retrieve]] +=== Retrieve a tag + +A `GET` request will retrieve the details of a tag + +operation::tag-get-example[snippets='response-fields,curl-request,http-response'] + + + +[[resources_tag_update]] +=== Update a tag + +A `PATCH` request is used to update a tag + +operation::tag-update-example[snippets='request-fields,curl-request,http-response'] diff --git a/modules/jooby-openapi/src/test/resources/adoc/museum.adoc b/modules/jooby-openapi/src/test/resources/adoc/museum.adoc new file mode 100644 index 0000000000..5b610862c5 --- /dev/null +++ b/modules/jooby-openapi/src/test/resources/adoc/museum.adoc @@ -0,0 +1,18 @@ += RESTful Notes API Guide +Jooby Doc; +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 4 +:sectlinks: + +[[overview]] += Overview + +[[overview_http_verbs]] +== HTTP verbs + +${ operation("POST", "/museum-hours") | curl } + +Response: diff --git a/modules/jooby-openapi/src/test/resources/docs/api-guide.adoc b/modules/jooby-openapi/src/test/resources/docs/api-guide.adoc new file mode 100644 index 0000000000..8cb817847e --- /dev/null +++ b/modules/jooby-openapi/src/test/resources/docs/api-guide.adoc @@ -0,0 +1,228 @@ += RESTful Notes API Guide +Andy Wilkinson; +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 4 +:sectlinks: +:operation-curl-request-title: Example request +:operation-http-response-title: Example response + +[[overview]] += Overview + +[[overview_http_verbs]] +== HTTP verbs + +RESTful notes tries to adhere as closely as possible to standard HTTP and REST conventions in its +use of HTTP verbs. + +|=== +| Verb | Usage + +| `GET` +| Used to retrieve a resource + +| `POST` +| Used to create a new resource + +| `PATCH` +| Used to update an existing resource, including partial updates + +| `DELETE` +| Used to delete an existing resource +|=== + +[[overview_http_status_codes]] +== HTTP status codes + +RESTful notes tries to adhere as closely as possible to standard HTTP and REST conventions in its +use of HTTP status codes. + +|=== +| Status code | Usage + +| `200 OK` +| The request completed successfully + +| `201 Created` +| A new resource has been created successfully. The resource's URI is available from the response's +`Location` header + +| `204 No Content` +| An update to an existing resource has been applied successfully + +| `400 Bad Request` +| The request was malformed. The response body will include an error providing further information + +| `404 Not Found` +| The requested resource did not exist +|=== + +[[overview_errors]] +== Errors + +Whenever an error response (status code >= 400) is returned, the body will contain a JSON object +that describes the problem. The error object has the following structure: + +include::{snippets}/error-example/response-fields.adoc[] + +For example, a request that attempts to apply a non-existent tag to a note will produce a +`400 Bad Request` response: + +include::{snippets}/error-example/http-response.adoc[] + +[[overview_hypermedia]] +== Hypermedia + +RESTful Notes uses hypermedia and resources include links to other resources in their +responses. Responses are in https://github.com/mikekelly/hal_specification[Hypertext +Application Language (HAL)] format. Links can be found beneath the `_links` key. Users of +the API should not create URIs themselves, instead they should use the above-described +links to navigate from resource to resource. + +[[resources]] += Resources + + + +[[resources_index]] +== Index + +The index provides the entry point into the service. + + + +[[resources_index_access]] +=== Accessing the index + +A `GET` request is used to access the index + +operation::index-example[snippets='response-fields,http-response,links'] + + + +[[resources_notes]] +== Notes + +The Notes resources is used to create and list notes + + + +[[resources_notes_list]] +=== Listing notes + +A `GET` request will list all of the service's notes. + +operation::notes-list-example[snippets='response-fields,curl-request,http-response,links'] + + + +[[resources_notes_create]] +=== Creating a note + +A `POST` request is used to create a note. + +operation::notes-create-example[snippets='request-fields,curl-request,http-response'] + + + +[[resources_tags]] +== Tags + +The Tags resource is used to create and list tags. + + + +[[resources_tags_list]] +=== Listing tags + +A `GET` request will list all of the service's tags. + +operation::tags-list-example[snippets='response-fields,curl-request,http-response,links'] + + + +[[resources_tags_create]] +=== Creating a tag + +A `POST` request is used to create a note + +operation::tags-create-example[snippets='request-fields,curl-request,http-response'] + + + +[[resources_note]] +== Note + +The Note resource is used to retrieve, update, and delete individual notes + + + +[[resources_note_links]] +=== Links + +include::{snippets}/note-get-example/links.adoc[] + + + +[[resources_note_retrieve]] +=== Retrieve a note + +A `GET` request will retrieve the details of a note + +operation::note-get-example[snippets='response-fields,curl-request,http-response'] + + + +[[resources_note_update]] +=== Update a note + +A `PATCH` request is used to update a note + +==== Request structure + +include::{snippets}/note-update-example/request-fields.adoc[] + +To leave an attribute of a note unchanged, any of the above may be omitted from the request. + +==== Example request + +include::{snippets}/note-update-example/curl-request.adoc[] + +==== Example response + +include::{snippets}/note-update-example/http-response.adoc[] + + + +[[resources_tag]] +== Tag + +The Tag resource is used to retrieve, update, and delete individual tags + + + +[[resources_tag_links]] +=== Links + +include::{snippets}/tag-get-example/links.adoc[] + + + +[[resources_tag_retrieve]] +=== Retrieve a tag + +A `GET` request will retrieve the details of a tag + +operation::tag-get-example[snippets='response-fields,curl-request,http-response'] + + + +[[resources_tag_update]] +=== Update a tag + +A `PATCH` request is used to update a tag + +operation::tag-update-example[snippets='request-fields,curl-request,http-response'] diff --git a/modules/jooby-openapi/src/test/resources/docs/getting-started-guide.adoc b/modules/jooby-openapi/src/test/resources/docs/getting-started-guide.adoc new file mode 100644 index 0000000000..c1af376c76 --- /dev/null +++ b/modules/jooby-openapi/src/test/resources/docs/getting-started-guide.adoc @@ -0,0 +1,175 @@ += RESTful Notes Getting Started Guide +Andy Wilkinson; +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 4 +:sectlinks: + +[[introduction]] += Introduction + +RESTful Notes is a RESTful web service for creating and storing notes. It uses hypermedia +to describe the relationships between resources and to allow navigation between them. + + + +[[getting_started_running_the_service]] +== Running the service +RESTful Notes is written using https://projects.spring.io/spring-boot[Spring Boot] which +makes it easy to get it up and running so that you can start exploring the REST API. + +The first step is to clone the Git repository: + +[source,bash] +---- +$ git clone https://github.com/spring-projects/spring-restdocs-samples +---- + +Once the clone is complete, you're ready to get the service up and running: + +[source,bash] +---- +$ cd restful-notes-spring-data-rest +$ ./mvnw clean package +$ java -jar target/*.jar +---- + +You can check that the service is up and running by executing a simple request using +cURL: + +include::{snippets}/index/1/curl-request.adoc[] + +This request should yield the following response in the +https://github.com/mikekelly/hal_specification[Hypertext Application Language (HAL)] +format: + +include::{snippets}/index/1/http-response.adoc[] + +Note the `_links` in the JSON response. They are key to navigating the API. + + + +[[getting_started_creating_a_note]] +== Creating a note +Now that you've started the service and verified that it works, the next step is to use +it to create a new note. As you saw above, the URI for working with notes is included as +a link when you perform a `GET` request against the root of the service: + +include::{snippets}/index/1/http-response.adoc[] + +To create a note, you need to execute a `POST` request to this URI including a JSON +payload containing the title and body of the note: + +include::{snippets}/creating-a-note/1/curl-request.adoc[] + +The response from this request should have a status code of `201 Created` and contain a +`Location` header whose value is the URI of the newly created note: + +include::{snippets}/creating-a-note/1/http-response.adoc[] + +To work with the newly created note you use the URI in the `Location` header. For example, +you can access the note's details by performing a `GET` request: + +include::{snippets}/creating-a-note/2/curl-request.adoc[] + +This request will produce a response with the note's details in its body: + +include::{snippets}/creating-a-note/2/http-response.adoc[] + +Note the `tags` link which we'll make use of later. + + + +[[getting_started_creating_a_tag]] +== Creating a tag +To make a note easier to find, it can be associated with any number of tags. To be able +to tag a note, you must first create the tag. + +Referring back to the response for the service's index, the URI for working with tags is +include as a link: + +include::{snippets}/index/1/http-response.adoc[] + +To create a tag you need to execute a `POST` request to this URI, including a JSON +payload containing the name of the tag: + +include::{snippets}/creating-a-note/3/curl-request.adoc[] + +The response from this request should have a status code of `201 Created` and contain a +`Location` header whose value is the URI of the newly created tag: + +include::{snippets}/creating-a-note/3/http-response.adoc[] + +To work with the newly created tag you use the URI in the `Location` header. For example +you can access the tag's details by performing a `GET` request: + +include::{snippets}/creating-a-note/4/curl-request.adoc[] + +This request will produce a response with the tag's details in its body: + +include::{snippets}/creating-a-note/4/http-response.adoc[] + + + +[[getting_started_tagging_a_note]] +== Tagging a note +A tag isn't particularly useful until it's been associated with one or more notes. There +are two ways to tag a note: when the note is first created or by updating an existing +note. We'll look at both of these in turn. + + + +[[getting_started_tagging_a_note_creating]] +=== Creating a tagged note +The process is largely the same as we saw before, but this time, in addition to providing +a title and body for the note, we'll also provide the tag that we want to be associated +with it. + +Once again we execute a `POST` request. However, this time, in an array named tags, we +include the URI of the tag we just created: + +include::{snippets}/creating-a-note/5/curl-request.adoc[] + +Once again, the response's `Location` header tells us the URI of the newly created note: + +include::{snippets}/creating-a-note/5/http-response.adoc[] + +As before, a `GET` request executed against this URI will retrieve the note's details: + +include::{snippets}/creating-a-note/6/curl-request.adoc[] +include::{snippets}/creating-a-note/6/http-response.adoc[] + +To verify that the tag has been associated with the note, we can perform a `GET` request +against the URI from the `tags` link: + +include::{snippets}/creating-a-note/7/curl-request.adoc[] + +The response embeds information about the tag that we've just associated with the note: + +include::{snippets}/creating-a-note/7/http-response.adoc[] + + + +[[getting_started_tagging_a_note_existing]] +=== Tagging an existing note +An existing note can be tagged by executing a `PATCH` request against the note's URI with +a body that contains the array of tags to be associated with the note. We'll used the +URI of the untagged note that we created earlier: + +include::{snippets}/creating-a-note/8/curl-request.adoc[] + +This request should produce a `204 No Content` response: + +include::{snippets}/creating-a-note/8/http-response.adoc[] + +When we first created this note, we noted the tags link included in its details: + +include::{snippets}/creating-a-note/2/http-response.adoc[] + +We can use that link now and execute a `GET` request to see that the note now has a +single tag: + +include::{snippets}/creating-a-note/9/curl-request.adoc[] +include::{snippets}/creating-a-note/9/http-response.adoc[] From f5b0a66265104bad0ea6ee55fb5b92326168de04 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Wed, 26 Nov 2025 10:19:48 -0300 Subject: [PATCH 04/31] prepare for next development cycle --- jooby/pom.xml | 2 +- modules/jooby-apt/pom.xml | 2 +- modules/jooby-avaje-inject/pom.xml | 2 +- modules/jooby-avaje-jsonb/pom.xml | 2 +- modules/jooby-avaje-validator/pom.xml | 2 +- modules/jooby-awssdk-v1/pom.xml | 2 +- modules/jooby-awssdk-v2/pom.xml | 2 +- modules/jooby-bom/pom.xml | 4 ++-- modules/jooby-caffeine/pom.xml | 2 +- modules/jooby-camel/pom.xml | 2 +- modules/jooby-cli/pom.xml | 2 +- modules/jooby-commons-email/pom.xml | 2 +- modules/jooby-conscrypt/pom.xml | 2 +- modules/jooby-db-scheduler/pom.xml | 2 +- modules/jooby-distribution/pom.xml | 2 +- modules/jooby-ebean/pom.xml | 2 +- modules/jooby-flyway/pom.xml | 2 +- modules/jooby-freemarker/pom.xml | 2 +- modules/jooby-gradle-setup/pom.xml | 2 +- modules/jooby-graphiql/pom.xml | 2 +- modules/jooby-graphql/pom.xml | 2 +- modules/jooby-gson/pom.xml | 2 +- modules/jooby-guice/pom.xml | 2 +- modules/jooby-handlebars/pom.xml | 2 +- modules/jooby-hibernate-validator/pom.xml | 2 +- modules/jooby-hibernate/pom.xml | 2 +- modules/jooby-hikari/pom.xml | 2 +- modules/jooby-jackson/pom.xml | 2 +- modules/jooby-jasypt/pom.xml | 2 +- modules/jooby-jdbi/pom.xml | 2 +- modules/jooby-jetty/pom.xml | 2 +- modules/jooby-jstachio/pom.xml | 2 +- modules/jooby-jte/pom.xml | 2 +- modules/jooby-jwt/pom.xml | 2 +- modules/jooby-kafka/pom.xml | 2 +- modules/jooby-kotlin/pom.xml | 2 +- modules/jooby-log4j/pom.xml | 2 +- modules/jooby-logback/pom.xml | 2 +- modules/jooby-maven-plugin/pom.xml | 2 +- modules/jooby-metrics/pom.xml | 2 +- modules/jooby-mutiny/pom.xml | 2 +- modules/jooby-netty/pom.xml | 2 +- modules/jooby-openapi/pom.xml | 2 +- modules/jooby-pac4j/pom.xml | 2 +- modules/jooby-pebble/pom.xml | 2 +- modules/jooby-quartz/pom.xml | 2 +- modules/jooby-reactor/pom.xml | 2 +- modules/jooby-redis/pom.xml | 2 +- modules/jooby-redoc/pom.xml | 2 +- modules/jooby-rocker/pom.xml | 2 +- modules/jooby-run/pom.xml | 2 +- modules/jooby-rxjava3/pom.xml | 2 +- modules/jooby-stork/pom.xml | 2 +- modules/jooby-swagger-ui/pom.xml | 2 +- modules/jooby-test/pom.xml | 2 +- modules/jooby-thymeleaf/pom.xml | 2 +- modules/jooby-undertow/pom.xml | 2 +- modules/jooby-vertx-mysql-client/pom.xml | 2 +- modules/jooby-vertx-pg-client/pom.xml | 2 +- modules/jooby-vertx-sql-client/pom.xml | 2 +- modules/jooby-vertx/pom.xml | 2 +- modules/jooby-whoops/pom.xml | 2 +- modules/jooby-yasson/pom.xml | 2 +- modules/pom.xml | 2 +- pom.xml | 4 ++-- tests/pom.xml | 2 +- 66 files changed, 68 insertions(+), 68 deletions(-) diff --git a/jooby/pom.xml b/jooby/pom.xml index fcaa4fab30..424eca7e39 100644 --- a/jooby/pom.xml +++ b/jooby/pom.xml @@ -6,7 +6,7 @@ io.jooby jooby-project - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby jooby diff --git a/modules/jooby-apt/pom.xml b/modules/jooby-apt/pom.xml index 198493dc04..32ee21294f 100644 --- a/modules/jooby-apt/pom.xml +++ b/modules/jooby-apt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-apt jooby-apt diff --git a/modules/jooby-avaje-inject/pom.xml b/modules/jooby-avaje-inject/pom.xml index dee1e09015..73dfa8be4a 100644 --- a/modules/jooby-avaje-inject/pom.xml +++ b/modules/jooby-avaje-inject/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-avaje-inject jooby-avaje-inject diff --git a/modules/jooby-avaje-jsonb/pom.xml b/modules/jooby-avaje-jsonb/pom.xml index d8e8fba7c0..37a365331e 100644 --- a/modules/jooby-avaje-jsonb/pom.xml +++ b/modules/jooby-avaje-jsonb/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-avaje-jsonb jooby-avaje-jsonb diff --git a/modules/jooby-avaje-validator/pom.xml b/modules/jooby-avaje-validator/pom.xml index c88fe7d1de..a02eed303e 100644 --- a/modules/jooby-avaje-validator/pom.xml +++ b/modules/jooby-avaje-validator/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-avaje-validator jooby-avaje-validator diff --git a/modules/jooby-awssdk-v1/pom.xml b/modules/jooby-awssdk-v1/pom.xml index 0cf6a31014..c0211f5c49 100644 --- a/modules/jooby-awssdk-v1/pom.xml +++ b/modules/jooby-awssdk-v1/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-awssdk-v1 jooby-awssdk-v1 diff --git a/modules/jooby-awssdk-v2/pom.xml b/modules/jooby-awssdk-v2/pom.xml index a819f6a9ad..44879c1e4f 100644 --- a/modules/jooby-awssdk-v2/pom.xml +++ b/modules/jooby-awssdk-v2/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-awssdk-v2 jooby-awssdk-v2 diff --git a/modules/jooby-bom/pom.xml b/modules/jooby-bom/pom.xml index fce2ece48c..0937de6b44 100644 --- a/modules/jooby-bom/pom.xml +++ b/modules/jooby-bom/pom.xml @@ -7,14 +7,14 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT io.jooby jooby-bom jooby-bom pom - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT Jooby (Bill of Materials) https://jooby.io diff --git a/modules/jooby-caffeine/pom.xml b/modules/jooby-caffeine/pom.xml index 091683c998..88e09da439 100644 --- a/modules/jooby-caffeine/pom.xml +++ b/modules/jooby-caffeine/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-caffeine jooby-caffeine diff --git a/modules/jooby-camel/pom.xml b/modules/jooby-camel/pom.xml index dd19494128..815bc8a3ea 100644 --- a/modules/jooby-camel/pom.xml +++ b/modules/jooby-camel/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-camel jooby-camel diff --git a/modules/jooby-cli/pom.xml b/modules/jooby-cli/pom.xml index 17251648f5..9d5d477d03 100644 --- a/modules/jooby-cli/pom.xml +++ b/modules/jooby-cli/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-cli jooby-cli diff --git a/modules/jooby-commons-email/pom.xml b/modules/jooby-commons-email/pom.xml index 3bc7d000e1..f40d993059 100644 --- a/modules/jooby-commons-email/pom.xml +++ b/modules/jooby-commons-email/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-commons-email jooby-commons-email diff --git a/modules/jooby-conscrypt/pom.xml b/modules/jooby-conscrypt/pom.xml index c263bed1bc..895c17033d 100644 --- a/modules/jooby-conscrypt/pom.xml +++ b/modules/jooby-conscrypt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-conscrypt jooby-conscrypt diff --git a/modules/jooby-db-scheduler/pom.xml b/modules/jooby-db-scheduler/pom.xml index 3a85a83200..e5c04015e8 100644 --- a/modules/jooby-db-scheduler/pom.xml +++ b/modules/jooby-db-scheduler/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-db-scheduler jooby-db-scheduler diff --git a/modules/jooby-distribution/pom.xml b/modules/jooby-distribution/pom.xml index a6af7e9d6c..0b8f5d13f4 100644 --- a/modules/jooby-distribution/pom.xml +++ b/modules/jooby-distribution/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-distribution jooby-distribution diff --git a/modules/jooby-ebean/pom.xml b/modules/jooby-ebean/pom.xml index 433f9826ef..c9fbb8c74e 100644 --- a/modules/jooby-ebean/pom.xml +++ b/modules/jooby-ebean/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-ebean jooby-ebean diff --git a/modules/jooby-flyway/pom.xml b/modules/jooby-flyway/pom.xml index 62b5ecf644..32097e0270 100644 --- a/modules/jooby-flyway/pom.xml +++ b/modules/jooby-flyway/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-flyway jooby-flyway diff --git a/modules/jooby-freemarker/pom.xml b/modules/jooby-freemarker/pom.xml index e9ebe611b9..cd9369b01c 100644 --- a/modules/jooby-freemarker/pom.xml +++ b/modules/jooby-freemarker/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-freemarker jooby-freemarker diff --git a/modules/jooby-gradle-setup/pom.xml b/modules/jooby-gradle-setup/pom.xml index 84e846db15..e796c048dd 100644 --- a/modules/jooby-gradle-setup/pom.xml +++ b/modules/jooby-gradle-setup/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-gradle-setup jooby-gradle-setup diff --git a/modules/jooby-graphiql/pom.xml b/modules/jooby-graphiql/pom.xml index 12480dbbf5..415c7739c5 100644 --- a/modules/jooby-graphiql/pom.xml +++ b/modules/jooby-graphiql/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-graphiql jooby-graphiql diff --git a/modules/jooby-graphql/pom.xml b/modules/jooby-graphql/pom.xml index e3f1c22ae3..96121bbb98 100644 --- a/modules/jooby-graphql/pom.xml +++ b/modules/jooby-graphql/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-graphql jooby-graphql diff --git a/modules/jooby-gson/pom.xml b/modules/jooby-gson/pom.xml index 9f04d8fb55..b0f586b23b 100644 --- a/modules/jooby-gson/pom.xml +++ b/modules/jooby-gson/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-gson jooby-gson diff --git a/modules/jooby-guice/pom.xml b/modules/jooby-guice/pom.xml index d20ae4f894..fb54623494 100644 --- a/modules/jooby-guice/pom.xml +++ b/modules/jooby-guice/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-guice jooby-guice diff --git a/modules/jooby-handlebars/pom.xml b/modules/jooby-handlebars/pom.xml index 31f87c6cbb..30b4ffb841 100644 --- a/modules/jooby-handlebars/pom.xml +++ b/modules/jooby-handlebars/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-handlebars jooby-handlebars diff --git a/modules/jooby-hibernate-validator/pom.xml b/modules/jooby-hibernate-validator/pom.xml index e50499d6ff..75f1e198ec 100644 --- a/modules/jooby-hibernate-validator/pom.xml +++ b/modules/jooby-hibernate-validator/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-hibernate-validator jooby-hibernate-validator diff --git a/modules/jooby-hibernate/pom.xml b/modules/jooby-hibernate/pom.xml index b60c0e2b9c..2c157021cd 100644 --- a/modules/jooby-hibernate/pom.xml +++ b/modules/jooby-hibernate/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-hibernate jooby-hibernate diff --git a/modules/jooby-hikari/pom.xml b/modules/jooby-hikari/pom.xml index a8441a2fe8..9387eaa182 100644 --- a/modules/jooby-hikari/pom.xml +++ b/modules/jooby-hikari/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-hikari jooby-hikari diff --git a/modules/jooby-jackson/pom.xml b/modules/jooby-jackson/pom.xml index 60bcd147de..a780eeedc7 100644 --- a/modules/jooby-jackson/pom.xml +++ b/modules/jooby-jackson/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-jackson jooby-jackson diff --git a/modules/jooby-jasypt/pom.xml b/modules/jooby-jasypt/pom.xml index 3ec7bf40f7..0903d8de60 100644 --- a/modules/jooby-jasypt/pom.xml +++ b/modules/jooby-jasypt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-jasypt jooby-jasypt diff --git a/modules/jooby-jdbi/pom.xml b/modules/jooby-jdbi/pom.xml index b7e78b6012..ff4cc752bd 100644 --- a/modules/jooby-jdbi/pom.xml +++ b/modules/jooby-jdbi/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-jdbi jooby-jdbi diff --git a/modules/jooby-jetty/pom.xml b/modules/jooby-jetty/pom.xml index fd0fbec848..c7a66a43c8 100644 --- a/modules/jooby-jetty/pom.xml +++ b/modules/jooby-jetty/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-jetty jooby-jetty diff --git a/modules/jooby-jstachio/pom.xml b/modules/jooby-jstachio/pom.xml index 9ba8bf9668..14a3c27fa9 100644 --- a/modules/jooby-jstachio/pom.xml +++ b/modules/jooby-jstachio/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-jstachio jooby-jstachio diff --git a/modules/jooby-jte/pom.xml b/modules/jooby-jte/pom.xml index 111e4e6309..a794fbb846 100644 --- a/modules/jooby-jte/pom.xml +++ b/modules/jooby-jte/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-jte jooby-jte diff --git a/modules/jooby-jwt/pom.xml b/modules/jooby-jwt/pom.xml index 1db46e857a..23baf4a67d 100644 --- a/modules/jooby-jwt/pom.xml +++ b/modules/jooby-jwt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-jwt jooby-jwt diff --git a/modules/jooby-kafka/pom.xml b/modules/jooby-kafka/pom.xml index 1bb0c5fde0..0203249609 100644 --- a/modules/jooby-kafka/pom.xml +++ b/modules/jooby-kafka/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-kafka jooby-kafka diff --git a/modules/jooby-kotlin/pom.xml b/modules/jooby-kotlin/pom.xml index 926506d987..32efb7d334 100644 --- a/modules/jooby-kotlin/pom.xml +++ b/modules/jooby-kotlin/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-kotlin jooby-kotlin diff --git a/modules/jooby-log4j/pom.xml b/modules/jooby-log4j/pom.xml index e309758816..ed4d2c42c6 100644 --- a/modules/jooby-log4j/pom.xml +++ b/modules/jooby-log4j/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-log4j jooby-log4j diff --git a/modules/jooby-logback/pom.xml b/modules/jooby-logback/pom.xml index b759ddb808..12397ed3db 100644 --- a/modules/jooby-logback/pom.xml +++ b/modules/jooby-logback/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-logback jooby-logback diff --git a/modules/jooby-maven-plugin/pom.xml b/modules/jooby-maven-plugin/pom.xml index b06c863d6d..2cb9798d81 100644 --- a/modules/jooby-maven-plugin/pom.xml +++ b/modules/jooby-maven-plugin/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-maven-plugin jooby-maven-plugin diff --git a/modules/jooby-metrics/pom.xml b/modules/jooby-metrics/pom.xml index 0d934b681f..0474ccd62c 100644 --- a/modules/jooby-metrics/pom.xml +++ b/modules/jooby-metrics/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-metrics jooby-metrics diff --git a/modules/jooby-mutiny/pom.xml b/modules/jooby-mutiny/pom.xml index 87973814b7..583f26c5df 100644 --- a/modules/jooby-mutiny/pom.xml +++ b/modules/jooby-mutiny/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-mutiny jooby-mutiny diff --git a/modules/jooby-netty/pom.xml b/modules/jooby-netty/pom.xml index d4de9eb6bb..cfa0a5f334 100644 --- a/modules/jooby-netty/pom.xml +++ b/modules/jooby-netty/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-netty jooby-netty diff --git a/modules/jooby-openapi/pom.xml b/modules/jooby-openapi/pom.xml index 519313da9a..3cd0fbdb24 100644 --- a/modules/jooby-openapi/pom.xml +++ b/modules/jooby-openapi/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-openapi jooby-openapi diff --git a/modules/jooby-pac4j/pom.xml b/modules/jooby-pac4j/pom.xml index 7414fbc3ee..01f361e151 100644 --- a/modules/jooby-pac4j/pom.xml +++ b/modules/jooby-pac4j/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-pac4j jooby-pac4j diff --git a/modules/jooby-pebble/pom.xml b/modules/jooby-pebble/pom.xml index 3b03d868f5..50e8fec3fb 100644 --- a/modules/jooby-pebble/pom.xml +++ b/modules/jooby-pebble/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-pebble jooby-pebble diff --git a/modules/jooby-quartz/pom.xml b/modules/jooby-quartz/pom.xml index 998cb99c3c..4da9a37f53 100644 --- a/modules/jooby-quartz/pom.xml +++ b/modules/jooby-quartz/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-quartz jooby-quartz diff --git a/modules/jooby-reactor/pom.xml b/modules/jooby-reactor/pom.xml index c4d15e2dbf..be79a3a238 100644 --- a/modules/jooby-reactor/pom.xml +++ b/modules/jooby-reactor/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-reactor jooby-reactor diff --git a/modules/jooby-redis/pom.xml b/modules/jooby-redis/pom.xml index 7c81a3d278..51d2011202 100644 --- a/modules/jooby-redis/pom.xml +++ b/modules/jooby-redis/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-redis jooby-redis diff --git a/modules/jooby-redoc/pom.xml b/modules/jooby-redoc/pom.xml index be2c90a870..094ae17dc2 100644 --- a/modules/jooby-redoc/pom.xml +++ b/modules/jooby-redoc/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-redoc jooby-redoc diff --git a/modules/jooby-rocker/pom.xml b/modules/jooby-rocker/pom.xml index 766e860c4a..0c9e1c3718 100644 --- a/modules/jooby-rocker/pom.xml +++ b/modules/jooby-rocker/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-rocker jooby-rocker diff --git a/modules/jooby-run/pom.xml b/modules/jooby-run/pom.xml index 9ebbda06da..03420649c7 100644 --- a/modules/jooby-run/pom.xml +++ b/modules/jooby-run/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-run jooby-run diff --git a/modules/jooby-rxjava3/pom.xml b/modules/jooby-rxjava3/pom.xml index 53871a68ad..94e018d5eb 100644 --- a/modules/jooby-rxjava3/pom.xml +++ b/modules/jooby-rxjava3/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-rxjava3 jooby-rxjava3 diff --git a/modules/jooby-stork/pom.xml b/modules/jooby-stork/pom.xml index cc2330271f..f1b9a5b7b9 100644 --- a/modules/jooby-stork/pom.xml +++ b/modules/jooby-stork/pom.xml @@ -4,7 +4,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-stork diff --git a/modules/jooby-swagger-ui/pom.xml b/modules/jooby-swagger-ui/pom.xml index f254b42e49..49abea4b73 100644 --- a/modules/jooby-swagger-ui/pom.xml +++ b/modules/jooby-swagger-ui/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-swagger-ui jooby-swagger-ui diff --git a/modules/jooby-test/pom.xml b/modules/jooby-test/pom.xml index b8f5d4e9c7..368f743642 100644 --- a/modules/jooby-test/pom.xml +++ b/modules/jooby-test/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-test jooby-test diff --git a/modules/jooby-thymeleaf/pom.xml b/modules/jooby-thymeleaf/pom.xml index 8904d4182a..cb160fe460 100644 --- a/modules/jooby-thymeleaf/pom.xml +++ b/modules/jooby-thymeleaf/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-thymeleaf jooby-thymeleaf diff --git a/modules/jooby-undertow/pom.xml b/modules/jooby-undertow/pom.xml index 5d4e6089f0..ffb82911e9 100644 --- a/modules/jooby-undertow/pom.xml +++ b/modules/jooby-undertow/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-undertow jooby-undertow diff --git a/modules/jooby-vertx-mysql-client/pom.xml b/modules/jooby-vertx-mysql-client/pom.xml index cef4f9027b..f94ec98050 100644 --- a/modules/jooby-vertx-mysql-client/pom.xml +++ b/modules/jooby-vertx-mysql-client/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-vertx-mysql-client jooby-vertx-mysql-client diff --git a/modules/jooby-vertx-pg-client/pom.xml b/modules/jooby-vertx-pg-client/pom.xml index d299f96c0c..23c4957834 100644 --- a/modules/jooby-vertx-pg-client/pom.xml +++ b/modules/jooby-vertx-pg-client/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-vertx-pg-client jooby-vertx-pg-client diff --git a/modules/jooby-vertx-sql-client/pom.xml b/modules/jooby-vertx-sql-client/pom.xml index 576c1be170..2c35774643 100644 --- a/modules/jooby-vertx-sql-client/pom.xml +++ b/modules/jooby-vertx-sql-client/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-vertx-sql-client jooby-vertx-sql-client diff --git a/modules/jooby-vertx/pom.xml b/modules/jooby-vertx/pom.xml index 641b56bb7e..b98a7ed507 100644 --- a/modules/jooby-vertx/pom.xml +++ b/modules/jooby-vertx/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-vertx jooby-vertx diff --git a/modules/jooby-whoops/pom.xml b/modules/jooby-whoops/pom.xml index 4950ee8a49..2d49951015 100644 --- a/modules/jooby-whoops/pom.xml +++ b/modules/jooby-whoops/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-whoops jooby-whoops diff --git a/modules/jooby-yasson/pom.xml b/modules/jooby-yasson/pom.xml index 61af4f37df..894b7ad878 100644 --- a/modules/jooby-yasson/pom.xml +++ b/modules/jooby-yasson/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT jooby-yasson jooby-yasson diff --git a/modules/pom.xml b/modules/pom.xml index 13bc761275..d40ce5473a 100644 --- a/modules/pom.xml +++ b/modules/pom.xml @@ -4,7 +4,7 @@ io.jooby jooby-project - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT modules diff --git a/pom.xml b/pom.xml index 9d8c06beb3..bf1143a559 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.jooby jooby-project - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT pom jooby-project @@ -210,7 +210,7 @@ 21 21 yyyy-MM-dd HH:mm:ssa - 2025-11-04T01:02:57Z + 2025-11-26T13:19:32Z UTF-8 etc${file.separator}source${file.separator}formatter.sh diff --git a/tests/pom.xml b/tests/pom.xml index ddcb385060..eee986f931 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -6,7 +6,7 @@ io.jooby jooby-project - 4.0.12-SNAPSHOT + 4.0.13-SNAPSHOT tests tests From bc0ac2e73e40693dc17fad7705c1d29c3be6a51c Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Wed, 26 Nov 2025 21:43:01 -0300 Subject: [PATCH 05/31] filter: curl - more advanced implementation - unit tests - ref #3820 --- modules/jooby-openapi/pom.xml | 10 + .../internal/openapi/AnnotationParser.java | 40 +++- .../internal/openapi/AsciiDocGenerator.java | 25 +- .../jooby/internal/openapi/OpenAPIParser.java | 2 +- .../internal/openapi/asciidoc/Filters.java | 177 +++++++++----- .../src/main/java/module-info.java | 3 +- .../templates/asciidoc/default-curl.snippet | 2 +- .../asciidoc/default-http-request.snippet | 12 +- .../internal/openapi/asciidoc/FilterTest.java | 160 +++++++++++++ .../java/io/jooby/openapi/CurrentDir.java | 22 ++ .../java/issues/i3729/api/ApiDocTest.java | 4 +- .../java/issues/i3729/api/LibraryApi.java | 5 +- .../java/issues/i3729/api/ScriptLibrary.java | 4 +- .../issues/i3729/museum/AsciiDocTest.java | 31 --- .../src/test/resources/adoc/guide.adoc | 217 ------------------ .../adoc/{museum.adoc => library.adoc} | 2 +- 16 files changed, 378 insertions(+), 338 deletions(-) create mode 100644 modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java create mode 100644 modules/jooby-openapi/src/test/java/io/jooby/openapi/CurrentDir.java delete mode 100644 modules/jooby-openapi/src/test/java/issues/i3729/museum/AsciiDocTest.java delete mode 100644 modules/jooby-openapi/src/test/resources/adoc/guide.adoc rename modules/jooby-openapi/src/test/resources/adoc/{museum.adoc => library.adoc} (78%) diff --git a/modules/jooby-openapi/pom.xml b/modules/jooby-openapi/pom.xml index c288dcbaa2..7bc2379033 100644 --- a/modules/jooby-openapi/pom.xml +++ b/modules/jooby-openapi/pom.xml @@ -73,6 +73,11 @@ swagger-parser + + com.google.guava + guava + + org.junit.jupiter @@ -139,6 +144,11 @@ 1.18.1 test + + org.mockito + mockito-core + test + diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AnnotationParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AnnotationParser.java index ae33cb8d56..349a47521d 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AnnotationParser.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AnnotationParser.java @@ -21,14 +21,7 @@ import org.objectweb.asm.tree.*; import io.jooby.*; -import io.jooby.annotation.ContextParam; -import io.jooby.annotation.CookieParam; -import io.jooby.annotation.FormParam; -import io.jooby.annotation.GET; -import io.jooby.annotation.HeaderParam; -import io.jooby.annotation.Path; -import io.jooby.annotation.PathParam; -import io.jooby.annotation.QueryParam; +import io.jooby.annotation.*; import io.swagger.v3.oas.models.media.Content; import io.swagger.v3.oas.models.media.ObjectSchema; import io.swagger.v3.oas.models.media.Schema; @@ -314,6 +307,7 @@ private static Map methods(ParserContext ctx, ClassNode node return methods; } + @SuppressWarnings("unchecked") private static List routerMethod( ParserContext ctx, String prefix, ClassNode classNode, MethodNode method) { @@ -330,6 +324,9 @@ private static List routerMethod( operation.setOperationId(method.name); Optional.ofNullable(requestBody.get()).ifPresent(operation::setRequestBody); + mediaType(classNode, method, produces(), operation::addProduces); + mediaType(classNode, method, consumes(), operation::addConsumes); + result.add(operation); } } @@ -337,6 +334,25 @@ private static List routerMethod( return result; } + @SuppressWarnings("unchecked") + public static void mediaType( + ClassNode classNode, MethodNode method, List types, Consumer consumer) { + mediaType(classNode, method, types).stream() + .map(AsmUtils::toMap) + .map(it -> it.get("value")) + .filter(Objects::nonNull) + .map(List.class::cast) + .flatMap(List::stream) + .distinct() + .forEach(it -> consumer.accept(it.toString())); + } + + public static List mediaType( + ClassNode classNode, MethodNode method, List types) { + var result = findAnnotationByType(method.visibleAnnotations, types); + return result.isEmpty() ? findAnnotationByType(classNode.visibleAnnotations, types) : result; + } + private static ResponseExt returnTypes(MethodNode method) { Signature signature = Signature.create(method); String desc = Optional.ofNullable(method.signature).orElse(method.desc); @@ -604,6 +620,14 @@ private static List httpMethods() { return annotationTypes; } + private static List produces() { + return List.of(Produces.class.getName(), jakarta.ws.rs.Produces.class.getName()); + } + + private static List consumes() { + return List.of(Consumes.class.getName(), jakarta.ws.rs.Consumes.class.getName()); + } + private static List httpMethod(String pkg, Class pathType) { List annotationTypes = Router.METHODS.stream().map(m -> pkg + "." + m).collect(Collectors.toList()); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AsciiDocGenerator.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AsciiDocGenerator.java index 0ea4e999a8..1c34429e85 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AsciiDocGenerator.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AsciiDocGenerator.java @@ -26,14 +26,7 @@ public class AsciiDocGenerator { public static String generate(OpenAPIExt openAPI, Path index) throws IOException { - var snippetResolver = new SnippetResolver(index.getParent().resolve("snippet")); - var engine = - newEngine( - new OpenApiSupport(Map.of("openapi", openAPI, "snippetResolver", snippetResolver)), - "${", - "}"); - snippetResolver.setEngine(engine); - + var engine = newEngine(openAPI, index.getParent()); var template = engine.getTemplate(index.toAbsolutePath().toString()); var writer = new StringWriter(); var context = new HashMap(); @@ -41,15 +34,25 @@ public static String generate(OpenAPIExt openAPI, Path index) throws IOException return writer.toString(); } - private static PebbleEngine newEngine(OpenApiSupport extension, String start, String end) { + private static PebbleEngine newEngine(OpenAPIExt openAPI, Path baseDir) { + var snippetResolver = new SnippetResolver(baseDir.resolve("snippet")); + var engine = + newEngine( + new OpenApiSupport(Map.of("openapi", openAPI, "snippetResolver", snippetResolver))); + snippetResolver.setEngine(engine); + return newEngine( + new OpenApiSupport(Map.of("openapi", openAPI, "snippetResolver", snippetResolver))); + } + + private static PebbleEngine newEngine(OpenApiSupport extension) { // 1. Define the custom syntax using a builder return new PebbleEngine.Builder() .extension(extension) .autoEscaping(false) .syntax( new Syntax.Builder() - .setPrintOpenDelimiter(start) - .setPrintCloseDelimiter(end) + .setPrintOpenDelimiter("${") + .setPrintCloseDelimiter("}") .setEnableNewLineTrimming(false) .build()) .build(); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIParser.java index 5eed7d7b73..58dc070599 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIParser.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIParser.java @@ -604,7 +604,7 @@ private static void operationResponse( String name = stringValue(value, "name"); stringValue(value, "description", header::setDescription); - io.swagger.v3.oas.models.media.Schema schema = + var schema = annotationValue(value, "schema") .map(schemaMap -> toSchema(ctx, schemaMap).orElseGet(StringSchema::new)) .orElseGet(StringSchema::new); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Filters.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Filters.java index ea89c6dd65..c012f0cbce 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Filters.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Filters.java @@ -7,6 +7,9 @@ import java.util.*; +import com.google.common.base.Splitter; +import com.google.common.collect.*; +import edu.umd.cs.findbugs.annotations.NonNull; import io.jooby.internal.openapi.OperationExt; import io.pebbletemplates.pebble.error.PebbleException; import io.pebbletemplates.pebble.extension.Filter; @@ -16,6 +19,9 @@ public enum Filters implements Filter { curl { + private static final CharSequence Accept = new HeaderName("Accept"); + private static final CharSequence ContentType = new HeaderName("Content-Type"); + @Override public Object apply( Object input, @@ -30,41 +36,36 @@ public Object apply( "Argument must be " + Operation.class.getName() + ". Got: " + input); } var snippetResolver = (SnippetResolver) context.getVariable("snippetResolver"); - - var options = new LinkedHashMap>(); - options.put("-X", Set.of(null)); - operation - .getProduces() - .forEach( - produces -> { - options - .computeIfAbsent("-H", (key) -> new LinkedHashSet<>()) - .add("'Accept: " + produces + "'"); - }); - - // Convert to map so can override any generated option - var optionList = new ArrayList<>(args.values()); - for (int i = 0; i < optionList.size(); ) { - var key = optionList.get(i).toString(); - String value = null; - if (i + 1 < optionList.size()) { - var next = optionList.get(i + 1); - if (next.toString().startsWith("-")) { - i += 1; - } else { - value = next.toString(); - i += 2; - } - } else { - i += 1; - } - var values = options.computeIfAbsent(key, k -> new LinkedHashSet<>()); - if (value != null) { - values.add(value); - } + var options = args(args); + var method = + Optional.of(options.removeAll("-X")) + .map(Collection::iterator) + .filter(Iterator::hasNext) + .map(Iterator::next) + .orElse(operation.getMethod()) + .toUpperCase(); + /* Accept/Content-Type: */ + var addAccept = true; + var addContentType = true; + if (options.containsKey("-H")) { + var headers = parseHeaders(options.get("-H")); + addAccept = !headers.containsKey(Accept); + addContentType = !headers.containsKey(ContentType); + } + if (addAccept) { + operation.getProduces().forEach(value -> options.put("-H", "'Accept: " + value + "'")); + } + if (addContentType && !READ_METHODS.contains(method)) { + operation + .getConsumes() + .forEach(value -> options.put("-H", "'Content-Type: " + value + "'")); + } + /* Method */ + if (!options.containsKey("-X")) { + options.put("-X", method); } return snippetResolver.apply( - "curl", Map.of("url", operation.getPattern(), "options", toString(options))); + name(), Map.of("url", operation.getPattern(), "options", toString(options))); } catch (PebbleException pebbleException) { throw pebbleException; } catch (Exception exception) { @@ -72,33 +73,68 @@ public Object apply( } } - private String toString(Map> options) { - if (options.isEmpty()) { - return ""; - } - var sb = new StringBuilder(); - var separator = " "; - for (var e : options.entrySet()) { - var values = e.getValue(); - if (values.isEmpty()) { - sb.append(e.getKey()).append(separator); - } else { - for (var value : e.getValue()) { - sb.append(e.getKey()).append(separator); - sb.append(value).append(separator); - } - } - } - sb.deleteCharAt(sb.length() - separator.length()); - return sb.toString(); - } - @Override public List getArgumentNames() { return null; } }; + protected Multimap parseHeaders(Collection headers) { + Multimap result = LinkedHashMultimap.create(); + for (var line : headers) { + if (line.startsWith("'") && line.endsWith("'")) { + line = line.substring(1, line.length() - 1); + } + var header = Splitter.on(':').trimResults().omitEmptyStrings().splitToList(line); + if (header.size() != 2) { + throw new IllegalArgumentException("Invalid header: " + line); + } + result.put(new HeaderName(header.get(0)), header.get(1)); + } + return result; + } + + protected static final Set READ_METHODS = Set.of("GET", "HEAD"); + + protected String toString(Multimap options) { + if (options.isEmpty()) { + return ""; + } + var sb = new StringBuilder(); + var separator = " "; + options.forEach( + (k, v) -> { + sb.append(k).append(separator); + if (v != null && !v.isEmpty()) { + sb.append(v).append(separator); + } + }); + sb.deleteCharAt(sb.length() - separator.length()); + return sb.toString(); + } + + protected Multimap args(Map args) { + Multimap result = LinkedHashMultimap.create(); + var optionList = new ArrayList<>(args.values()); + for (int i = 0; i < optionList.size(); ) { + var key = optionList.get(i).toString(); + String value = null; + if (i + 1 < optionList.size()) { + var next = optionList.get(i + 1); + if (next.toString().startsWith("-")) { + i += 1; + } else { + value = next.toString(); + i += 2; + } + } else { + i += 1; + } + result.put(key, value == null ? "" : value); + } + return result; + } + public static Map fn() { Map functions = new HashMap<>(); for (var value : values()) { @@ -106,4 +142,37 @@ public static Map fn() { } return functions; } + + protected record HeaderName(String value) implements CharSequence { + + @Override + public int length() { + return value.length(); + } + + @Override + public boolean equals(Object obj) { + return value.equalsIgnoreCase(obj.toString()); + } + + @Override + public int hashCode() { + return value.toLowerCase().hashCode(); + } + + @Override + public char charAt(int index) { + return value.charAt(index); + } + + @NonNull @Override + public CharSequence subSequence(int start, int end) { + return value.subSequence(start, end); + } + + @Override + public String toString() { + return value; + } + } } diff --git a/modules/jooby-openapi/src/main/java/module-info.java b/modules/jooby-openapi/src/main/java/module-info.java index c01866c3a1..d9c1d8bbc0 100644 --- a/modules/jooby-openapi/src/main/java/module-info.java +++ b/modules/jooby-openapi/src/main/java/module-info.java @@ -1,8 +1,6 @@ /** Open API module. */ module io.jooby.openapi { exports io.jooby.openapi; - exports io.jooby.internal.openapi to - com.fasterxml.jackson.databind; requires io.jooby; requires static com.github.spotbugs.annotations; @@ -21,4 +19,5 @@ requires org.objectweb.asm.util; requires io.pebbletemplates; requires jdk.jshell; + requires com.google.common; } diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-curl.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-curl.snippet index 29a3da60dc..b09a3bf02f 100644 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-curl.snippet +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-curl.snippet @@ -1,4 +1,4 @@ [source,bash] ---- -$ curl ${options} '${url}' +$ curl ${options | raw} '${url}' ---- diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-request.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-request.snippet index 9893e2a036..a25406eabd 100644 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-request.snippet +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-request.snippet @@ -1,8 +1,8 @@ [source,http,options="nowrap"] ---- -{{method}} {{path}} HTTP/1.1 -{{#headers}} -{{name}}: {{value}} -{{/headers}} -{{requestBody}} ----- \ No newline at end of file +${method} ${path} HTTP/1.1 +{% for h in headers %} +${name}: ${value} +{% endfor %} +${requestBody} +---- diff --git a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java new file mode 100644 index 0000000000..a1fb524d68 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java @@ -0,0 +1,160 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import static io.jooby.internal.openapi.asciidoc.Filters.curl; +import static io.jooby.openapi.CurrentDir.basedir; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.jooby.internal.openapi.OpenAPIExt; +import io.jooby.internal.openapi.OperationExt; +import io.pebbletemplates.pebble.PebbleEngine; +import io.pebbletemplates.pebble.extension.AbstractExtension; +import io.pebbletemplates.pebble.lexer.Syntax; +import io.pebbletemplates.pebble.template.EvaluationContext; +import io.pebbletemplates.pebble.template.PebbleTemplate; + +public class FilterTest { + + SnippetResolver resolver; + + @BeforeEach + public void setup() { + var openapi = new OpenAPIExt(); + resolver = new SnippetResolver(basedir("src", "test", "resources", "adoc")); + resolver.setEngine( + new PebbleEngine.Builder() + .extension( + new AbstractExtension() { + @Override + public Map getGlobalVariables() { + return Map.of("openapi", openapi); + } + }) + .syntax( + new Syntax.Builder() + .setPrintOpenDelimiter("${") + .setPrintCloseDelimiter("}") + .build()) + .build()); + } + + @Test + public void curl() { + assertEquals( + """ + [source,bash] + ---- + $ curl -X GET '/api/library/{isbn}' + ---- + """, + curl.apply( + operation("GET", "/api/library/{isbn}"), Map.of(), template(), evaluationContext(), 1)); + + // Passing arguments + assertEquals( + """ + [source,bash] + ---- + $ curl -i -X GET '/api/library/{isbn}' + ---- + """, + curl.apply( + operation("GET", "/api/library/{isbn}"), + args("-i"), + template(), + evaluationContext(), + 1)); + + // Override method + assertEquals( + """ + [source,bash] + ---- + $ curl -i -X POST '/api/library/{isbn}' + ---- + """, + curl.apply( + operation("GET", "/api/library/{isbn}"), + args("-i", "-X", "POST"), + template(), + evaluationContext(), + 1)); + + // With Accept Header + assertEquals( + """ + [source,bash] + ---- + $ curl -H 'Accept: application/json' -X GET '/api/library/{isbn}' + ---- + """, + curl.apply( + operation("GET", "/api/library/{isbn}", List.of(), List.of("application/json")), + args(), + template(), + evaluationContext(), + 1)); + + // With Override Accept Header + assertEquals( + """ + [source,bash] + ---- + $ curl -H 'Accept: application/xml' -X GET '/api/library/{isbn}' + ---- + """, + curl.apply( + operation("GET", "/api/library/{isbn}", List.of(), List.of("application/json")), + args("-H", "'Accept: application/xml'"), + template(), + evaluationContext(), + 1)); + } + + private Map args(Object... args) { + Map result = new LinkedHashMap<>(); + for (int i = 0; i < args.length; i++) { + result.put(Integer.toString(i), args[i]); + } + return result; + } + + private EvaluationContext evaluationContext() { + var context = mock(EvaluationContext.class); + when(context.getVariable("snippetResolver")).thenReturn(resolver); + return context; + } + + private PebbleTemplate template() { + var template = mock(PebbleTemplate.class); + return template; + } + + public OperationExt operation(String method, String pattern) { + var operation = mock(OperationExt.class); + when(operation.getMethod()).thenReturn(method); + when(operation.getPattern()).thenReturn(pattern); + return operation; + } + + public OperationExt operation( + String method, String pattern, List consumes, List produces) { + var operation = operation(method, pattern); + when(operation.getConsumes()).thenReturn(consumes); + when(operation.getProduces()).thenReturn(produces); + return operation; + } +} diff --git a/modules/jooby-openapi/src/test/java/io/jooby/openapi/CurrentDir.java b/modules/jooby-openapi/src/test/java/io/jooby/openapi/CurrentDir.java new file mode 100644 index 0000000000..cf27d6a07d --- /dev/null +++ b/modules/jooby-openapi/src/test/java/io/jooby/openapi/CurrentDir.java @@ -0,0 +1,22 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.openapi; + +import java.nio.file.Path; +import java.nio.file.Paths; + +public class CurrentDir { + public static Path basedir(String... others) { + var baseDir = Paths.get(System.getProperty("user.dir")); + if (!baseDir.getFileName().toString().endsWith("jooby-openapi")) { + baseDir = baseDir.resolve("modules").resolve("jooby-openapi"); + } + for (var other : others) { + baseDir = baseDir.resolve(other); + } + return baseDir; + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java index 8260168217..d25e099466 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java @@ -76,7 +76,7 @@ private void checkResult(OpenAPIResult result) { + " description: \"Not Found: If a book doesn't exist.\"\n" + " \"400\":\n" + " description: \"Bad Request: For bad ISBN code.\"\n" - + " /api/library/{id}:\n" + + " /api/library/author/{id}:\n" + " summary: Library API.\n" + " description: \"Contains all operations for creating, updating and fetching" + " books.\"\n" @@ -89,7 +89,7 @@ private void checkResult(OpenAPIResult result) { + " parameters:\n" + " - name: id\n" + " in: path\n" - + " description: ID.\n" + + " description: Author ID.\n" + " required: true\n" + " schema:\n" + " type: string\n" diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java index f9e6deb147..601b71c102 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java @@ -20,6 +20,7 @@ * @tag.description Access to all books. */ @Path("/api/library") +@Produces("application/json") public class LibraryApi { /** @@ -40,11 +41,11 @@ public Book bookByIsbn(@PathParam String isbn) throws NotFoundException, BadRequ /** * Author by Id. * - * @param id ID. + * @param id Author ID. * @return An author * @tag Author. Oxxx */ - @GET("/{id}") + @GET("/author/{id}") public Author author(@PathParam String id) { return new Author(); } diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/ScriptLibrary.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/ScriptLibrary.java index c123db911f..8dfe891acb 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/ScriptLibrary.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/ScriptLibrary.java @@ -45,13 +45,13 @@ public class ScriptLibrary extends Jooby { /* * Author by Id. * - * @param id ID. + * @param id Author ID. * @return An author * @tag Author. Oxxx * @operationId author */ get( - "/{id}", + "/author/{id}", ctx -> { var id = ctx.path("id").value(); return new Author(); diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/museum/AsciiDocTest.java b/modules/jooby-openapi/src/test/java/issues/i3729/museum/AsciiDocTest.java deleted file mode 100644 index 8c8df23bfb..0000000000 --- a/modules/jooby-openapi/src/test/java/issues/i3729/museum/AsciiDocTest.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package issues.i3729.museum; - -import static io.swagger.v3.oas.models.SpecVersion.V31; -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.nio.file.Path; -import java.nio.file.Paths; - -import io.jooby.openapi.OpenAPIResult; -import io.jooby.openapi.OpenAPITest; - -public class AsciiDocTest { - - @OpenAPITest(value = MuseumApp.class, version = V31) - public void shouldGenerateDoc(OpenAPIResult result) { - assertEquals("", result.toAsciiDoc(basedir().resolve("adoc").resolve("museum.adoc"))); - } - - private static Path basedir() { - var baseDir = Paths.get(System.getProperty("user.dir")); - if (!baseDir.getFileName().toString().endsWith("jooby-openapi")) { - baseDir = baseDir.resolve("modules").resolve("jooby-openapi"); - } - return baseDir.resolve("src").resolve("test").resolve("resources"); - } -} diff --git a/modules/jooby-openapi/src/test/resources/adoc/guide.adoc b/modules/jooby-openapi/src/test/resources/adoc/guide.adoc deleted file mode 100644 index 459f441491..0000000000 --- a/modules/jooby-openapi/src/test/resources/adoc/guide.adoc +++ /dev/null @@ -1,217 +0,0 @@ -= RESTful Notes API Guide -Andy Wilkinson; -:doctype: book -:icons: font -:source-highlighter: highlightjs -:toc: left -:toclevels: 4 -:sectlinks: -:operation-curl-request-title: Example request -:operation-http-response-title: Example response - -[[overview]] -= Overview - -[[overview_http_verbs]] -== HTTP verbs - -RESTful notes tries to adhere as closely as possible to standard HTTP and REST conventions in its -use of HTTP verbs. - -|=== -| Verb | Usage - -| `GET` -| Used to retrieve a resource - -| `POST` -| Used to create a new resource - -| `PATCH` -| Used to update an existing resource, including partial updates - -| `DELETE` -| Used to delete an existing resource -|=== - -[[overview_http_status_codes]] -== HTTP status codes - -RESTful notes tries to adhere as closely as possible to standard HTTP and REST conventions in its -use of HTTP status codes. - -|=== -| Status code | Usage - -| `200 OK` -| The request completed successfully - -| `201 Created` -| A new resource has been created successfully. The resource's URI is available from the response's -`Location` header - -| `204 No Content` -| An update to an existing resource has been applied successfully - -| `400 Bad Request` -| The request was malformed. The response body will include an error providing further information - -| `404 Not Found` -| The requested resource did not exist -|=== - -[[overview_errors]] -== Errors - -Whenever an error response (status code >= 400) is returned, the body will contain a JSON object -that describes the problem. The error object has the following structure: - -include::{snippets}/error-example/response-fields.adoc[] - -For example, a request that attempts to apply a non-existent tag to a note will produce a -`400 Bad Request` response: - -include::{snippets}/error-example/http-response.adoc[] - -[[resources]] -= Resources - - - -[[resources_index]] -== Index - -The index provides the entry point into the service. - - - -[[resources_index_access]] -=== Accessing the index - -A `GET` request is used to access the index - -operation::index-example[snippets='response-fields,http-response,links'] - -[[resources_notes]] -== Notes - -The Notes resources is used to create and list notes - - - -[[resources_notes_list]] -=== Listing notes - -A `GET` request will list all of the service's notes. - -operation::notes-list-example[snippets='response-fields,curl-request,http-response,links'] - - - -[[resources_notes_create]] -=== Creating a note - -A `POST` request is used to create a note. - -operation::notes-create-example[snippets='request-fields,curl-request,http-response'] - - - -[[resources_tags]] -== Tags - -The Tags resource is used to create and list tags. - - - -[[resources_tags_list]] -=== Listing tags - -A `GET` request will list all of the service's tags. - -operation::tags-list-example[snippets='response-fields,curl-request,http-response,links'] - - - -[[resources_tags_create]] -=== Creating a tag - -A `POST` request is used to create a note - -operation::tags-create-example[snippets='request-fields,curl-request,http-response'] - - - -[[resources_note]] -== Note - -The Note resource is used to retrieve, update, and delete individual notes - - - -[[resources_note_links]] -=== Links - -include::{snippets}/note-get-example/links.adoc[] - - - -[[resources_note_retrieve]] -=== Retrieve a note - -A `GET` request will retrieve the details of a note - -operation::note-get-example[snippets='response-fields,curl-request,http-response'] - - - -[[resources_note_update]] -=== Update a note - -A `PATCH` request is used to update a note - -==== Request structure - -include::{snippets}/note-update-example/request-fields.adoc[] - -To leave an attribute of a note unchanged, any of the above may be omitted from the request. - -==== Example request - -include::{snippets}/note-update-example/curl-request.adoc[] - -==== Example response - -include::{snippets}/note-update-example/http-response.adoc[] - - - -[[resources_tag]] -== Tag - -The Tag resource is used to retrieve, update, and delete individual tags - - - -[[resources_tag_links]] -=== Links - -include::{snippets}/tag-get-example/links.adoc[] - - - -[[resources_tag_retrieve]] -=== Retrieve a tag - -A `GET` request will retrieve the details of a tag - -operation::tag-get-example[snippets='response-fields,curl-request,http-response'] - - - -[[resources_tag_update]] -=== Update a tag - -A `PATCH` request is used to update a tag - -operation::tag-update-example[snippets='request-fields,curl-request,http-response'] diff --git a/modules/jooby-openapi/src/test/resources/adoc/museum.adoc b/modules/jooby-openapi/src/test/resources/adoc/library.adoc similarity index 78% rename from modules/jooby-openapi/src/test/resources/adoc/museum.adoc rename to modules/jooby-openapi/src/test/resources/adoc/library.adoc index 5b610862c5..1409523c4e 100644 --- a/modules/jooby-openapi/src/test/resources/adoc/museum.adoc +++ b/modules/jooby-openapi/src/test/resources/adoc/library.adoc @@ -13,6 +13,6 @@ Jooby Doc; [[overview_http_verbs]] == HTTP verbs -${ operation("POST", "/museum-hours") | curl } +${ operation("GET", "/api/library/{isbn}") | curl("-i") } Response: From db3bc076c38003f4c38f4666d4b64a7f23b58c91 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sun, 30 Nov 2025 14:30:19 -0300 Subject: [PATCH 06/31] openapi: asciidoc output - curl support form parameter - curl support query parameter - added schema, json, yaml filters - ref #3820 --- jooby/src/main/java/io/jooby/StatusCode.java | 205 +++---- modules/jooby-openapi/pom.xml | 6 + .../internal/openapi/AsciiDocGenerator.java | 40 +- .../jooby/internal/openapi/RouteParser.java | 5 +- .../internal/openapi/asciidoc/Filters.java | 171 ++---- .../internal/openapi/asciidoc/Functions.java | 3 +- .../openapi/asciidoc/InternalContext.java | 41 ++ .../openapi/asciidoc/OperationFilters.java | 497 ++++++++++++++++ .../internal/openapi/asciidoc/SchemaData.java | 52 ++ .../src/main/java/module-info.java | 1 + .../templates/asciidoc/default-curl.snippet | 2 +- .../asciidoc/default-http-request.snippet | 8 +- .../asciidoc/default-http-response.snippet | 12 +- .../templates/asciidoc/default-schema.snippet | 4 + .../asciidoc/default-statusCode.snippet | 5 + .../internal/openapi/asciidoc/FilterTest.java | 434 ++++++++++++-- .../io/jooby/openapi/OperationBuilder.java | 150 +++++ .../java/issues/i3729/api/ApiDocTest.java | 531 ++++++++++-------- .../src/test/java/issues/i3729/api/Book.java | 12 + .../test/java/issues/i3729/api/BookError.java | 41 ++ .../test/java/issues/i3729/api/BookQuery.java | 4 +- .../java/issues/i3729/api/LibraryApi.java | 2 +- .../java/issues/i3729/api/ScriptLibrary.java | 2 +- .../src/test/resources/adoc/library.adoc | 29 +- 24 files changed, 1710 insertions(+), 547 deletions(-) create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/InternalContext.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/SchemaData.java create mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-schema.snippet create mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-statusCode.snippet create mode 100644 modules/jooby-openapi/src/test/java/io/jooby/openapi/OperationBuilder.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3729/api/BookError.java diff --git a/jooby/src/main/java/io/jooby/StatusCode.java b/jooby/src/main/java/io/jooby/StatusCode.java index 1dd1e9b1c5..e32ff6cf7e 100644 --- a/jooby/src/main/java/io/jooby/StatusCode.java +++ b/jooby/src/main/java/io/jooby/StatusCode.java @@ -917,9 +917,27 @@ public final class StatusCode { private final String reason; + private final transient boolean unknown; + private StatusCode(final int value, final String reason) { this.value = value; this.reason = reason; + this.unknown = false; + } + + private StatusCode(final int value) { + this.value = value; + this.reason = Integer.toString(value); + this.unknown = true; + } + + /** + * True for custom status code. + * + * @return True for custom status code. + */ + public boolean isUnknown() { + return unknown; } /** @@ -971,129 +989,68 @@ public int hashCode() { * @throws IllegalArgumentException if this enum has no constant for the specified numeric value */ public static StatusCode valueOf(final int statusCode) { - switch (statusCode) { - case CONTINUE_CODE: - return CONTINUE; - case SWITCHING_PROTOCOLS_CODE: - return SWITCHING_PROTOCOLS; - case PROCESSING_CODE: - return PROCESSING; - case CHECKPOINT_CODE: - return CHECKPOINT; - case OK_CODE: - return OK; - case CREATED_CODE: - return CREATED; - case ACCEPTED_CODE: - return ACCEPTED; - case NON_AUTHORITATIVE_INFORMATION_CODE: - return NON_AUTHORITATIVE_INFORMATION; - case NO_CONTENT_CODE: - return NO_CONTENT; - case RESET_CONTENT_CODE: - return RESET_CONTENT; - case PARTIAL_CONTENT_CODE: - return PARTIAL_CONTENT; - case MULTI_STATUS_CODE: - return MULTI_STATUS; - case ALREADY_REPORTED_CODE: - return ALREADY_REPORTED; - case IM_USED_CODE: - return IM_USED; - case MULTIPLE_CHOICES_CODE: - return MULTIPLE_CHOICES; - case MOVED_PERMANENTLY_CODE: - return MOVED_PERMANENTLY; - case FOUND_CODE: - return FOUND; - case SEE_OTHER_CODE: - return SEE_OTHER; - case NOT_MODIFIED_CODE: - return NOT_MODIFIED; - case USE_PROXY_CODE: - return USE_PROXY; - case TEMPORARY_REDIRECT_CODE: - return TEMPORARY_REDIRECT; - case RESUME_INCOMPLETE_CODE: - return RESUME_INCOMPLETE; - case BAD_REQUEST_CODE: - return BAD_REQUEST; - case UNAUTHORIZED_CODE: - return UNAUTHORIZED; - case PAYMENT_REQUIRED_CODE: - return PAYMENT_REQUIRED; - case FORBIDDEN_CODE: - return FORBIDDEN; - case NOT_FOUND_CODE: - return NOT_FOUND; - case METHOD_NOT_ALLOWED_CODE: - return METHOD_NOT_ALLOWED; - case NOT_ACCEPTABLE_CODE: - return NOT_ACCEPTABLE; - case PROXY_AUTHENTICATION_REQUIRED_CODE: - return PROXY_AUTHENTICATION_REQUIRED; - case REQUEST_TIMEOUT_CODE: - return REQUEST_TIMEOUT; - case CONFLICT_CODE: - return CONFLICT; - case GONE_CODE: - return GONE; - case LENGTH_REQUIRED_CODE: - return LENGTH_REQUIRED; - case PRECONDITION_FAILED_CODE: - return PRECONDITION_FAILED; - case REQUEST_ENTITY_TOO_LARGE_CODE: - return REQUEST_ENTITY_TOO_LARGE; - case REQUEST_URI_TOO_LONG_CODE: - return REQUEST_URI_TOO_LONG; - case UNSUPPORTED_MEDIA_TYPE_CODE: - return UNSUPPORTED_MEDIA_TYPE; - case REQUESTED_RANGE_NOT_SATISFIABLE_CODE: - return REQUESTED_RANGE_NOT_SATISFIABLE; - case EXPECTATION_FAILED_CODE: - return EXPECTATION_FAILED; - case I_AM_A_TEAPOT_CODE: - return I_AM_A_TEAPOT; - case UNPROCESSABLE_ENTITY_CODE: - return UNPROCESSABLE_ENTITY; - case LOCKED_CODE: - return LOCKED; - case FAILED_DEPENDENCY_CODE: - return FAILED_DEPENDENCY; - case UPGRADE_REQUIRED_CODE: - return UPGRADE_REQUIRED; - case PRECONDITION_REQUIRED_CODE: - return PRECONDITION_REQUIRED; - case TOO_MANY_REQUESTS_CODE: - return TOO_MANY_REQUESTS; - case REQUEST_HEADER_FIELDS_TOO_LARGE_CODE: - return REQUEST_HEADER_FIELDS_TOO_LARGE; - case SERVER_ERROR_CODE: - return SERVER_ERROR; - case NOT_IMPLEMENTED_CODE: - return NOT_IMPLEMENTED; - case BAD_GATEWAY_CODE: - return BAD_GATEWAY; - case SERVICE_UNAVAILABLE_CODE: - return SERVICE_UNAVAILABLE; - case GATEWAY_TIMEOUT_CODE: - return GATEWAY_TIMEOUT; - case HTTP_VERSION_NOT_SUPPORTED_CODE: - return HTTP_VERSION_NOT_SUPPORTED; - case VARIANT_ALSO_NEGOTIATES_CODE: - return VARIANT_ALSO_NEGOTIATES; - case INSUFFICIENT_STORAGE_CODE: - return INSUFFICIENT_STORAGE; - case LOOP_DETECTED_CODE: - return LOOP_DETECTED; - case BANDWIDTH_LIMIT_EXCEEDED_CODE: - return BANDWIDTH_LIMIT_EXCEEDED; - case NOT_EXTENDED_CODE: - return NOT_EXTENDED; - case NETWORK_AUTHENTICATION_REQUIRED_CODE: - return NETWORK_AUTHENTICATION_REQUIRED; - default: - return new StatusCode(statusCode, Integer.toString(statusCode)); - } + return switch (statusCode) { + case CONTINUE_CODE -> CONTINUE; + case SWITCHING_PROTOCOLS_CODE -> SWITCHING_PROTOCOLS; + case PROCESSING_CODE -> PROCESSING; + case CHECKPOINT_CODE -> CHECKPOINT; + case OK_CODE -> OK; + case CREATED_CODE -> CREATED; + case ACCEPTED_CODE -> ACCEPTED; + case NON_AUTHORITATIVE_INFORMATION_CODE -> NON_AUTHORITATIVE_INFORMATION; + case NO_CONTENT_CODE -> NO_CONTENT; + case RESET_CONTENT_CODE -> RESET_CONTENT; + case PARTIAL_CONTENT_CODE -> PARTIAL_CONTENT; + case MULTI_STATUS_CODE -> MULTI_STATUS; + case ALREADY_REPORTED_CODE -> ALREADY_REPORTED; + case IM_USED_CODE -> IM_USED; + case MULTIPLE_CHOICES_CODE -> MULTIPLE_CHOICES; + case MOVED_PERMANENTLY_CODE -> MOVED_PERMANENTLY; + case FOUND_CODE -> FOUND; + case SEE_OTHER_CODE -> SEE_OTHER; + case NOT_MODIFIED_CODE -> NOT_MODIFIED; + case USE_PROXY_CODE -> USE_PROXY; + case TEMPORARY_REDIRECT_CODE -> TEMPORARY_REDIRECT; + case RESUME_INCOMPLETE_CODE -> RESUME_INCOMPLETE; + case BAD_REQUEST_CODE -> BAD_REQUEST; + case UNAUTHORIZED_CODE -> UNAUTHORIZED; + case PAYMENT_REQUIRED_CODE -> PAYMENT_REQUIRED; + case FORBIDDEN_CODE -> FORBIDDEN; + case NOT_FOUND_CODE -> NOT_FOUND; + case METHOD_NOT_ALLOWED_CODE -> METHOD_NOT_ALLOWED; + case NOT_ACCEPTABLE_CODE -> NOT_ACCEPTABLE; + case PROXY_AUTHENTICATION_REQUIRED_CODE -> PROXY_AUTHENTICATION_REQUIRED; + case REQUEST_TIMEOUT_CODE -> REQUEST_TIMEOUT; + case CONFLICT_CODE -> CONFLICT; + case GONE_CODE -> GONE; + case LENGTH_REQUIRED_CODE -> LENGTH_REQUIRED; + case PRECONDITION_FAILED_CODE -> PRECONDITION_FAILED; + case REQUEST_ENTITY_TOO_LARGE_CODE -> REQUEST_ENTITY_TOO_LARGE; + case REQUEST_URI_TOO_LONG_CODE -> REQUEST_URI_TOO_LONG; + case UNSUPPORTED_MEDIA_TYPE_CODE -> UNSUPPORTED_MEDIA_TYPE; + case REQUESTED_RANGE_NOT_SATISFIABLE_CODE -> REQUESTED_RANGE_NOT_SATISFIABLE; + case EXPECTATION_FAILED_CODE -> EXPECTATION_FAILED; + case I_AM_A_TEAPOT_CODE -> I_AM_A_TEAPOT; + case UNPROCESSABLE_ENTITY_CODE -> UNPROCESSABLE_ENTITY; + case LOCKED_CODE -> LOCKED; + case FAILED_DEPENDENCY_CODE -> FAILED_DEPENDENCY; + case UPGRADE_REQUIRED_CODE -> UPGRADE_REQUIRED; + case PRECONDITION_REQUIRED_CODE -> PRECONDITION_REQUIRED; + case TOO_MANY_REQUESTS_CODE -> TOO_MANY_REQUESTS; + case REQUEST_HEADER_FIELDS_TOO_LARGE_CODE -> REQUEST_HEADER_FIELDS_TOO_LARGE; + case SERVER_ERROR_CODE -> SERVER_ERROR; + case NOT_IMPLEMENTED_CODE -> NOT_IMPLEMENTED; + case BAD_GATEWAY_CODE -> BAD_GATEWAY; + case SERVICE_UNAVAILABLE_CODE -> SERVICE_UNAVAILABLE; + case GATEWAY_TIMEOUT_CODE -> GATEWAY_TIMEOUT; + case HTTP_VERSION_NOT_SUPPORTED_CODE -> HTTP_VERSION_NOT_SUPPORTED; + case VARIANT_ALSO_NEGOTIATES_CODE -> VARIANT_ALSO_NEGOTIATES; + case INSUFFICIENT_STORAGE_CODE -> INSUFFICIENT_STORAGE; + case LOOP_DETECTED_CODE -> LOOP_DETECTED; + case BANDWIDTH_LIMIT_EXCEEDED_CODE -> BANDWIDTH_LIMIT_EXCEEDED; + case NOT_EXTENDED_CODE -> NOT_EXTENDED; + case NETWORK_AUTHENTICATION_REQUIRED_CODE -> NETWORK_AUTHENTICATION_REQUIRED; + default -> new StatusCode(statusCode); + }; } } diff --git a/modules/jooby-openapi/pom.xml b/modules/jooby-openapi/pom.xml index 7bc2379033..76a726b5ca 100644 --- a/modules/jooby-openapi/pom.xml +++ b/modules/jooby-openapi/pom.xml @@ -18,6 +18,12 @@ ${jooby.version} + + net.datafaker + datafaker + 2.5.3 + + jakarta.ws.rs diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AsciiDocGenerator.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AsciiDocGenerator.java index 1c34429e85..bc9de9a48c 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AsciiDocGenerator.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AsciiDocGenerator.java @@ -11,6 +11,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import io.jooby.internal.openapi.asciidoc.Filters; import io.jooby.internal.openapi.asciidoc.Functions; @@ -22,6 +23,12 @@ import io.pebbletemplates.pebble.operator.BinaryOperator; import io.pebbletemplates.pebble.operator.UnaryOperator; import io.pebbletemplates.pebble.tokenParser.TokenParser; +import io.swagger.v3.core.util.Json; +import io.swagger.v3.core.util.Json31; +import io.swagger.v3.core.util.Yaml; +import io.swagger.v3.core.util.Yaml31; +import io.swagger.v3.oas.models.SpecVersion; +import io.swagger.v3.oas.models.servers.Server; public class AsciiDocGenerator { @@ -34,14 +41,33 @@ public static String generate(OpenAPIExt openAPI, Path index) throws IOException return writer.toString(); } - private static PebbleEngine newEngine(OpenAPIExt openAPI, Path baseDir) { + @SuppressWarnings("unchecked") + private static PebbleEngine newEngine(OpenAPIExt openapi, Path baseDir) { + var json = (openapi.getSpecVersion() == SpecVersion.V30 ? Json.mapper() : Json31.mapper()); + var yaml = (openapi.getSpecVersion() == SpecVersion.V30 ? Yaml.mapper() : Yaml31.mapper()); var snippetResolver = new SnippetResolver(baseDir.resolve("snippet")); - var engine = - newEngine( - new OpenApiSupport(Map.of("openapi", openAPI, "snippetResolver", snippetResolver))); + var serverUrl = + Optional.ofNullable(openapi.getServers()) + .map(List::getFirst) + .map(Server::getUrl) + .orElse(""); + var openapiRoot = json.convertValue(openapi, Map.class); + openapiRoot.put( + "internal", + Map.of( + "openapi", + openapi, + "resolver", + snippetResolver, + "serverUrl", + serverUrl, + "json", + json, + "yaml", + yaml)); + var engine = newEngine(new OpenApiSupport(openapiRoot)); snippetResolver.setEngine(engine); - return newEngine( - new OpenApiSupport(Map.of("openapi", openAPI, "snippetResolver", snippetResolver))); + return engine; } private static PebbleEngine newEngine(OpenApiSupport extension) { @@ -67,7 +93,7 @@ public OpenApiSupport(Map vars) { @Override public Map getFilters() { - return Filters.fn(); + return Filters.allFilters(); } @Override diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RouteParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RouteParser.java index e66514b93e..6201aa4236 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RouteParser.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RouteParser.java @@ -164,12 +164,11 @@ private void checkRequestBody(ParserContext ctx, OperationExt operation) { if (requestBody != null) { if (requestBody.getContent() == null) { // default content - io.swagger.v3.oas.models.media.MediaType mediaType = - new io.swagger.v3.oas.models.media.MediaType(); + var mediaType = new io.swagger.v3.oas.models.media.MediaType(); mediaType.setSchema(ctx.schema(requestBody.getJavaType())); String mediaTypeName = operation.getConsumes().stream().findFirst().orElseGet(requestBody::getContentType); - Content content = new Content(); + var content = new Content(); content.addMediaType(mediaTypeName, mediaType); requestBody.setContent(content); } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Filters.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Filters.java index c012f0cbce..ef00988971 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Filters.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Filters.java @@ -5,22 +5,22 @@ */ package io.jooby.internal.openapi.asciidoc; -import java.util.*; +import java.util.HashMap; +import java.util.List; +import java.util.Map; -import com.google.common.base.Splitter; -import com.google.common.collect.*; -import edu.umd.cs.findbugs.annotations.NonNull; -import io.jooby.internal.openapi.OperationExt; +import com.fasterxml.jackson.core.JsonProcessingException; import io.pebbletemplates.pebble.error.PebbleException; import io.pebbletemplates.pebble.extension.Filter; import io.pebbletemplates.pebble.template.EvaluationContext; import io.pebbletemplates.pebble.template.PebbleTemplate; -import io.swagger.v3.oas.models.Operation; public enum Filters implements Filter { - curl { - private static final CharSequence Accept = new HeaderName("Accept"); - private static final CharSequence ContentType = new HeaderName("Content-Type"); + json { + @Override + public List getArgumentNames() { + return null; + } @Override public Object apply( @@ -31,148 +31,47 @@ public Object apply( int lineNumber) throws PebbleException { try { - if (!(input instanceof OperationExt operation)) { - throw new IllegalArgumentException( - "Argument must be " + Operation.class.getName() + ". Got: " + input); - } - var snippetResolver = (SnippetResolver) context.getVariable("snippetResolver"); - var options = args(args); - var method = - Optional.of(options.removeAll("-X")) - .map(Collection::iterator) - .filter(Iterator::hasNext) - .map(Iterator::next) - .orElse(operation.getMethod()) - .toUpperCase(); - /* Accept/Content-Type: */ - var addAccept = true; - var addContentType = true; - if (options.containsKey("-H")) { - var headers = parseHeaders(options.get("-H")); - addAccept = !headers.containsKey(Accept); - addContentType = !headers.containsKey(ContentType); - } - if (addAccept) { - operation.getProduces().forEach(value -> options.put("-H", "'Accept: " + value + "'")); - } - if (addContentType && !READ_METHODS.contains(method)) { - operation - .getConsumes() - .forEach(value -> options.put("-H", "'Content-Type: " + value + "'")); - } - /* Method */ - if (!options.containsKey("-X")) { - options.put("-X", method); - } - return snippetResolver.apply( - name(), Map.of("url", operation.getPattern(), "options", toString(options))); - } catch (PebbleException pebbleException) { - throw pebbleException; - } catch (Exception exception) { - throw new PebbleException(exception, name() + " failed to generate output"); + var json = InternalContext.json(context); + return json.writer().withDefaultPrettyPrinter().writeValueAsString(input); + } catch (JsonProcessingException e) { + throw new PebbleException( + e, "Could not convert to JSON: " + input, lineNumber, self.getName()); } } + }, + yaml { @Override public List getArgumentNames() { return null; } - }; - - protected Multimap parseHeaders(Collection headers) { - Multimap result = LinkedHashMultimap.create(); - for (var line : headers) { - if (line.startsWith("'") && line.endsWith("'")) { - line = line.substring(1, line.length() - 1); - } - var header = Splitter.on(':').trimResults().omitEmptyStrings().splitToList(line); - if (header.size() != 2) { - throw new IllegalArgumentException("Invalid header: " + line); - } - result.put(new HeaderName(header.get(0)), header.get(1)); - } - return result; - } - - protected static final Set READ_METHODS = Set.of("GET", "HEAD"); - protected String toString(Multimap options) { - if (options.isEmpty()) { - return ""; - } - var sb = new StringBuilder(); - var separator = " "; - options.forEach( - (k, v) -> { - sb.append(k).append(separator); - if (v != null && !v.isEmpty()) { - sb.append(v).append(separator); - } - }); - sb.deleteCharAt(sb.length() - separator.length()); - return sb.toString(); - } - - protected Multimap args(Map args) { - Multimap result = LinkedHashMultimap.create(); - var optionList = new ArrayList<>(args.values()); - for (int i = 0; i < optionList.size(); ) { - var key = optionList.get(i).toString(); - String value = null; - if (i + 1 < optionList.size()) { - var next = optionList.get(i + 1); - if (next.toString().startsWith("-")) { - i += 1; - } else { - value = next.toString(); - i += 2; - } - } else { - i += 1; + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + try { + var yaml = InternalContext.yaml(context); + return yaml.writer().withDefaultPrettyPrinter().writeValueAsString(input); + } catch (JsonProcessingException e) { + throw new PebbleException( + e, "Could not convert to YAML: " + input, lineNumber, self.getName()); } - result.put(key, value == null ? "" : value); } - return result; - } + }; - public static Map fn() { + public static Map allFilters() { Map functions = new HashMap<>(); for (var value : values()) { functions.put(value.name(), value); } - return functions; - } - - protected record HeaderName(String value) implements CharSequence { - - @Override - public int length() { - return value.length(); - } - - @Override - public boolean equals(Object obj) { - return value.equalsIgnoreCase(obj.toString()); - } - - @Override - public int hashCode() { - return value.toLowerCase().hashCode(); - } - - @Override - public char charAt(int index) { - return value.charAt(index); - } - - @NonNull @Override - public CharSequence subSequence(int start, int end) { - return value.subSequence(start, end); - } - - @Override - public String toString() { - return value; + for (var value : OperationFilters.values()) { + functions.put(value.id(), value); } + return functions; } } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java index 3327e5d3e4..40e6a93e0b 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java @@ -9,7 +9,6 @@ import java.util.List; import java.util.Map; -import io.jooby.internal.openapi.OpenAPIExt; import io.pebbletemplates.pebble.error.PebbleException; import io.pebbletemplates.pebble.extension.Function; import io.pebbletemplates.pebble.template.EvaluationContext; @@ -36,7 +35,7 @@ public Object execute( namedArgs.put("id", value); } namedArgs.putIfAbsent("pattern", (String) args.get("pattern")); - OpenAPIExt openApi = (OpenAPIExt) context.getVariable("openapi"); + var openApi = InternalContext.openApi(context); var operationId = namedArgs.get("id"); if (operationId == null) { var method = namedArgs.get("method"); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/InternalContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/InternalContext.java new file mode 100644 index 0000000000..80eeb7ace3 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/InternalContext.java @@ -0,0 +1,41 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.Map; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jooby.internal.openapi.OpenAPIExt; +import io.pebbletemplates.pebble.template.EvaluationContext; + +public class InternalContext { + + @SuppressWarnings("unchecked") + private static T internal(EvaluationContext context, String name) { + var internal = (Map) context.getVariable("internal"); + return (T) internal.get(name); + } + + public static T variable(EvaluationContext context, String name) { + return internal(context, name); + } + + public static OpenAPIExt openApi(EvaluationContext context) { + return internal(context, "openapi"); + } + + public static SnippetResolver resolver(EvaluationContext context) { + return internal(context, "resolver"); + } + + public static ObjectMapper json(EvaluationContext context) { + return internal(context, "json"); + } + + public static ObjectMapper yaml(EvaluationContext context) { + return internal(context, "yaml"); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java new file mode 100644 index 0000000000..8b9dd27c95 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java @@ -0,0 +1,497 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.google.common.base.Splitter; +import com.google.common.collect.*; +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.MediaType; +import io.jooby.StatusCode; +import io.jooby.internal.openapi.OperationExt; +import io.jooby.internal.openapi.RequestBodyExt; +import io.jooby.internal.openapi.ResponseExt; +import io.pebbletemplates.pebble.error.PebbleException; +import io.pebbletemplates.pebble.extension.Filter; +import io.pebbletemplates.pebble.template.EvaluationContext; +import io.pebbletemplates.pebble.template.PebbleTemplate; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.parameters.Parameter; + +public enum OperationFilters implements Filter { + curl { + private static final CharSequence Accept = new HeaderName("Accept"); + private static final CharSequence ContentType = new HeaderName("Content-Type"); + + @Override + protected Object doApply( + SnippetResolver resolver, + OperationExt operation, + Map snippetContext, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws Exception { + var options = args(args); + var method = + Optional.of(options.removeAll("-X")) + .map(Collection::iterator) + .filter(Iterator::hasNext) + .map(Iterator::next) + .orElse(operation.getMethod()) + .toUpperCase(); + /* Accept/Content-Type: */ + var addAccept = true; + var addContentType = true; + if (options.containsKey("-H")) { + var headers = parseHeaders(options.get("-H")); + addAccept = !headers.containsKey(Accept); + addContentType = !headers.containsKey(ContentType); + } + if (addAccept) { + operation.getProduces().forEach(value -> options.put("-H", "'Accept: " + value + "'")); + } + if (addContentType && !READ_METHODS.contains(method)) { + operation + .getConsumes() + .forEach(value -> options.put("-H", "'Content-Type: " + value + "'")); + } + /* Body */ + if (operation.getRequestBody() != null) { + var requestBody = operation.getRequestBody(); + var content = requestBody.getContent(); + if (content != null) { + var mediaType = + content.get(operation.getConsumes().stream().findFirst().orElse(MediaType.JSON)); + if (mediaType != null) { + var json = InternalContext.json(context); + options.put( + "-d", "'" + json.writeValueAsString(SchemaData.from(mediaType.getSchema())) + "'"); + } + } + } else { + // can be form + var form = + operation.getParameters().stream().filter(it -> "form".equals(it.getIn())).toList(); + encodeUrlParameter(form) + .forEach( + it -> + options.put( + it.getKey(), + it.getKey().equals("-F") + ? "\"" + it.getValue() + "\"" + : "'" + it.getValue() + "'")); + } + /* Method */ + var url = snippetContext.get("url").toString(); + var query = + operation.getParameters().stream().filter(it -> "query".equals(it.getIn())).toList(); + // query parameters + if (!query.isEmpty()) { + url += + encodeUrlParameter(query) + .map(Map.Entry::getValue) + .collect(Collectors.joining("&", "?", "")); + } + options.put("-X", method + " '" + url + "'"); + var optionString = toString(options); + snippetContext.put("options", optionString); + return resolver.apply(id(), snippetContext); + } + + @NonNull private static Stream> encodeUrlParameter(List query) { + return query.stream() + .flatMap( + it -> { + var names = List.of(it.getName()); + var schema = it.getSchema(); + var index = new AtomicInteger(0); + if (it.getSchema().getType().equals("array")) { + schema = it.getSchema().getItems(); + // shows 3 examples + names = List.of(it.getName(), it.getName(), it.getName()); + index.set(1); + } + var option = "binary".equals(schema.getFormat()) ? "-F" : "--data-urlencode"; + var value = + "binary".equals(schema.getFormat()) + ? "@/file%1$s.extension" + : SchemaData.shemaType(schema) + "%1$s"; + return names.stream() + .map( + name -> + Map.entry( + option, + name + + "=" + + String.format( + value, (index.get() == 0 ? "" : index.getAndIncrement())))); + }); + } + }, + httpRequest { + @Override + protected Object doApply( + SnippetResolver resolver, + OperationExt operation, + Map snippetContext, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws Exception { + /* Body */ + var requestBodyString = ""; + if (operation.getRequestBody() != null) { + var json = InternalContext.json(context); + var schema = schema(context, operation.getRequestBody()); + requestBodyString = json.writeValueAsString(SchemaData.from(schema)) + "\n"; + } + snippetContext.put("requestBody", requestBodyString); + snippetContext.put("headers", snippetContext.get("requestHeaders")); + return resolver.apply(id(), snippetContext); + } + + @Override + protected String id() { + return "http-request"; + } + }, + httpResponse { + @Override + protected Object doApply( + SnippetResolver resolver, + OperationExt operation, + Map snippetContext, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws Exception { + /* Body */ + var requestBodyString = ""; + var statusCode = findStatusCode(args); + ResponseExt response; + if (statusCode != null) { + response = (ResponseExt) operation.getResponses().get(Integer.toString(statusCode.value())); + if (response == null) { + throw new IllegalArgumentException("No response: " + statusCode.value()); + } + } else { + response = operation.getDefaultResponse(); + statusCode = StatusCode.valueOf(Integer.parseInt(response.getCode())); + } + var json = InternalContext.json(context); + var schema = schema(context, response); + if (schema != null) { + requestBodyString = json.writeValueAsString(SchemaData.from(schema)) + "\n"; + } + snippetContext.put("statusCode", statusCode.value()); + snippetContext.put("statusReason", statusCode.reason()); + snippetContext.put("responseBody", requestBodyString); + snippetContext.put("headers", snippetContext.get("responseHeaders")); + return resolver.apply(id(), snippetContext); + } + + @Override + protected String id() { + return "http-response"; + } + }, + schema { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + try { + var schema = schema(context, input); + var snippetResolver = InternalContext.resolver(context); + var json = InternalContext.json(context); + return snippetResolver.apply( + id(), Map.of("schema", json.writeValueAsString(SchemaData.from(schema)))); + } catch (PebbleException pebbleException) { + throw pebbleException; + } catch (Exception exception) { + throw new PebbleException( + exception, name() + " failed to generate output", lineNumber, self.getName()); + } + } + + @Override + protected Object doApply( + SnippetResolver resolver, + OperationExt operation, + Map snippetContext, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws Exception { + // NOOP + return null; + } + }, + statusCode { + @Override + protected Object doApply( + SnippetResolver resolver, + OperationExt operation, + Map snippetContext, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws Exception { + var statusCode = findStatusCode(args); + var response = operation.getResponses().get(Integer.toString(statusCode.value())); + if (response == null) { + throw new IllegalArgumentException("No response for: " + statusCode); + } + var json = InternalContext.json(context); + var schema = schema(context, response); + Map schemaData; + if (schema == null) { + if (statusCode.value() >= 400) { + schemaData = new LinkedHashMap<>(); + // follow default error handler + schemaData.put("message", "..."); + schemaData.put("statusCode", statusCode.value()); + schemaData.put("reason", statusCode.reason()); + } else { + throw new IllegalArgumentException("No schema response for: " + statusCode); + } + } else { + schemaData = SchemaData.from(schema); + } + var responseString = json.writer().withDefaultPrettyPrinter().writeValueAsString(schemaData); + snippetContext.put( + "statusReason", + Optional.ofNullable(response.getDescription()).orElse(statusCode.reason())); + snippetContext.put("response", responseString); + return resolver.apply(id(), snippetContext); + } + }; + + protected String id() { + return name(); + } + + @Override + public List getArgumentNames() { + return List.of("code"); + } + + protected abstract Object doApply( + SnippetResolver resolver, + OperationExt operation, + Map snippetContext, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws Exception; + + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + try { + if (!(input instanceof OperationExt operation)) { + throw new IllegalArgumentException( + "Argument must be " + Operation.class.getName() + ". Got: " + input); + } + var snippetResolver = InternalContext.resolver(context); + var snippetContext = + newSnippetContext(InternalContext.variable(context, "serverUrl"), operation); + return doApply(snippetResolver, operation, snippetContext, args, self, context, lineNumber); + } catch (PebbleException pebbleException) { + throw pebbleException; + } catch (Exception exception) { + throw new PebbleException( + exception, name() + " failed to generate output", lineNumber, self.getName()); + } + } + + protected Map newSnippetContext(String serverUrl, OperationExt operation) { + Map map = new HashMap<>(); + map.put("pattern", operation.getPattern()); + map.put("path", operation.getPattern()); + map.put("method", operation.getMethod()); + map.put("url", serverUrl + operation.getPattern()); + var requestHeaders = ArrayListMultimap.create(); + var responseHeaders = ArrayListMultimap.create(); + var headerParams = + operation.getParameters().stream() + .filter(it -> "header".equalsIgnoreCase(it.getIn())) + .toList(); + operation + .getProduces() + .forEach( + value -> { + requestHeaders.put("Accept", value); + responseHeaders.put("Content-Type", value); + }); + headerParams.forEach(it -> requestHeaders.put(it.getName(), "{{" + it.getName() + "}}")); + if (!READ_METHODS.contains(operation.getMethod())) { + operation.getConsumes().forEach(value -> requestHeaders.put("Content-Type", value)); + } + + map.put("requestHeaders", requestHeaders.entries()); + map.put("responseHeaders", responseHeaders.entries()); + return map; + } + + protected StatusCode findStatusCode(Map args) { + for (var value : args.values()) { + try { + var code = Integer.parseInt(String.valueOf(value)); + if (code >= 100 && code <= 600) { + return StatusCode.valueOf(code); + } + } catch (NumberFormatException ignored) { + } + } + return null; + } + + protected Schema schema(EvaluationContext context, Object input) { + var schema = + switch (input) { + case Schema s -> s; + case RequestBodyExt requestBody -> + Optional.ofNullable(requestBody.getContent()) + .flatMap(content -> content.values().stream().findFirst()) + .map(io.swagger.v3.oas.models.media.MediaType::getSchema) + .orElse(null); + case ResponseExt response -> + Optional.ofNullable(response.getContent()) + .flatMap(content -> content.values().stream().findFirst()) + .map(io.swagger.v3.oas.models.media.MediaType::getSchema) + .orElse(null); + case null -> throw new NullPointerException("Unable to get schema from null"); + default -> + throw new IllegalArgumentException( + "Unable to get schema from " + input.getClass().getName()); + }; + if (schema != null && schema.get$ref() != null) { + var openapi = InternalContext.openApi(context); + var components = openapi.getComponents(); + if (components != null) { + var name = schema.get$ref().substring("#/components/schemas/".length()); + return components.getSchemas().getOrDefault(name, schema); + } + } + return schema; + } + + protected Multimap parseHeaders(Collection headers) { + Multimap result = LinkedHashMultimap.create(); + for (var line : headers) { + if (line.startsWith("'") && line.endsWith("'")) { + line = line.substring(1, line.length() - 1); + } + var header = Splitter.on(':').trimResults().omitEmptyStrings().splitToList(line); + if (header.size() != 2) { + throw new IllegalArgumentException("Invalid header: " + line); + } + result.put(new HeaderName(header.get(0)), header.get(1)); + } + return result; + } + + protected static final Set READ_METHODS = Set.of("GET", "HEAD"); + + protected String toString(Multimap options) { + if (options.isEmpty()) { + return ""; + } + var sb = new StringBuilder(); + var separator = "\\\n"; + var tabSize = id().length() + 1; + options.forEach( + (k, v) -> { + if (!sb.isEmpty()) { + sb.append(" ".repeat(tabSize)); + } + sb.append(k); + if (v != null && !v.isEmpty()) { + sb.append(" ").append(v); + } + sb.append(separator); + }); + sb.setLength(sb.length() - separator.length()); + return sb.toString(); + } + + protected Multimap args(Map args) { + Multimap result = LinkedHashMultimap.create(); + var optionList = new ArrayList<>(args.values()); + for (int i = 0; i < optionList.size(); ) { + var key = optionList.get(i).toString(); + String value = null; + if (i + 1 < optionList.size()) { + var next = optionList.get(i + 1); + if (next.toString().startsWith("-")) { + i += 1; + } else { + value = next.toString(); + i += 2; + } + } else { + i += 1; + } + result.put(key, value == null ? "" : value); + } + return result; + } + + protected record HeaderName(String value) implements CharSequence { + + @Override + public int length() { + return value.length(); + } + + @Override + public boolean equals(Object obj) { + return value.equalsIgnoreCase(obj.toString()); + } + + @Override + public int hashCode() { + return value.toLowerCase().hashCode(); + } + + @Override + public char charAt(int index) { + return value.charAt(index); + } + + @NonNull @Override + public CharSequence subSequence(int start, int end) { + return value.subSequence(start, end); + } + + @Override + @NonNull public String toString() { + return value; + } + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/SchemaData.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/SchemaData.java new file mode 100644 index 0000000000..2881f6dd31 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/SchemaData.java @@ -0,0 +1,52 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import static java.util.Optional.ofNullable; + +import java.util.*; + +import io.jooby.internal.openapi.ModelConvertersExt; +import io.swagger.v3.oas.models.media.Schema; + +public class SchemaData { + public static Map from(Class schema) { + return from(ModelConvertersExt.getInstance().read(schema).get(schema.getSimpleName())); + } + + public static Map from(Schema schema) { + return ofNullable(traverse(schema.getProperties())).orElse(Map.of()); + } + + @SuppressWarnings("rawtypes") + private static Map traverse(Map properties) { + if (properties != null) { + Map result = new LinkedHashMap<>(); + properties.forEach( + (name, value) -> { + if (value.getType().equals("object")) { + result.put(name, from(value)); + } else if (value.getType().equals("array")) { + var array = + ofNullable(value.getItems()) + .map(Schema::getProperties) + .map(SchemaData::traverse) + .map(List::of) + .orElse(List.of()); + result.put(name, array); + } else { + result.put(name, shemaType(value)); + } + }); + return result; + } + return null; + } + + public static String shemaType(Schema schema) { + return Optional.ofNullable(schema.getFormat()).orElse(schema.getType()); + } +} diff --git a/modules/jooby-openapi/src/main/java/module-info.java b/modules/jooby-openapi/src/main/java/module-info.java index d9c1d8bbc0..9c74f44759 100644 --- a/modules/jooby-openapi/src/main/java/module-info.java +++ b/modules/jooby-openapi/src/main/java/module-info.java @@ -20,4 +20,5 @@ requires io.pebbletemplates; requires jdk.jshell; requires com.google.common; + requires org.checkerframework.checker.qual; } diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-curl.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-curl.snippet index b09a3bf02f..1d9b354595 100644 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-curl.snippet +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-curl.snippet @@ -1,4 +1,4 @@ [source,bash] ---- -$ curl ${options | raw} '${url}' +curl ${options} ---- diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-request.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-request.snippet index a25406eabd..2b404bac71 100644 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-request.snippet +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-request.snippet @@ -1,8 +1,8 @@ [source,http,options="nowrap"] ---- ${method} ${path} HTTP/1.1 -{% for h in headers %} -${name}: ${value} -{% endfor %} -${requestBody} +{% for h in headers -%} +${h.key}: ${h.value} +{% endfor -%} +${requestBody -} ---- diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-response.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-response.snippet index 90a25e8514..1ed23e6e6e 100644 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-response.snippet +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-response.snippet @@ -1,8 +1,8 @@ [source,http,options="nowrap"] ---- -HTTP/1.1 {{statusCode}} {{statusReason}} -{{#headers}} -{{name}}: {{value}} -{{/headers}} -{{responseBody}} ----- \ No newline at end of file +HTTP/1.1 ${statusCode} ${statusReason} +{% for h in headers -%} +${h.key}: ${h.value} +{% endfor -%} +${responseBody -} +---- diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-schema.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-schema.snippet new file mode 100644 index 0000000000..7cd869e93b --- /dev/null +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-schema.snippet @@ -0,0 +1,4 @@ +[source,json] +---- +${schema} +---- diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-statusCode.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-statusCode.snippet new file mode 100644 index 0000000000..203f7a07fb --- /dev/null +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-statusCode.snippet @@ -0,0 +1,5 @@ +.${statusReason} +[source,json] +---- +${response} +---- diff --git a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java index a1fb524d68..cdc9c36aae 100644 --- a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java +++ b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java @@ -5,48 +5,70 @@ */ package io.jooby.internal.openapi.asciidoc; -import static io.jooby.internal.openapi.asciidoc.Filters.curl; +import static io.jooby.internal.openapi.asciidoc.OperationFilters.*; import static io.jooby.openapi.CurrentDir.basedir; +import static io.jooby.openapi.OperationBuilder.operation; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import io.jooby.StatusCode; import io.jooby.internal.openapi.OpenAPIExt; -import io.jooby.internal.openapi.OperationExt; import io.pebbletemplates.pebble.PebbleEngine; import io.pebbletemplates.pebble.extension.AbstractExtension; import io.pebbletemplates.pebble.lexer.Syntax; import io.pebbletemplates.pebble.template.EvaluationContext; import io.pebbletemplates.pebble.template.PebbleTemplate; +import io.swagger.v3.core.util.Json; +import io.swagger.v3.core.util.Yaml; +import issues.i3729.api.Book; +import issues.i3729.api.BookError; public class FilterTest { - + private static final String SERVER_URL = "https://api.libray.com"; SnippetResolver resolver; + private Map internalContext; @BeforeEach public void setup() { var openapi = new OpenAPIExt(); resolver = new SnippetResolver(basedir("src", "test", "resources", "adoc")); + internalContext = + Map.of( + "openapi", + openapi, + "serverUrl", + SERVER_URL, + "json", + Json.mapper(), + "yaml", + Yaml.mapper(), + "resolver", + resolver); resolver.setEngine( new PebbleEngine.Builder() .extension( new AbstractExtension() { @Override + @SuppressWarnings("unchecked") public Map getGlobalVariables() { - return Map.of("openapi", openapi); + var openApiRoot = Json.mapper().convertValue(openapi, Map.class); + openApiRoot.put("internal", internalContext); + return openApiRoot; } }) + .autoEscaping(false) .syntax( new Syntax.Builder() .setPrintOpenDelimiter("${") .setPrintCloseDelimiter("}") + .setEnableNewLineTrimming(false) .build()) .build()); } @@ -57,22 +79,83 @@ public void curl() { """ [source,bash] ---- - $ curl -X GET '/api/library/{isbn}' + curl -X GET 'https://api.libray.com/api/library/{isbn}' + ---- + """, + curl.apply( + operation("GET", "/api/library/{isbn}").build(), + args(), + template(), + evaluationContext(), + 1)); + + // Query parameter + assertEquals( + """ + [source,bash] + ---- + curl -X GET 'https://api.libray.com/api/library/{isbn}?foo=string&bar=string' + ---- + """, + curl.apply( + operation("GET", "/api/library/{isbn}").query("foo", "bar").build(), + args(), + template(), + evaluationContext(), + 1)); + + // Form parameter + assertEquals( + """ + [source,bash] + ---- + curl --data-urlencode 'foo=string'\\ + --data-urlencode 'bar=string'\\ + -X POST 'https://api.libray.com/api/library/{isbn}' + ---- + """, + curl.apply( + operation("POST", "/api/library/{isbn}").form("foo", "bar").build(), + args(), + template(), + evaluationContext(), + 1)); + + // Query+Form parameter + assertEquals( + """ + [source,bash] + ---- + curl --data-urlencode 'foo=string'\\ + --data-urlencode 'bar=string'\\ + -X POST 'https://api.libray.com/api/library/{isbn}?active=boolean' ---- """, curl.apply( - operation("GET", "/api/library/{isbn}"), Map.of(), template(), evaluationContext(), 1)); + operation("POST", "/api/library/{isbn}") + .parameter( + Map.of( + "query", + mapOf("active", "boolean"), + "form", + mapOf("foo", "string", "bar", "string"))) + .build(), + args(), + template(), + evaluationContext(), + 1)); // Passing arguments assertEquals( """ [source,bash] ---- - $ curl -i -X GET '/api/library/{isbn}' + curl -i\\ + -X GET 'https://api.libray.com/api/library/{isbn}' ---- """, curl.apply( - operation("GET", "/api/library/{isbn}"), + operation("GET", "/api/library/{isbn}").build(), args("-i"), template(), evaluationContext(), @@ -83,11 +166,12 @@ public void curl() { """ [source,bash] ---- - $ curl -i -X POST '/api/library/{isbn}' + curl -i\\ + -X POST 'https://api.libray.com/api/library/{isbn}' ---- """, curl.apply( - operation("GET", "/api/library/{isbn}"), + operation("GET", "/api/library/{isbn}").build(), args("-i", "-X", "POST"), template(), evaluationContext(), @@ -98,11 +182,12 @@ public void curl() { """ [source,bash] ---- - $ curl -H 'Accept: application/json' -X GET '/api/library/{isbn}' + curl -H 'Accept: application/json'\\ + -X GET 'https://api.libray.com/api/library/{isbn}' ---- """, curl.apply( - operation("GET", "/api/library/{isbn}", List.of(), List.of("application/json")), + operation("GET", "/api/library/{isbn}").produces("application/json").build(), args(), template(), evaluationContext(), @@ -113,15 +198,306 @@ public void curl() { """ [source,bash] ---- - $ curl -H 'Accept: application/xml' -X GET '/api/library/{isbn}' + curl -H 'Accept: application/xml'\\ + -X GET 'https://api.libray.com/api/library/{isbn}' ---- """, curl.apply( - operation("GET", "/api/library/{isbn}", List.of(), List.of("application/json")), + operation("GET", "/api/library/{isbn}").produces("application/json").build(), args("-H", "'Accept: application/xml'"), template(), evaluationContext(), 1)); + + assertEquals( + """ + [source,bash] + ---- + curl -H 'Content-Type: application/json'\\ + -d '{"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"}'\\ + -X POST 'https://api.libray.com/api/library' + ---- + """, + curl.apply( + operation("POST", "/api/library").body(new Book(), "application/json").build(), + args(), + template(), + evaluationContext(), + 1)); + + assertEquals( + """ + [source,bash] + ---- + curl -H 'Content-Type: multipart/form-data'\\ + --data-urlencode 'name=string'\\ + -F "file=@/file.extension"\\ + -X POST 'https://api.libray.com/api/library' + ---- + """, + curl.apply( + operation("POST", "/api/library") + .parameter(Map.of("form", mapOf("name", "string", "file", "binary"))) + .consumes("multipart/form-data") + .build(), + args(), + template(), + evaluationContext(), + 1)); + } + + @Test + public void httpRequest() { + assertEquals( + """ + [source,http,options="nowrap"] + ---- + GET /api/library/{isbn} HTTP/1.1 + ---- + """, + httpRequest.apply( + operation("GET", "/api/library/{isbn}").build(), + args(), + template(), + evaluationContext(), + 1)); + + assertEquals( + """ + [source,http,options="nowrap"] + ---- + GET /api/library/{isbn} HTTP/1.1 + Accept: application/json + ---- + """, + httpRequest.apply( + operation("GET", "/api/library/{isbn}").produces("application/json").build(), + args(), + template(), + evaluationContext(), + 1)); + + assertEquals( + """ + [source,http,options="nowrap"] + ---- + POST /api/library HTTP/1.1 + Content-Type: application/json + {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} + ---- + """, + httpRequest.apply( + operation("POST", "/api/library").body(new Book(), "application/json").build(), + args(), + template(), + evaluationContext(), + 1)); + } + + @Test + public void httpResponse() { + assertEquals( + """ + [source,http,options="nowrap"] + ---- + HTTP/1.1 200 Success + ---- + """, + httpResponse.apply( + operation("GET", "/api/library/{isbn}").defaultResponse().build(), + args(), + template(), + evaluationContext(), + 1)); + + assertEquals( + """ + [source,http,options="nowrap"] + ---- + HTTP/1.1 200 Success + Content-Type: application/json + ---- + """, + httpResponse.apply( + operation("GET", "/api/library/{isbn}") + .defaultResponse() + .produces("application/json") + .build(), + args(), + template(), + evaluationContext(), + 1)); + + assertEquals( + """ + [source,http,options="nowrap"] + ---- + HTTP/1.1 201 Created + Content-Type: application/json + {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} + ---- + """, + httpResponse.apply( + operation("POST", "/api/library") + .produces("application/json") + .response(new Book(), StatusCode.CREATED, "application/json") + .build(), + args(), + template(), + evaluationContext(), + 1)); + + assertEquals( + """ + [source,http,options="nowrap"] + ---- + HTTP/1.1 201 Created + Content-Type: application/json + {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} + ---- + """, + httpResponse.apply( + operation("POST", "/api/library") + .produces("application/json") + .response(new Book(), StatusCode.CREATED, "application/json") + .build(), + args(), + template(), + evaluationContext(), + 1)); + + assertEquals( + """ + [source,http,options="nowrap"] + ---- + HTTP/1.1 400 Bad Request + Content-Type: application/json + {"path":"string","message":"string","code":"int32"} + ---- + """, + httpResponse.apply( + operation("POST", "/api/library") + .produces("application/json") + .response(new Book(), StatusCode.CREATED, "application/json") + .response(new BookError(), StatusCode.BAD_REQUEST, "application/json") + .build(), + args("400"), + template(), + evaluationContext(), + 1)); + } + + @Test + public void statusCode() { + assertEquals( + """ + .Created + [source,json] + ---- + { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "authors" : [ ], + "image" : "binary" + } + ---- + """, + statusCode.apply( + operation("POST", "/api/library") + .produces("application/json") + .response(new Book(), StatusCode.CREATED, "application/json") + .response(new BookError(), StatusCode.BAD_REQUEST, "application/json") + .build(), + args("201"), + template(), + evaluationContext(), + 1)); + + assertEquals( + """ + .Bad Request + [source,json] + ---- + { + "path" : "string", + "message" : "string", + "code" : "int32" + } + ---- + """, + statusCode.apply( + operation("POST", "/api/library") + .produces("application/json") + .response(new Book(), StatusCode.CREATED, "application/json") + .response(new BookError(), StatusCode.BAD_REQUEST, "application/json") + .build(), + args("400"), + template(), + evaluationContext(), + 1)); + } + + @Test + public void schema() { + // Request Body + assertEquals( + """ + [source,json] + ---- + {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} + ---- + """, + schema.apply( + operation("POST", "/api/library") + .body(new Book(), "application/json") + .build() + .getRequestBody(), + args(), + template(), + evaluationContext(), + 1)); + + // Response + assertEquals( + """ + [source,json] + ---- + {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} + ---- + """, + schema.apply( + operation("POST", "/api/library") + .response(new Book(), StatusCode.OK, "application/json") + .build() + .getDefaultResponse(), + args(), + template(), + evaluationContext(), + 1)); + + // Schema + assertEquals( + """ + [source,json] + ---- + {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} + ---- + """, + schema.apply( + operation("POST", "/api/library") + .response(new Book(), StatusCode.OK, "application/json") + .build() + .getDefaultResponse() + .getContent() + .get("application/json") + .getSchema(), + args(), + template(), + evaluationContext(), + 1)); } private Map args(Object... args) { @@ -132,29 +508,21 @@ private Map args(Object... args) { return result; } + private static Map mapOf(String... values) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < values.length; i += 2) { + map.put(values[i], values[i + 1]); + } + return map; + } + private EvaluationContext evaluationContext() { var context = mock(EvaluationContext.class); - when(context.getVariable("snippetResolver")).thenReturn(resolver); + when(context.getVariable("internal")).thenReturn(internalContext); return context; } private PebbleTemplate template() { - var template = mock(PebbleTemplate.class); - return template; - } - - public OperationExt operation(String method, String pattern) { - var operation = mock(OperationExt.class); - when(operation.getMethod()).thenReturn(method); - when(operation.getPattern()).thenReturn(pattern); - return operation; - } - - public OperationExt operation( - String method, String pattern, List consumes, List produces) { - var operation = operation(method, pattern); - when(operation.getConsumes()).thenReturn(consumes); - when(operation.getProduces()).thenReturn(produces); - return operation; + return mock(PebbleTemplate.class); } } diff --git a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OperationBuilder.java b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OperationBuilder.java new file mode 100644 index 0000000000..89bcfc320d --- /dev/null +++ b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OperationBuilder.java @@ -0,0 +1,150 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.openapi; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import io.jooby.StatusCode; +import io.jooby.internal.openapi.*; +import io.swagger.v3.core.converter.ModelConverters; +import io.swagger.v3.core.util.Json; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.parameters.Parameter; +import io.swagger.v3.oas.models.responses.ApiResponses; + +public class OperationBuilder { + + private ApiResponses responses; + + private final OperationExt operation = mock(OperationExt.class); + + static { + ModelConverters.getInstance().addConverter(new ModelConverterExt(Json.mapper())); + } + + public static OperationBuilder operation(String method, String pattern) { + return new OperationBuilder().method(method).pattern(pattern); + } + + public OperationBuilder query(String... name) { + return parameter(Map.of("query", mapOf(name))); + } + + public OperationBuilder form(String... name) { + return parameter(Map.of("form", mapOf(name))); + } + + private static Map mapOf(String... values) { + Map map = new LinkedHashMap<>(); + for (var value : values) { + map.put(value, "string"); + } + return map; + } + + public OperationBuilder parameter(Map> parameterSpecs) { + List parameters = new ArrayList<>(); + for (var parameterSpec : parameterSpecs.entrySet()) { + var in = parameterSpec.getKey(); + for (var entry : parameterSpec.getValue().entrySet()) { + var schema = mock(Schema.class); + var type = entry.getValue(); + if (type.equals("binary")) { + when(schema.getFormat()).thenReturn(type); + type = "string"; + } + when(schema.getType()).thenReturn(type); + var parameter = mock(ParameterExt.class); + when(parameter.getName()).thenReturn(entry.getKey()); + when(parameter.getIn()).thenReturn(in); + when(parameter.getSchema()).thenReturn(schema); + parameters.add(parameter); + } + } + when(operation.getParameters()).thenReturn(parameters); + return this; + } + + public OperationBuilder produces(String... produces) { + return produces(List.of(produces)); + } + + public OperationBuilder produces(List produces) { + when(operation.getProduces()).thenReturn(produces); + return this; + } + + public OperationBuilder consumes(String... consumes) { + return consumes(List.of(consumes)); + } + + public OperationBuilder consumes(List consumes) { + when(operation.getConsumes()).thenReturn(consumes); + return this; + } + + public OperationBuilder method(String method) { + when(operation.getMethod()).thenReturn(method); + return this; + } + + public OperationBuilder pattern(String pattern) { + when(operation.getPattern()).thenReturn(pattern); + return this; + } + + public OperationBuilder response(Object body, StatusCode code, String contentType) { + var schemas = ModelConvertersExt.getInstance().read(body.getClass()); + var mediaType = new io.swagger.v3.oas.models.media.MediaType(); + mediaType.schema(schemas.get(body.getClass().getSimpleName())); + + var content = new Content(); + content.addMediaType(contentType, mediaType); + + ResponseExt response = mock(ResponseExt.class); + when(response.getContent()).thenReturn(content); + when(response.getCode()).thenReturn(Integer.toString(code.value())); + + if (responses == null) { + responses = mock(ApiResponses.class); + when(operation.getResponses()).thenReturn(responses); + when(operation.getDefaultResponse()).thenReturn(response); + } + when(responses.get(Integer.toString(code.value()))).thenReturn(response); + return this; + } + + public OperationBuilder defaultResponse() { + return response(Map.of(), StatusCode.OK, "application/json"); + } + + public OperationBuilder body(Object body, String contentType) { + consumes(contentType); + var schemas = ModelConverters.getInstance().read(body.getClass()); + var mediaType = new io.swagger.v3.oas.models.media.MediaType(); + mediaType.schema(schemas.get(body.getClass().getSimpleName())); + + var content = new Content(); + content.addMediaType(contentType, mediaType); + + var requestBodyExt = mock(RequestBodyExt.class); + when(requestBodyExt.getContent()).thenReturn(content); + when(requestBodyExt.getJavaType()).thenReturn(body.getClass().getName()); + when(operation.getRequestBody()).thenReturn(requestBodyExt); + return this; + } + + public OperationExt build() { + return operation; + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java index d25e099466..cbe69d5b78 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java @@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; +import io.jooby.openapi.CurrentDir; import io.jooby.openapi.OpenAPIResult; import io.jooby.openapi.OpenAPITest; @@ -17,6 +18,89 @@ public void shouldGenerateMvcDoc(OpenAPIResult result) { checkResult(result); } + @OpenAPITest(value = AppLibrary.class) + public void shouldGenerateAdoc(OpenAPIResult result) { + assertEquals( + """ + = Library API. + Jooby Doc; + :doctype: book + :icons: font + :source-highlighter: highlightjs + :toc: left + :toclevels: 4 + :sectlinks: + + == Introduction + + Available data: Books and authors. + + == Support + + Write your questions at support@jooby.io + + [[overview_operations]] + == Operations + + === List Books + + Query books. By using advanced filters. + + [source,bash] + ---- + curl -H 'Accept: application/json'\\ + -X GET 'https://api.fake-museum-example.com/v1/api/library?title=string&author=string&isbn=string1&isbn=string2&isbn=string3' + ---- + + + === Find a book by ISBN + + + [source,bash] + ---- + curl -i\\ + -H 'Accept: application/json'\\ + -X GET 'https://api.fake-museum-example.com/v1/api/library/{isbn}' + ---- + + .A matching book. + [source,json] + ---- + { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "authors" : [ ], + "image" : "binary" + } + ---- + + .Bad Request: For bad ISBN code. + [source,json] + ---- + { + "message" : "...", + "statusCode" : 400, + "reason" : "Bad Request" + } + ---- + + .Not Found: If a book doesn't exist. + [source,json] + ---- + { + "message" : "...", + "statusCode" : 404, + "reason" : "Not Found" + } + ---- + + """, + result.toAsciiDoc(CurrentDir.basedir("src", "test", "resources", "adoc", "library.adoc"))); + } + @OpenAPITest(value = ScriptLibrary.class) public void shouldGenerateScriptDoc(OpenAPIResult result) { checkResult(result); @@ -24,227 +108,232 @@ public void shouldGenerateScriptDoc(OpenAPIResult result) { private void checkResult(OpenAPIResult result) { assertEquals( - "openapi: 3.0.1\n" - + "info:\n" - + " title: Library API.\n" - + " description: \"Available data: Books and authors.\"\n" - + " contact:\n" - + " name: Jooby\n" - + " url: https://jooby.io\n" - + " email: support@jooby.io\n" - + " license:\n" - + " name: Apache\n" - + " url: https://jooby.io/LICENSE\n" - + " version: 4.0.0\n" - + " x-logo:\n" - + " url: https://redocly.github.io/redoc/museum-logo.png\n" - + " altText: Museum logo\n" - + "servers:\n" - + "- url: https://api.fake-museum-example.com/v1\n" - + "tags:\n" - + "- name: Library\n" - + " description: Access to all books.\n" - + "- name: Author\n" - + " description: Oxxx\n" - + "paths:\n" - + " /api/library/{isbn}:\n" - + " summary: Library API.\n" - + " description: \"Contains all operations for creating, updating and fetching" - + " books.\"\n" - + " get:\n" - + " tags:\n" - + " - Library\n" - + " - Book\n" - + " - Author\n" - + " summary: Find a book by isbn.\n" - + " operationId: bookByIsbn\n" - + " parameters:\n" - + " - name: isbn\n" - + " in: path\n" - + " description: Book isbn. Like IK-1900.\n" - + " required: true\n" - + " schema:\n" - + " type: string\n" - + " responses:\n" - + " \"200\":\n" - + " description: A matching book.\n" - + " content:\n" - + " application/json:\n" - + " schema:\n" - + " $ref: \"#/components/schemas/Book\"\n" - + " \"404\":\n" - + " description: \"Not Found: If a book doesn't exist.\"\n" - + " \"400\":\n" - + " description: \"Bad Request: For bad ISBN code.\"\n" - + " /api/library/author/{id}:\n" - + " summary: Library API.\n" - + " description: \"Contains all operations for creating, updating and fetching" - + " books.\"\n" - + " get:\n" - + " tags:\n" - + " - Library\n" - + " - Author\n" - + " summary: Author by Id.\n" - + " operationId: author\n" - + " parameters:\n" - + " - name: id\n" - + " in: path\n" - + " description: Author ID.\n" - + " required: true\n" - + " schema:\n" - + " type: string\n" - + " responses:\n" - + " \"200\":\n" - + " description: An author\n" - + " content:\n" - + " application/json:\n" - + " schema:\n" - + " $ref: \"#/components/schemas/Author\"\n" - + " /api/library:\n" - + " summary: Library API.\n" - + " description: \"Contains all operations for creating, updating and fetching" - + " books.\"\n" - + " get:\n" - + " tags:\n" - + " - Library\n" - + " summary: Query books.\n" - + " operationId: query\n" - + " parameters:\n" - + " - name: title\n" - + " in: query\n" - + " description: Book's title.\n" - + " schema:\n" - + " type: string\n" - + " - name: author\n" - + " in: query\n" - + " description: Book's author. Optional.\n" - + " schema:\n" - + " type: string\n" - + " - name: isbn\n" - + " in: query\n" - + " description: Book's isbn. Optional.\n" - + " schema:\n" - + " type: string\n" - + " responses:\n" - + " \"200\":\n" - + " description: Matching books.\n" - + " content:\n" - + " application/json:\n" - + " schema:\n" - + " type: array\n" - + " items:\n" - + " $ref: \"#/components/schemas/Book\"\n" - + " x-badges:\n" - + " - name: Beta\n" - + " position: before\n" - + " color: purple\n" - + " post:\n" - + " tags:\n" - + " - Library\n" - + " - Author\n" - + " summary: Creates a new book.\n" - + " description: Book can be created or updated.\n" - + " operationId: createBook\n" - + " requestBody:\n" - + " description: Book to create.\n" - + " content:\n" - + " application/json:\n" - + " schema:\n" - + " $ref: \"#/components/schemas/Book\"\n" - + " example:\n" - + " isbn: X01981\n" - + " title: HarryPotter\n" - + " required: true\n" - + " responses:\n" - + " \"200\":\n" - + " description: Saved book.\n" - + " content:\n" - + " application/json:\n" - + " schema:\n" - + " $ref: \"#/components/schemas/Book\"\n" - + " example:\n" - + " id: generatedId\n" - + " isbn: '...'\n" - + "components:\n" - + " schemas:\n" - + " Author:\n" - + " type: object\n" - + " properties:\n" - + " ssn:\n" - + " type: string\n" - + " description: Social security number.\n" - + " name:\n" - + " type: string\n" - + " description: Author's name.\n" - + " address:\n" - + " $ref: \"#/components/schemas/Address\"\n" - + " books:\n" - + " uniqueItems: true\n" - + " type: array\n" - + " description: Published books.\n" - + " items:\n" - + " $ref: \"#/components/schemas/Book\"\n" - + " BookQuery:\n" - + " type: object\n" - + " properties:\n" - + " title:\n" - + " type: string\n" - + " description: Book's title.\n" - + " author:\n" - + " type: string\n" - + " description: Book's author. Optional.\n" - + " isbn:\n" - + " type: string\n" - + " description: Book's isbn. Optional.\n" - + " description: Query books by complex filters.\n" - + " Address:\n" - + " type: object\n" - + " properties:\n" - + " street:\n" - + " type: string\n" - + " description: Street name.\n" - + " city:\n" - + " type: string\n" - + " description: City name.\n" - + " state:\n" - + " type: string\n" - + " description: State.\n" - + " country:\n" - + " type: string\n" - + " description: Two digit country code.\n" - + " description: Author address.\n" - + " Book:\n" - + " type: object\n" - + " properties:\n" - + " isbn:\n" - + " type: string\n" - + " description: Book ISBN. Method.\n" - + " title:\n" - + " type: string\n" - + " description: Book's title.\n" - + " publicationDate:\n" - + " type: string\n" - + " description: Publication date. Format mm-dd-yyyy.\n" - + " format: date\n" - + " text:\n" - + " type: string\n" - + " type:\n" - + " type: string\n" - + " description: |-\n" - + " Book type.\n" - + " - Fiction: Fiction books are based on imaginary characters and events," - + " while non-fiction books are based o n real people and events.\n" - + " - NonFiction: Non-fiction genres include biography, autobiography," - + " history, self-help, and true crime.\n" - + " enum:\n" - + " - Fiction\n" - + " - NonFiction\n" - + " authors:\n" - + " uniqueItems: true\n" - + " type: array\n" - + " items:\n" - + " $ref: \"#/components/schemas/Author\"\n" - + " description: Book model.\n", + """ + openapi: 3.0.1 + info: + title: Library API. + description: "Available data: Books and authors." + contact: + name: Jooby + url: https://jooby.io + email: support@jooby.io + license: + name: Apache + url: https://jooby.io/LICENSE + version: 4.0.0 + x-logo: + url: https://redocly.github.io/redoc/museum-logo.png + altText: Museum logo + servers: + - url: https://api.fake-museum-example.com/v1 + tags: + - name: Library + description: Access to all books. + - name: Author + description: Oxxx + paths: + /api/library/{isbn}: + summary: Library API. + description: "Contains all operations for creating, updating and fetching books." + get: + tags: + - Library + - Book + - Author + summary: Find a book by isbn. + operationId: bookByIsbn + parameters: + - name: isbn + in: path + description: Book isbn. Like IK-1900. + required: true + schema: + type: string + responses: + "200": + description: A matching book. + content: + application/json: + schema: + $ref: "#/components/schemas/Book" + "404": + description: "Not Found: If a book doesn't exist." + "400": + description: "Bad Request: For bad ISBN code." + /api/library/author/{id}: + summary: Library API. + description: "Contains all operations for creating, updating and fetching books." + get: + tags: + - Library + - Author + summary: Author by Id. + operationId: author + parameters: + - name: id + in: path + description: Author ID. + required: true + schema: + type: string + responses: + "200": + description: An author + content: + application/json: + schema: + $ref: "#/components/schemas/Author" + /api/library: + summary: Library API. + description: "Contains all operations for creating, updating and fetching books." + get: + tags: + - Library + summary: Query books. + description: By using advanced filters. + operationId: query + parameters: + - name: title + in: query + description: Book's title. + schema: + type: string + - name: author + in: query + description: Book's author. Optional. + schema: + type: string + - name: isbn + in: query + description: Book's isbn. Optional. + schema: + type: array + items: + type: string + responses: + "200": + description: Matching books. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Book" + x-badges: + - name: Beta + position: before + color: purple + post: + tags: + - Library + - Author + summary: Creates a new book. + description: Book can be created or updated. + operationId: createBook + requestBody: + description: Book to create. + content: + application/json: + schema: + $ref: "#/components/schemas/Book" + example: + isbn: X01981 + title: HarryPotter + required: true + responses: + "200": + description: Saved book. + content: + application/json: + schema: + $ref: "#/components/schemas/Book" + example: + id: generatedId + isbn: '...' + components: + schemas: + Author: + type: object + properties: + ssn: + type: string + description: Social security number. + name: + type: string + description: Author's name. + address: + $ref: "#/components/schemas/Address" + books: + uniqueItems: true + type: array + description: Published books. + items: + $ref: "#/components/schemas/Book" + BookQuery: + type: object + properties: + title: + type: string + description: Book's title. + author: + type: string + description: Book's author. Optional. + isbn: + type: array + description: Book's isbn. Optional. + items: + type: string + description: Query books by complex filters. + Address: + type: object + properties: + street: + type: string + description: Street name. + city: + type: string + description: City name. + state: + type: string + description: State. + country: + type: string + description: Two digit country code. + description: Author address. + Book: + type: object + properties: + isbn: + type: string + description: Book ISBN. Method. + title: + type: string + description: Book's title. + publicationDate: + type: string + description: Publication date. Format mm-dd-yyyy. + format: date + text: + type: string + type: + type: string + description: |- + Book type. + - Fiction: Fiction books are based on imaginary characters and events, while non-fiction books are based o n real people and events. + - NonFiction: Non-fiction genres include biography, autobiography, history, self-help, and true crime. + enum: + - Fiction + - NonFiction + authors: + uniqueItems: true + type: array + items: + $ref: "#/components/schemas/Author" + image: + type: string + format: binary + description: Book model. + """, result.toYaml()); } } diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/Book.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/Book.java index bfc4c2bc3e..c35b42135e 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/Book.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/Book.java @@ -8,6 +8,8 @@ import java.time.LocalDate; import java.util.Set; +import io.jooby.FileUpload; + /** Book model. */ public class Book { /** Book ISBN. */ @@ -26,6 +28,8 @@ public class Book { Set authors; + FileUpload image; + /** * Book ISBN. Method. * @@ -78,4 +82,12 @@ public Set getAuthors() { public void setAuthors(Set authors) { this.authors = authors; } + + public FileUpload getImage() { + return image; + } + + public void setImage(FileUpload image) { + this.image = image; + } } diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/BookError.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/BookError.java new file mode 100644 index 0000000000..0181bf5d54 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/BookError.java @@ -0,0 +1,41 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3729.api; + +/** Book model. */ +public class BookError { + /** Book resource path. */ + private String path; + + /** Book's error message. */ + private String message; + + private int code; + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public int getCode() { + return code; + } + + public void setCode(int code) { + this.code = code; + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/BookQuery.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/BookQuery.java index 51cc1f1493..e03f0c814c 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/BookQuery.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/BookQuery.java @@ -5,6 +5,8 @@ */ package issues.i3729.api; +import java.util.List; + /** * Query books by complex filters. * @@ -12,4 +14,4 @@ * @param author Book's author. Optional. * @param isbn Book's isbn. Optional. */ -public record BookQuery(String title, String author, String isbn) {} +public record BookQuery(String title, String author, List isbn) {} diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java index 601b71c102..93340cfa08 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java @@ -51,7 +51,7 @@ public Author author(@PathParam String id) { } /** - * Query books. + * Query books. By using advanced filters. * * @param query Book's param query. * @return Matching books. diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/ScriptLibrary.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/ScriptLibrary.java index 8dfe891acb..f4d762decf 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/ScriptLibrary.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/ScriptLibrary.java @@ -58,7 +58,7 @@ public class ScriptLibrary extends Jooby { }); /* - * Query books. + * Query books. By using advanced filters. * * @param query Book's param query. * @return Matching books. diff --git a/modules/jooby-openapi/src/test/resources/adoc/library.adoc b/modules/jooby-openapi/src/test/resources/adoc/library.adoc index 1409523c4e..95621a9c75 100644 --- a/modules/jooby-openapi/src/test/resources/adoc/library.adoc +++ b/modules/jooby-openapi/src/test/resources/adoc/library.adoc @@ -1,4 +1,4 @@ -= RESTful Notes API Guide += ${info.title} Jooby Doc; :doctype: book :icons: font @@ -7,12 +7,27 @@ Jooby Doc; :toclevels: 4 :sectlinks: -[[overview]] -= Overview +== Introduction -[[overview_http_verbs]] -== HTTP verbs +${info.description} -${ operation("GET", "/api/library/{isbn}") | curl("-i") } +== Support -Response: +Write your questions at ${info.contact.email} + +[[overview_operations]] +== Operations + +=== List Books +{% set listBooks = operation("GET", "/api/library") %} +${listBooks.summary} ${listBooks.description} + +${ listBooks | curl } + +=== Find a book by ISBN + +{% set bookByISBN = operation("GET", "/api/library/{isbn}") %} +${ bookByISBN | curl("-i") } +${ bookByISBN | statusCode(200) } +${ bookByISBN | statusCode(400) } +${ bookByISBN | statusCode(404) } From 2fb853d0a5a32f822d4277f374e0c9a697349eb0 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Wed, 3 Dec 2025 20:36:00 -0300 Subject: [PATCH 07/31] openapi: asciidoc output #3820 - add more snippets - code clean up --- .../internal/openapi/AsciiDocGenerator.java | 15 +- .../internal/openapi/asciidoc/Filters.java | 10 +- .../openapi/asciidoc/InternalContext.java | 2 +- .../openapi/asciidoc/OperationFilters.java | 201 ++++++++++++-- .../default-cookie-parameters.snippet | 7 + .../asciidoc/default-form-parameters.snippet | 15 +- .../asciidoc/default-http-request.snippet | 2 +- .../asciidoc/default-http-response.snippet | 2 +- .../asciidoc/default-httpie-request.snippet | 4 - .../templates/asciidoc/default-links.snippet | 9 - .../asciidoc/default-path-parameters.snippet | 16 +- .../asciidoc/default-query-parameters.snippet | 15 +- .../asciidoc/default-request-body.snippet | 4 - .../asciidoc/default-request-cookies.snippet | 9 - .../asciidoc/default-request-fields.snippet | 14 +- .../asciidoc/default-request-headers.snippet | 14 +- .../default-request-parameters.snippet | 15 +- .../default-request-part-body.snippet | 4 - .../default-request-part-fields.snippet | 10 - .../asciidoc/default-request-parts.snippet | 9 - .../asciidoc/default-response-body.snippet | 4 - .../asciidoc/default-response-cookies.snippet | 9 - .../asciidoc/default-response-fields.snippet | 14 +- .../asciidoc/default-response-headers.snippet | 9 - ...de.snippet => default-status-code.snippet} | 0 .../internal/openapi/asciidoc/FilterTest.java | 252 ++++++++++++++++++ .../io/jooby/openapi/OperationBuilder.java | 8 + .../java/issues/i3729/api/ApiDocTest.java | 61 ++++- .../src/test/java/issues/i3729/api/Book.java | 1 + .../src/test/resources/adoc/library.adoc | 8 + 30 files changed, 577 insertions(+), 166 deletions(-) create mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-cookie-parameters.snippet delete mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-httpie-request.snippet delete mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-links.snippet delete mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-body.snippet delete mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-cookies.snippet delete mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-part-body.snippet delete mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-part-fields.snippet delete mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-parts.snippet delete mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-body.snippet delete mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-cookies.snippet delete mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-headers.snippet rename modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/{default-statusCode.snippet => default-status-code.snippet} (100%) diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AsciiDocGenerator.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AsciiDocGenerator.java index bc9de9a48c..6799a31201 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AsciiDocGenerator.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AsciiDocGenerator.java @@ -38,7 +38,7 @@ public static String generate(OpenAPIExt openAPI, Path index) throws IOException var writer = new StringWriter(); var context = new HashMap(); template.evaluate(writer, context); - return writer.toString(); + return writer.toString().trim(); } @SuppressWarnings("unchecked") @@ -52,19 +52,10 @@ private static PebbleEngine newEngine(OpenAPIExt openapi, Path baseDir) { .map(Server::getUrl) .orElse(""); var openapiRoot = json.convertValue(openapi, Map.class); + openapiRoot.put("openapi", openapi); openapiRoot.put( "internal", - Map.of( - "openapi", - openapi, - "resolver", - snippetResolver, - "serverUrl", - serverUrl, - "json", - json, - "yaml", - yaml)); + Map.of("resolver", snippetResolver, "serverUrl", serverUrl, "json", json, "yaml", yaml)); var engine = newEngine(new OpenApiSupport(openapiRoot)); snippetResolver.setEngine(engine); return engine; diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Filters.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Filters.java index ef00988971..6ce3a4395d 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Filters.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Filters.java @@ -32,7 +32,9 @@ public Object apply( throws PebbleException { try { var json = InternalContext.json(context); - return json.writer().withDefaultPrettyPrinter().writeValueAsString(input); + return "[source,json]\n----\n" + + json.writer().withDefaultPrettyPrinter().writeValueAsString(input) + + "\n----"; } catch (JsonProcessingException e) { throw new PebbleException( e, "Could not convert to JSON: " + input, lineNumber, self.getName()); @@ -56,7 +58,9 @@ public Object apply( throws PebbleException { try { var yaml = InternalContext.yaml(context); - return yaml.writer().withDefaultPrettyPrinter().writeValueAsString(input); + return "[source,yaml]\n----\n" + + yaml.writer().withDefaultPrettyPrinter().writeValueAsString(input) + + "----"; } catch (JsonProcessingException e) { throw new PebbleException( e, "Could not convert to YAML: " + input, lineNumber, self.getName()); @@ -70,7 +74,7 @@ public static Map allFilters() { functions.put(value.name(), value); } for (var value : OperationFilters.values()) { - functions.put(value.id(), value); + functions.put(value.name(), value); } return functions; } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/InternalContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/InternalContext.java index 80eeb7ace3..1f5181266c 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/InternalContext.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/InternalContext.java @@ -24,7 +24,7 @@ public static T variable(EvaluationContext context, String name) { } public static OpenAPIExt openApi(EvaluationContext context) { - return internal(context, "openapi"); + return (OpenAPIExt) context.getVariable("openapi"); } public static SnippetResolver resolver(EvaluationContext context) { diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java index 8b9dd27c95..56c6115ad4 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java @@ -7,12 +7,16 @@ import java.util.*; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; +import com.google.common.base.CaseFormat; +import com.google.common.base.Predicate; import com.google.common.base.Splitter; import com.google.common.collect.*; import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; import io.jooby.MediaType; import io.jooby.StatusCode; import io.jooby.internal.openapi.OperationExt; @@ -160,10 +164,26 @@ protected Object doApply( snippetContext.put("headers", snippetContext.get("requestHeaders")); return resolver.apply(id(), snippetContext); } - + }, + requestFields { @Override - protected String id() { - return "http-request"; + protected Object doApply( + SnippetResolver resolver, + OperationExt operation, + Map snippetContext, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws Exception { + /* Body */ + List> fields = List.of(); + if (operation.getRequestBody() != null) { + var schema = schema(context, operation.getRequestBody()); + fields = schemaToTable(schema); + } + snippetContext.put("fields", fields); + return resolver.apply(id(), snippetContext); } }, httpResponse { @@ -180,14 +200,8 @@ protected Object doApply( /* Body */ var requestBodyString = ""; var statusCode = findStatusCode(args); - ResponseExt response; - if (statusCode != null) { - response = (ResponseExt) operation.getResponses().get(Integer.toString(statusCode.value())); - if (response == null) { - throw new IllegalArgumentException("No response: " + statusCode.value()); - } - } else { - response = operation.getDefaultResponse(); + var response = responseByStatusCode(operation, statusCode); + if (statusCode == null) { statusCode = StatusCode.valueOf(Integer.parseInt(response.getCode())); } var json = InternalContext.json(context); @@ -201,10 +215,100 @@ protected Object doApply( snippetContext.put("headers", snippetContext.get("responseHeaders")); return resolver.apply(id(), snippetContext); } - + }, + responseFields { + @Override + protected Object doApply( + SnippetResolver resolver, + OperationExt operation, + Map snippetContext, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws Exception { + /* Body */ + var statusCode = findStatusCode(args); + var response = responseByStatusCode(operation, statusCode); + var schema = schema(context, response); + snippetContext.put("fields", schemaToTable(schema)); + return resolver.apply(id(), snippetContext); + } + }, + formParameters { + @Override + protected Object doApply( + SnippetResolver resolver, + OperationExt operation, + Map snippetContext, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws Exception { + snippetContext.put("parameters", parametersToTable(operation, p -> "form".equals(p.getIn()))); + return resolver.apply(id(), snippetContext); + } + }, + queryParameters { + @Override + protected Object doApply( + SnippetResolver resolver, + OperationExt operation, + Map snippetContext, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws Exception { + snippetContext.put( + "parameters", parametersToTable(operation, p -> "query".equals(p.getIn()))); + return resolver.apply(id(), snippetContext); + } + }, + pathParameters { @Override - protected String id() { - return "http-response"; + protected Object doApply( + SnippetResolver resolver, + OperationExt operation, + Map snippetContext, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws Exception { + snippetContext.put("parameters", parametersToTable(operation, p -> "path".equals(p.getIn()))); + return resolver.apply(id(), snippetContext); + } + }, + cookieParameters { + @Override + protected Object doApply( + SnippetResolver resolver, + OperationExt operation, + Map snippetContext, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws Exception { + snippetContext.put("cookies", parametersToTable(operation, p -> "cookie".equals(p.getIn()))); + return resolver.apply(id(), snippetContext); + } + }, + requestParameters { + @Override + protected Object doApply( + SnippetResolver resolver, + OperationExt operation, + Map snippetContext, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws Exception { + snippetContext.put("parameters", parametersToTable(operation, p -> true)); + return resolver.apply(id(), snippetContext); } }, schema { @@ -256,7 +360,7 @@ protected Object doApply( int lineNumber) throws Exception { var statusCode = findStatusCode(args); - var response = operation.getResponses().get(Integer.toString(statusCode.value())); + var response = responseByStatusCode(operation, statusCode, null); if (response == null) { throw new IllegalArgumentException("No response for: " + statusCode); } @@ -285,8 +389,8 @@ protected Object doApply( } }; - protected String id() { - return name(); + protected final String id() { + return CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_HYPHEN, name()); } @Override @@ -329,6 +433,25 @@ public Object apply( } } + protected ResponseExt responseByStatusCode( + OperationExt operation, @Nullable StatusCode statusCode) { + return responseByStatusCode(operation, statusCode, operation.getDefaultResponse()); + } + + protected ResponseExt responseByStatusCode( + OperationExt operation, @Nullable StatusCode statusCode, ResponseExt defaultResponse) { + ResponseExt response; + if (statusCode != null) { + response = (ResponseExt) operation.getResponses().get(Integer.toString(statusCode.value())); + if (response == null) { + throw new IllegalArgumentException("No response: " + statusCode.value()); + } + } else { + response = defaultResponse; + } + return response; + } + protected Map newSnippetContext(String serverUrl, OperationExt operation) { Map map = new HashMap<>(); map.put("pattern", operation.getPattern()); @@ -353,8 +476,10 @@ protected Map newSnippetContext(String serverUrl, OperationExt o operation.getConsumes().forEach(value -> requestHeaders.put("Content-Type", value)); } - map.put("requestHeaders", requestHeaders.entries()); - map.put("responseHeaders", responseHeaders.entries()); + Function, Map> mapper = + e -> Map.of("name", e.getKey(), "value", e.getValue()); + map.put("requestHeaders", requestHeaders.entries().stream().map(mapper).toList()); + map.put("responseHeaders", responseHeaders.entries().stream().map(mapper).toList()); return map; } @@ -371,6 +496,44 @@ protected StatusCode findStatusCode(Map args) { return null; } + protected List> schemaToTable(Schema schema) { + List> fields = new ArrayList<>(); + SchemaData.from(schema) + .forEach( + (name, type) -> { + var field = new LinkedHashMap(); + field.put("name", name); + field.put("type", type.toString()); + var property = schema.getProperties().get(name); + if (property != null) { + field.put("description", property.getDescription()); + } + fields.add(field); + }); + return fields; + } + + protected List> parametersToTable( + OperationExt operation, Predicate predicate) { + List> fields = new ArrayList<>(); + var parameters = + Optional.ofNullable(operation.getParameters()).orElse(List.of()).stream() + .filter(predicate) + .sorted(Comparator.comparing(Parameter::getName)) + .toList(); + parameters.forEach( + it -> { + var schema = it.getSchema(); + var field = new LinkedHashMap(); + field.put("name", it.getName()); + field.put("type", SchemaData.shemaType(schema)); + field.put("description", it.getDescription()); + field.put("in", it.getIn()); + fields.add(field); + }); + return fields; + } + protected Schema schema(EvaluationContext context, Object input) { var schema = switch (input) { diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-cookie-parameters.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-cookie-parameters.snippet new file mode 100644 index 0000000000..7b5abf7a42 --- /dev/null +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-cookie-parameters.snippet @@ -0,0 +1,7 @@ +|=== +|Parameter|Description +{% for cookie in cookies %} +|`+${cookie.name}+` +|${cookie.description} +{% endfor %} +|=== diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-form-parameters.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-form-parameters.snippet index 9e6f6888a5..af4e93ba9e 100644 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-form-parameters.snippet +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-form-parameters.snippet @@ -1,9 +1,8 @@ |=== -|Parameter|Description - -{{#parameters}} -|{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}} -|{{#tableCellContent}}{{description}}{{/tableCellContent}} - -{{/parameters}} -|=== \ No newline at end of file +|Parameter|Type|Description +{% for parameter in parameters %} +|`+${parameter.name}+` +|`+${parameter.type}+` +|${parameter.description} +{% endfor %} +|=== diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-request.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-request.snippet index 2b404bac71..ba4c36ac21 100644 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-request.snippet +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-request.snippet @@ -2,7 +2,7 @@ ---- ${method} ${path} HTTP/1.1 {% for h in headers -%} -${h.key}: ${h.value} +${h.name}: ${h.value} {% endfor -%} ${requestBody -} ---- diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-response.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-response.snippet index 1ed23e6e6e..15791b78a0 100644 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-response.snippet +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-response.snippet @@ -2,7 +2,7 @@ ---- HTTP/1.1 ${statusCode} ${statusReason} {% for h in headers -%} -${h.key}: ${h.value} +${h.name}: ${h.value} {% endfor -%} ${responseBody -} ---- diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-httpie-request.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-httpie-request.snippet deleted file mode 100644 index 1b690a5f57..0000000000 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-httpie-request.snippet +++ /dev/null @@ -1,4 +0,0 @@ -[source,bash] ----- -$ {{echoContent}}http {{options}} {{url}}{{requestItems}} ----- \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-links.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-links.snippet deleted file mode 100644 index fda4f83764..0000000000 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-links.snippet +++ /dev/null @@ -1,9 +0,0 @@ -|=== -|Relation|Description - -{{#links}} -|{{#tableCellContent}}`+{{rel}}+`{{/tableCellContent}} -|{{#tableCellContent}}{{description}}{{/tableCellContent}} - -{{/links}} -|=== \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-path-parameters.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-path-parameters.snippet index 6976b7b28b..af4e93ba9e 100644 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-path-parameters.snippet +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-path-parameters.snippet @@ -1,10 +1,8 @@ -.+{{path}}+ |=== -|Parameter|Description - -{{#parameters}} -|{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}} -|{{#tableCellContent}}{{description}}{{/tableCellContent}} - -{{/parameters}} -|=== \ No newline at end of file +|Parameter|Type|Description +{% for parameter in parameters %} +|`+${parameter.name}+` +|`+${parameter.type}+` +|${parameter.description} +{% endfor %} +|=== diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-query-parameters.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-query-parameters.snippet index 9e6f6888a5..af4e93ba9e 100644 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-query-parameters.snippet +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-query-parameters.snippet @@ -1,9 +1,8 @@ |=== -|Parameter|Description - -{{#parameters}} -|{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}} -|{{#tableCellContent}}{{description}}{{/tableCellContent}} - -{{/parameters}} -|=== \ No newline at end of file +|Parameter|Type|Description +{% for parameter in parameters %} +|`+${parameter.name}+` +|`+${parameter.type}+` +|${parameter.description} +{% endfor %} +|=== diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-body.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-body.snippet deleted file mode 100644 index 00da2d0850..0000000000 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-body.snippet +++ /dev/null @@ -1,4 +0,0 @@ -[source{{#language}},{{language}}{{/language}},options="nowrap"] ----- -{{body}} ----- \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-cookies.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-cookies.snippet deleted file mode 100644 index 0c5315051f..0000000000 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-cookies.snippet +++ /dev/null @@ -1,9 +0,0 @@ -|=== -|Name|Description - -{{#cookies}} -|{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}} -|{{#tableCellContent}}{{description}}{{/tableCellContent}} - -{{/cookies}} -|=== \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-fields.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-fields.snippet index 0d8f18e934..1e7c540340 100644 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-fields.snippet +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-fields.snippet @@ -1,10 +1,8 @@ |=== |Path|Type|Description - -{{#fields}} -|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}} -|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}} -|{{#tableCellContent}}{{description}}{{/tableCellContent}} - -{{/fields}} -|=== \ No newline at end of file +{% for field in fields %} +|`+${field.name}+` +|`+${field.type}+` +|${field.description} +{% endfor %} +|=== diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-headers.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-headers.snippet index 5a8593332a..72964783a6 100644 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-headers.snippet +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-headers.snippet @@ -1,9 +1,7 @@ |=== -|Name|Description - -{{#headers}} -|{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}} -|{{#tableCellContent}}{{description}}{{/tableCellContent}} - -{{/headers}} -|=== \ No newline at end of file +|Parameter|Description +{% for header in headers %} +|`+${header.name}+` +|${header.description} +{% endfor %} +|=== diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-parameters.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-parameters.snippet index 9e6f6888a5..af4e93ba9e 100644 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-parameters.snippet +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-parameters.snippet @@ -1,9 +1,8 @@ |=== -|Parameter|Description - -{{#parameters}} -|{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}} -|{{#tableCellContent}}{{description}}{{/tableCellContent}} - -{{/parameters}} -|=== \ No newline at end of file +|Parameter|Type|Description +{% for parameter in parameters %} +|`+${parameter.name}+` +|`+${parameter.type}+` +|${parameter.description} +{% endfor %} +|=== diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-part-body.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-part-body.snippet deleted file mode 100644 index 00da2d0850..0000000000 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-part-body.snippet +++ /dev/null @@ -1,4 +0,0 @@ -[source{{#language}},{{language}}{{/language}},options="nowrap"] ----- -{{body}} ----- \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-part-fields.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-part-fields.snippet deleted file mode 100644 index 0d8f18e934..0000000000 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-part-fields.snippet +++ /dev/null @@ -1,10 +0,0 @@ -|=== -|Path|Type|Description - -{{#fields}} -|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}} -|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}} -|{{#tableCellContent}}{{description}}{{/tableCellContent}} - -{{/fields}} -|=== \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-parts.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-parts.snippet deleted file mode 100644 index 23a23436cd..0000000000 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-parts.snippet +++ /dev/null @@ -1,9 +0,0 @@ -|=== -|Part|Description - -{{#requestParts}} -|{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}} -|{{#tableCellContent}}{{description}}{{/tableCellContent}} - -{{/requestParts}} -|=== \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-body.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-body.snippet deleted file mode 100644 index 00da2d0850..0000000000 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-body.snippet +++ /dev/null @@ -1,4 +0,0 @@ -[source{{#language}},{{language}}{{/language}},options="nowrap"] ----- -{{body}} ----- \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-cookies.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-cookies.snippet deleted file mode 100644 index 0c5315051f..0000000000 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-cookies.snippet +++ /dev/null @@ -1,9 +0,0 @@ -|=== -|Name|Description - -{{#cookies}} -|{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}} -|{{#tableCellContent}}{{description}}{{/tableCellContent}} - -{{/cookies}} -|=== \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-fields.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-fields.snippet index 0d8f18e934..1e7c540340 100644 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-fields.snippet +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-fields.snippet @@ -1,10 +1,8 @@ |=== |Path|Type|Description - -{{#fields}} -|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}} -|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}} -|{{#tableCellContent}}{{description}}{{/tableCellContent}} - -{{/fields}} -|=== \ No newline at end of file +{% for field in fields %} +|`+${field.name}+` +|`+${field.type}+` +|${field.description} +{% endfor %} +|=== diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-headers.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-headers.snippet deleted file mode 100644 index 5a8593332a..0000000000 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-headers.snippet +++ /dev/null @@ -1,9 +0,0 @@ -|=== -|Name|Description - -{{#headers}} -|{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}} -|{{#tableCellContent}}{{description}}{{/tableCellContent}} - -{{/headers}} -|=== \ No newline at end of file diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-statusCode.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-status-code.snippet similarity index 100% rename from modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-statusCode.snippet rename to modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-status-code.snippet diff --git a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java index cdc9c36aae..227288f41f 100644 --- a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java +++ b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java @@ -73,6 +73,135 @@ public Map getGlobalVariables() { .build()); } + @Test + public void requestParams() { + // Query parameter + assertEquals( + """ + |=== + |Parameter|Type|Description + + |`+bar+` + |`+string+` + | + + |`+foo+` + |`+string+` + | + + |=== + """, + queryParameters.apply( + operation("GET", "/api/library/{isbn}").query("foo", "bar").build(), + args(), + template(), + evaluationContext(), + 1)); + // Path parameter + assertEquals( + """ + |=== + |Parameter|Type|Description + + |`+isbn+` + |`+string+` + | + + |=== + """, + pathParameters.apply( + operation("GET", "/api/library/{isbn}").path("isbn").build(), + args(), + template(), + evaluationContext(), + 1)); + + // Cookie parameter + assertEquals( + """ + |=== + |Parameter|Description + + |`+single-sign-on+` + | + + |=== + """, + cookieParameters.apply( + operation("GET", "/api/library/{isbn}").cookie("single-sign-on").build(), + args(), + template(), + evaluationContext(), + 1)); + + // Form + assertEquals( + """ + |=== + |Parameter|Type|Description + + |`+file+` + |`+binary+` + | + + |`+name+` + |`+string+` + | + + |=== + """, + formParameters.apply( + operation("POST", "/api/library") + .parameter(Map.of("form", mapOf("name", "string", "file", "binary"))) + .consumes("multipart/form-data") + .build(), + args(), + template(), + evaluationContext(), + 1)); + + // All them + assertEquals( + """ + |=== + |Parameter|Type|Description + + |`+active+` + |`+true+` + | + + |`+file+` + |`+binary+` + | + + |`+isbn+` + |`+string+` + | + + |`+name+` + |`+string+` + | + + |=== + """, + requestParameters.apply( + operation("POST", "/api/library") + .parameter( + Map.of( + "form", + mapOf("name", "string", "file", "binary"), + "path", + Map.of("isbn", "string"), + "query", + Map.of("active", "true"))) + .consumes("multipart/form-data") + .build(), + args(), + template(), + evaluationContext(), + 1)); + } + @Test public void curl() { assertEquals( @@ -387,6 +516,129 @@ public void httpResponse() { 1)); } + @Test + public void responseFields() { + assertEquals( + """ + |=== + |Path|Type|Description + + |`+isbn+` + |`+string+` + | + + |`+title+` + |`+string+` + | + + |`+publicationDate+` + |`+date+` + | + + |`+text+` + |`+string+` + | + + |`+type+` + |`+string+` + | + + |`+authors+` + |`+[]+` + | + + |`+image+` + |`+binary+` + | + + |=== + """, + responseFields.apply( + operation("POST", "/api/library") + .produces("application/json") + .response(new Book(), StatusCode.CREATED, "application/json") + .build(), + args(), + template(), + evaluationContext(), + 1)); + + assertEquals( + """ + |=== + |Path|Type|Description + + |`+isbn+` + |`+string+` + | + + |`+title+` + |`+string+` + | + + |`+publicationDate+` + |`+date+` + | + + |`+text+` + |`+string+` + | + + |`+type+` + |`+string+` + | + + |`+authors+` + |`+[]+` + | + + |`+image+` + |`+binary+` + | + + |=== + """, + responseFields.apply( + operation("POST", "/api/library") + .produces("application/json") + .response(new Book(), StatusCode.CREATED, "application/json") + .build(), + args(), + template(), + evaluationContext(), + 1)); + + assertEquals( + """ + |=== + |Path|Type|Description + + |`+path+` + |`+string+` + | + + |`+message+` + |`+string+` + | + + |`+code+` + |`+int32+` + | + + |=== + """, + responseFields.apply( + operation("POST", "/api/library") + .produces("application/json") + .response(new Book(), StatusCode.CREATED, "application/json") + .response(new BookError(), StatusCode.BAD_REQUEST, "application/json") + .build(), + args("400"), + template(), + evaluationContext(), + 1)); + } + @Test public void statusCode() { assertEquals( diff --git a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OperationBuilder.java b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OperationBuilder.java index 89bcfc320d..16ca256ade 100644 --- a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OperationBuilder.java +++ b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OperationBuilder.java @@ -44,6 +44,14 @@ public OperationBuilder form(String... name) { return parameter(Map.of("form", mapOf(name))); } + public OperationBuilder path(String... name) { + return parameter(Map.of("path", mapOf(name))); + } + + public OperationBuilder cookie(String... name) { + return parameter(Map.of("cookie", mapOf(name))); + } + private static Map mapOf(String... values) { Map map = new LinkedHashMap<>(); for (var value : values) { diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java index cbe69d5b78..70a4fc6aea 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java @@ -53,6 +53,26 @@ public void shouldGenerateAdoc(OpenAPIResult result) { ---- + ==== Request Fields + + |=== + |Parameter|Type|Description + + |`+author+` + |`+string+` + |Book's author. Optional. + + |`+isbn+` + |`+array+` + |Book's isbn. Optional. + + |`+title+` + |`+string+` + |Book's title. + + |=== + + === Find a book by ISBN @@ -97,7 +117,45 @@ public void shouldGenerateAdoc(OpenAPIResult result) { } ---- - """, + + ==== Response Fields + + |=== + |Path|Type|Description + + |`+isbn+` + |`+string+` + |Book ISBN. Method. + + |`+title+` + |`+string+` + |Book's title. + + |`+publicationDate+` + |`+date+` + |Publication date. Format mm-dd-yyyy. + + |`+text+` + |`+string+` + |Book's content. + + |`+type+` + |`+string+` + |Book type. + - Fiction: Fiction books are based on imaginary characters and events, while non-fiction books are based o n real people and events. + - NonFiction: Non-fiction genres include biography, autobiography, history, self-help, and true crime. + + |`+authors+` + |`+[]+` + | + + |`+image+` + |`+binary+` + | + + |=== + """ + .trim(), result.toAsciiDoc(CurrentDir.basedir("src", "test", "resources", "adoc", "library.adoc"))); } @@ -315,6 +373,7 @@ private void checkResult(OpenAPIResult result) { format: date text: type: string + description: Book's content. type: type: string description: |- diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/Book.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/Book.java index c35b42135e..7104e464b2 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/Book.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/Book.java @@ -21,6 +21,7 @@ public class Book { /** Publication date. Format mm-dd-yyyy. */ LocalDate publicationDate; + /** Book's content. */ String text; /** Book type. */ diff --git a/modules/jooby-openapi/src/test/resources/adoc/library.adoc b/modules/jooby-openapi/src/test/resources/adoc/library.adoc index 95621a9c75..f6b629624a 100644 --- a/modules/jooby-openapi/src/test/resources/adoc/library.adoc +++ b/modules/jooby-openapi/src/test/resources/adoc/library.adoc @@ -24,6 +24,10 @@ ${listBooks.summary} ${listBooks.description} ${ listBooks | curl } +==== Request Fields + +${ listBooks | queryParameters } + === Find a book by ISBN {% set bookByISBN = operation("GET", "/api/library/{isbn}") %} @@ -31,3 +35,7 @@ ${ bookByISBN | curl("-i") } ${ bookByISBN | statusCode(200) } ${ bookByISBN | statusCode(400) } ${ bookByISBN | statusCode(404) } + +==== Response Fields + +${ bookByISBN | responseFields } From 22ddb2a151d707bd3f1235615af9470e84bce5cf Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Wed, 3 Dec 2025 21:09:34 -0300 Subject: [PATCH 08/31] openapi: asciidoc output #3820 - attempt to control new lines --- .../openapi/asciidoc/SnippetResolver.java | 5 +- .../internal/openapi/asciidoc/FilterTest.java | 62 +++++++++---------- .../java/issues/i3729/api/ApiDocTest.java | 4 -- .../src/test/resources/adoc/library.adoc | 4 +- 4 files changed, 37 insertions(+), 38 deletions(-) diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/SnippetResolver.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/SnippetResolver.java index 013e3f1e73..5efe206cd4 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/SnippetResolver.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/SnippetResolver.java @@ -26,8 +26,9 @@ public SnippetResolver(Path baseDir) { public String apply(String snippet, Map context) throws IOException { var writer = new StringWriter(); - var snippetContent = resolve(baseDir, snippet); - engine.getLiteralTemplate(snippetContent).evaluate(writer, context); + var snippetContent = resolve(baseDir, snippet).trim().replaceAll("\r\n", "\n"); + var template = engine.getLiteralTemplate(snippetContent); + template.evaluate(writer, context); return writer.toString(); } diff --git a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java index 227288f41f..a87709978a 100644 --- a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java +++ b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java @@ -89,7 +89,7 @@ public void requestParams() { |`+string+` | - |=== + |===\ """, queryParameters.apply( operation("GET", "/api/library/{isbn}").query("foo", "bar").build(), @@ -107,7 +107,7 @@ public void requestParams() { |`+string+` | - |=== + |===\ """, pathParameters.apply( operation("GET", "/api/library/{isbn}").path("isbn").build(), @@ -125,7 +125,7 @@ public void requestParams() { |`+single-sign-on+` | - |=== + |===\ """, cookieParameters.apply( operation("GET", "/api/library/{isbn}").cookie("single-sign-on").build(), @@ -148,7 +148,7 @@ public void requestParams() { |`+string+` | - |=== + |===\ """, formParameters.apply( operation("POST", "/api/library") @@ -182,7 +182,7 @@ public void requestParams() { |`+string+` | - |=== + |===\ """, requestParameters.apply( operation("POST", "/api/library") @@ -209,7 +209,7 @@ public void curl() { [source,bash] ---- curl -X GET 'https://api.libray.com/api/library/{isbn}' - ---- + ----\ """, curl.apply( operation("GET", "/api/library/{isbn}").build(), @@ -224,7 +224,7 @@ public void curl() { [source,bash] ---- curl -X GET 'https://api.libray.com/api/library/{isbn}?foo=string&bar=string' - ---- + ----\ """, curl.apply( operation("GET", "/api/library/{isbn}").query("foo", "bar").build(), @@ -241,7 +241,7 @@ public void curl() { curl --data-urlencode 'foo=string'\\ --data-urlencode 'bar=string'\\ -X POST 'https://api.libray.com/api/library/{isbn}' - ---- + ----\ """, curl.apply( operation("POST", "/api/library/{isbn}").form("foo", "bar").build(), @@ -258,7 +258,7 @@ public void curl() { curl --data-urlencode 'foo=string'\\ --data-urlencode 'bar=string'\\ -X POST 'https://api.libray.com/api/library/{isbn}?active=boolean' - ---- + ----\ """, curl.apply( operation("POST", "/api/library/{isbn}") @@ -281,7 +281,7 @@ public void curl() { ---- curl -i\\ -X GET 'https://api.libray.com/api/library/{isbn}' - ---- + ----\ """, curl.apply( operation("GET", "/api/library/{isbn}").build(), @@ -297,7 +297,7 @@ public void curl() { ---- curl -i\\ -X POST 'https://api.libray.com/api/library/{isbn}' - ---- + ----\ """, curl.apply( operation("GET", "/api/library/{isbn}").build(), @@ -313,7 +313,7 @@ public void curl() { ---- curl -H 'Accept: application/json'\\ -X GET 'https://api.libray.com/api/library/{isbn}' - ---- + ----\ """, curl.apply( operation("GET", "/api/library/{isbn}").produces("application/json").build(), @@ -329,7 +329,7 @@ public void curl() { ---- curl -H 'Accept: application/xml'\\ -X GET 'https://api.libray.com/api/library/{isbn}' - ---- + ----\ """, curl.apply( operation("GET", "/api/library/{isbn}").produces("application/json").build(), @@ -345,7 +345,7 @@ public void curl() { curl -H 'Content-Type: application/json'\\ -d '{"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"}'\\ -X POST 'https://api.libray.com/api/library' - ---- + ----\ """, curl.apply( operation("POST", "/api/library").body(new Book(), "application/json").build(), @@ -362,7 +362,7 @@ public void curl() { --data-urlencode 'name=string'\\ -F "file=@/file.extension"\\ -X POST 'https://api.libray.com/api/library' - ---- + ----\ """, curl.apply( operation("POST", "/api/library") @@ -382,7 +382,7 @@ public void httpRequest() { [source,http,options="nowrap"] ---- GET /api/library/{isbn} HTTP/1.1 - ---- + ----\ """, httpRequest.apply( operation("GET", "/api/library/{isbn}").build(), @@ -397,7 +397,7 @@ public void httpRequest() { ---- GET /api/library/{isbn} HTTP/1.1 Accept: application/json - ---- + ----\ """, httpRequest.apply( operation("GET", "/api/library/{isbn}").produces("application/json").build(), @@ -413,7 +413,7 @@ public void httpRequest() { POST /api/library HTTP/1.1 Content-Type: application/json {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} - ---- + ----\ """, httpRequest.apply( operation("POST", "/api/library").body(new Book(), "application/json").build(), @@ -430,7 +430,7 @@ public void httpResponse() { [source,http,options="nowrap"] ---- HTTP/1.1 200 Success - ---- + ----\ """, httpResponse.apply( operation("GET", "/api/library/{isbn}").defaultResponse().build(), @@ -445,7 +445,7 @@ public void httpResponse() { ---- HTTP/1.1 200 Success Content-Type: application/json - ---- + ----\ """, httpResponse.apply( operation("GET", "/api/library/{isbn}") @@ -464,7 +464,7 @@ public void httpResponse() { HTTP/1.1 201 Created Content-Type: application/json {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} - ---- + ----\ """, httpResponse.apply( operation("POST", "/api/library") @@ -483,7 +483,7 @@ public void httpResponse() { HTTP/1.1 201 Created Content-Type: application/json {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} - ---- + ----\ """, httpResponse.apply( operation("POST", "/api/library") @@ -502,7 +502,7 @@ public void httpResponse() { HTTP/1.1 400 Bad Request Content-Type: application/json {"path":"string","message":"string","code":"int32"} - ---- + ----\ """, httpResponse.apply( operation("POST", "/api/library") @@ -551,7 +551,7 @@ public void responseFields() { |`+binary+` | - |=== + |===\ """, responseFields.apply( operation("POST", "/api/library") @@ -596,7 +596,7 @@ public void responseFields() { |`+binary+` | - |=== + |===\ """, responseFields.apply( operation("POST", "/api/library") @@ -625,7 +625,7 @@ public void responseFields() { |`+int32+` | - |=== + |===\ """, responseFields.apply( operation("POST", "/api/library") @@ -655,7 +655,7 @@ public void statusCode() { "authors" : [ ], "image" : "binary" } - ---- + ----\ """, statusCode.apply( operation("POST", "/api/library") @@ -678,7 +678,7 @@ public void statusCode() { "message" : "string", "code" : "int32" } - ---- + ----\ """, statusCode.apply( operation("POST", "/api/library") @@ -700,7 +700,7 @@ public void schema() { [source,json] ---- {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} - ---- + ----\ """, schema.apply( operation("POST", "/api/library") @@ -718,7 +718,7 @@ public void schema() { [source,json] ---- {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} - ---- + ----\ """, schema.apply( operation("POST", "/api/library") @@ -736,7 +736,7 @@ public void schema() { [source,json] ---- {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} - ---- + ----\ """, schema.apply( operation("POST", "/api/library") diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java index 70a4fc6aea..df067159f2 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java @@ -52,7 +52,6 @@ public void shouldGenerateAdoc(OpenAPIResult result) { -X GET 'https://api.fake-museum-example.com/v1/api/library?title=string&author=string&isbn=string1&isbn=string2&isbn=string3' ---- - ==== Request Fields |=== @@ -72,10 +71,8 @@ public void shouldGenerateAdoc(OpenAPIResult result) { |=== - === Find a book by ISBN - [source,bash] ---- curl -i\\ @@ -117,7 +114,6 @@ public void shouldGenerateAdoc(OpenAPIResult result) { } ---- - ==== Response Fields |=== diff --git a/modules/jooby-openapi/src/test/resources/adoc/library.adoc b/modules/jooby-openapi/src/test/resources/adoc/library.adoc index f6b629624a..d62abc2fa9 100644 --- a/modules/jooby-openapi/src/test/resources/adoc/library.adoc +++ b/modules/jooby-openapi/src/test/resources/adoc/library.adoc @@ -29,11 +29,13 @@ ${ listBooks | curl } ${ listBooks | queryParameters } === Find a book by ISBN - {% set bookByISBN = operation("GET", "/api/library/{isbn}") %} ${ bookByISBN | curl("-i") } + ${ bookByISBN | statusCode(200) } + ${ bookByISBN | statusCode(400) } + ${ bookByISBN | statusCode(404) } ==== Response Fields From 806f9b302be35153e120bc5023badb5762cb4dc1 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Thu, 4 Dec 2025 12:26:43 -0300 Subject: [PATCH 09/31] windows: attempt to find blank difference --- modules/jooby-openapi/pom.xml | 12 +- .../openapi/asciidoc/OperationFilters.java | 30 +- .../internal/openapi/asciidoc/FilterTest.java | 1187 +++++++++-------- .../java/issues/i3729/api/ApiDocTest.java | 268 ++-- 4 files changed, 765 insertions(+), 732 deletions(-) diff --git a/modules/jooby-openapi/pom.xml b/modules/jooby-openapi/pom.xml index 76a726b5ca..856e5d66b2 100644 --- a/modules/jooby-openapi/pom.xml +++ b/modules/jooby-openapi/pom.xml @@ -18,12 +18,6 @@ ${jooby.version} - - net.datafaker - datafaker - 2.5.3 - - jakarta.ws.rs @@ -155,6 +149,12 @@ mockito-core test + + org.assertj + assertj-core + 3.27.6 + test + diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java index 56c6115ad4..283a87d376 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java @@ -36,7 +36,7 @@ public enum OperationFilters implements Filter { private static final CharSequence ContentType = new HeaderName("Content-Type"); @Override - protected Object doApply( + protected String doApply( SnippetResolver resolver, OperationExt operation, Map snippetContext, @@ -144,7 +144,7 @@ protected Object doApply( }, httpRequest { @Override - protected Object doApply( + protected String doApply( SnippetResolver resolver, OperationExt operation, Map snippetContext, @@ -167,7 +167,7 @@ protected Object doApply( }, requestFields { @Override - protected Object doApply( + protected String doApply( SnippetResolver resolver, OperationExt operation, Map snippetContext, @@ -188,7 +188,7 @@ protected Object doApply( }, httpResponse { @Override - protected Object doApply( + protected String doApply( SnippetResolver resolver, OperationExt operation, Map snippetContext, @@ -218,7 +218,7 @@ protected Object doApply( }, responseFields { @Override - protected Object doApply( + protected String doApply( SnippetResolver resolver, OperationExt operation, Map snippetContext, @@ -237,7 +237,7 @@ protected Object doApply( }, formParameters { @Override - protected Object doApply( + protected String doApply( SnippetResolver resolver, OperationExt operation, Map snippetContext, @@ -252,7 +252,7 @@ protected Object doApply( }, queryParameters { @Override - protected Object doApply( + protected String doApply( SnippetResolver resolver, OperationExt operation, Map snippetContext, @@ -268,7 +268,7 @@ protected Object doApply( }, pathParameters { @Override - protected Object doApply( + protected String doApply( SnippetResolver resolver, OperationExt operation, Map snippetContext, @@ -283,7 +283,7 @@ protected Object doApply( }, cookieParameters { @Override - protected Object doApply( + protected String doApply( SnippetResolver resolver, OperationExt operation, Map snippetContext, @@ -298,7 +298,7 @@ protected Object doApply( }, requestParameters { @Override - protected Object doApply( + protected String doApply( SnippetResolver resolver, OperationExt operation, Map snippetContext, @@ -313,7 +313,7 @@ protected Object doApply( }, schema { @Override - public Object apply( + public String apply( Object input, Map args, PebbleTemplate self, @@ -335,7 +335,7 @@ public Object apply( } @Override - protected Object doApply( + protected String doApply( SnippetResolver resolver, OperationExt operation, Map snippetContext, @@ -350,7 +350,7 @@ protected Object doApply( }, statusCode { @Override - protected Object doApply( + protected String doApply( SnippetResolver resolver, OperationExt operation, Map snippetContext, @@ -398,7 +398,7 @@ public List getArgumentNames() { return List.of("code"); } - protected abstract Object doApply( + protected abstract String doApply( SnippetResolver resolver, OperationExt operation, Map snippetContext, @@ -409,7 +409,7 @@ protected abstract Object doApply( throws Exception; @Override - public Object apply( + public String apply( Object input, Map args, PebbleTemplate self, diff --git a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java index a87709978a..41b75db0f7 100644 --- a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java +++ b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java @@ -8,6 +8,7 @@ import static io.jooby.internal.openapi.asciidoc.OperationFilters.*; import static io.jooby.openapi.CurrentDir.basedir; import static io.jooby.openapi.OperationBuilder.operation; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -76,492 +77,516 @@ public Map getGlobalVariables() { @Test public void requestParams() { // Query parameter - assertEquals( - """ - |=== - |Parameter|Type|Description - - |`+bar+` - |`+string+` - | - - |`+foo+` - |`+string+` - | - - |===\ - """, - queryParameters.apply( - operation("GET", "/api/library/{isbn}").query("foo", "bar").build(), - args(), - template(), - evaluationContext(), - 1)); + assertThat( + queryParameters.apply( + operation("GET", "/api/library/{isbn}").query("foo", "bar").build(), + args(), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + |=== + |Parameter|Type|Description + + |`+bar+` + |`+string+` + | + + |`+foo+` + |`+string+` + | + + |===\ + """); // Path parameter - assertEquals( - """ - |=== - |Parameter|Type|Description - - |`+isbn+` - |`+string+` - | - - |===\ - """, - pathParameters.apply( - operation("GET", "/api/library/{isbn}").path("isbn").build(), - args(), - template(), - evaluationContext(), - 1)); + assertThat( + pathParameters.apply( + operation("GET", "/api/library/{isbn}").path("isbn").build(), + args(), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + |=== + |Parameter|Type|Description + + |`+isbn+` + |`+string+` + | + + |===\ + """); // Cookie parameter - assertEquals( - """ - |=== - |Parameter|Description - - |`+single-sign-on+` - | - - |===\ - """, - cookieParameters.apply( - operation("GET", "/api/library/{isbn}").cookie("single-sign-on").build(), - args(), - template(), - evaluationContext(), - 1)); + assertThat( + cookieParameters.apply( + operation("GET", "/api/library/{isbn}").cookie("single-sign-on").build(), + args(), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + |=== + |Parameter|Description + + |`+single-sign-on+` + | + + |===\ + """); // Form - assertEquals( - """ - |=== - |Parameter|Type|Description - - |`+file+` - |`+binary+` - | - - |`+name+` - |`+string+` - | - - |===\ - """, - formParameters.apply( - operation("POST", "/api/library") - .parameter(Map.of("form", mapOf("name", "string", "file", "binary"))) - .consumes("multipart/form-data") - .build(), - args(), - template(), - evaluationContext(), - 1)); + assertThat( + formParameters.apply( + operation("POST", "/api/library") + .parameter(Map.of("form", mapOf("name", "string", "file", "binary"))) + .consumes("multipart/form-data") + .build(), + args(), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + |=== + |Parameter|Type|Description + + |`+file+` + |`+binary+` + | + + |`+name+` + |`+string+` + | + + |===\ + """); // All them - assertEquals( - """ - |=== - |Parameter|Type|Description - - |`+active+` - |`+true+` - | - - |`+file+` - |`+binary+` - | - - |`+isbn+` - |`+string+` - | - - |`+name+` - |`+string+` - | - - |===\ - """, - requestParameters.apply( - operation("POST", "/api/library") - .parameter( - Map.of( - "form", - mapOf("name", "string", "file", "binary"), - "path", - Map.of("isbn", "string"), - "query", - Map.of("active", "true"))) - .consumes("multipart/form-data") - .build(), - args(), - template(), - evaluationContext(), - 1)); + assertThat( + requestParameters.apply( + operation("POST", "/api/library") + .parameter( + Map.of( + "form", + mapOf("name", "string", "file", "binary"), + "path", + Map.of("isbn", "string"), + "query", + Map.of("active", "true"))) + .consumes("multipart/form-data") + .build(), + args(), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + |=== + |Parameter|Type|Description + + |`+active+` + |`+true+` + | + + |`+file+` + |`+binary+` + | + + |`+isbn+` + |`+string+` + | + + |`+name+` + |`+string+` + | + + |===\ + """); } @Test public void curl() { - assertEquals( - """ - [source,bash] - ---- - curl -X GET 'https://api.libray.com/api/library/{isbn}' - ----\ - """, - curl.apply( - operation("GET", "/api/library/{isbn}").build(), - args(), - template(), - evaluationContext(), - 1)); + assertThat( + curl.apply( + operation("GET", "/api/library/{isbn}").build(), + args(), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + [source,bash] + ---- + curl -X GET 'https://api.libray.com/api/library/{isbn}' + ----\ + """); // Query parameter - assertEquals( - """ - [source,bash] - ---- - curl -X GET 'https://api.libray.com/api/library/{isbn}?foo=string&bar=string' - ----\ - """, - curl.apply( - operation("GET", "/api/library/{isbn}").query("foo", "bar").build(), - args(), - template(), - evaluationContext(), - 1)); + assertThat( + curl.apply( + operation("GET", "/api/library/{isbn}").query("foo", "bar").build(), + args(), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + [source,bash] + ---- + curl -X GET 'https://api.libray.com/api/library/{isbn}?foo=string&bar=string' + ----\ + """); // Form parameter - assertEquals( - """ - [source,bash] - ---- - curl --data-urlencode 'foo=string'\\ - --data-urlencode 'bar=string'\\ - -X POST 'https://api.libray.com/api/library/{isbn}' - ----\ - """, - curl.apply( - operation("POST", "/api/library/{isbn}").form("foo", "bar").build(), - args(), - template(), - evaluationContext(), - 1)); + assertThat( + curl.apply( + operation("POST", "/api/library/{isbn}").form("foo", "bar").build(), + args(), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + [source,bash] + ---- + curl --data-urlencode 'foo=string'\\ + --data-urlencode 'bar=string'\\ + -X POST 'https://api.libray.com/api/library/{isbn}' + ----\ + """); // Query+Form parameter - assertEquals( - """ - [source,bash] - ---- - curl --data-urlencode 'foo=string'\\ - --data-urlencode 'bar=string'\\ - -X POST 'https://api.libray.com/api/library/{isbn}?active=boolean' - ----\ - """, - curl.apply( - operation("POST", "/api/library/{isbn}") - .parameter( - Map.of( - "query", - mapOf("active", "boolean"), - "form", - mapOf("foo", "string", "bar", "string"))) - .build(), - args(), - template(), - evaluationContext(), - 1)); + assertThat( + curl.apply( + operation("POST", "/api/library/{isbn}") + .parameter( + Map.of( + "query", + mapOf("active", "boolean"), + "form", + mapOf("foo", "string", "bar", "string"))) + .build(), + args(), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + [source,bash] + ---- + curl --data-urlencode 'foo=string'\\ + --data-urlencode 'bar=string'\\ + -X POST 'https://api.libray.com/api/library/{isbn}?active=boolean' + ----\ + """); // Passing arguments - assertEquals( - """ - [source,bash] - ---- - curl -i\\ - -X GET 'https://api.libray.com/api/library/{isbn}' - ----\ - """, - curl.apply( - operation("GET", "/api/library/{isbn}").build(), - args("-i"), - template(), - evaluationContext(), - 1)); + assertThat( + curl.apply( + operation("GET", "/api/library/{isbn}").build(), + args("-i"), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + [source,bash] + ---- + curl -i\\ + -X GET 'https://api.libray.com/api/library/{isbn}' + ----\ + """); // Override method - assertEquals( - """ - [source,bash] - ---- - curl -i\\ - -X POST 'https://api.libray.com/api/library/{isbn}' - ----\ - """, - curl.apply( - operation("GET", "/api/library/{isbn}").build(), - args("-i", "-X", "POST"), - template(), - evaluationContext(), - 1)); + assertThat( + curl.apply( + operation("GET", "/api/library/{isbn}").build(), + args("-i", "-X", "POST"), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + [source,bash] + ---- + curl -i\\ + -X POST 'https://api.libray.com/api/library/{isbn}' + ----\ + """); // With Accept Header - assertEquals( - """ - [source,bash] - ---- - curl -H 'Accept: application/json'\\ - -X GET 'https://api.libray.com/api/library/{isbn}' - ----\ - """, - curl.apply( - operation("GET", "/api/library/{isbn}").produces("application/json").build(), - args(), - template(), - evaluationContext(), - 1)); + assertThat( + curl.apply( + operation("GET", "/api/library/{isbn}").produces("application/json").build(), + args(), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + [source,bash] + ---- + curl -H 'Accept: application/json'\\ + -X GET 'https://api.libray.com/api/library/{isbn}' + ----\ + """); // With Override Accept Header - assertEquals( - """ - [source,bash] - ---- - curl -H 'Accept: application/xml'\\ - -X GET 'https://api.libray.com/api/library/{isbn}' - ----\ - """, - curl.apply( - operation("GET", "/api/library/{isbn}").produces("application/json").build(), - args("-H", "'Accept: application/xml'"), - template(), - evaluationContext(), - 1)); - - assertEquals( - """ - [source,bash] - ---- - curl -H 'Content-Type: application/json'\\ - -d '{"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"}'\\ - -X POST 'https://api.libray.com/api/library' - ----\ - """, - curl.apply( - operation("POST", "/api/library").body(new Book(), "application/json").build(), - args(), - template(), - evaluationContext(), - 1)); - - assertEquals( - """ - [source,bash] - ---- - curl -H 'Content-Type: multipart/form-data'\\ - --data-urlencode 'name=string'\\ - -F "file=@/file.extension"\\ - -X POST 'https://api.libray.com/api/library' - ----\ - """, - curl.apply( - operation("POST", "/api/library") - .parameter(Map.of("form", mapOf("name", "string", "file", "binary"))) - .consumes("multipart/form-data") - .build(), - args(), - template(), - evaluationContext(), - 1)); + assertThat( + curl.apply( + operation("GET", "/api/library/{isbn}").produces("application/json").build(), + args("-H", "'Accept: application/xml'"), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + [source,bash] + ---- + curl -H 'Accept: application/xml'\\ + -X GET 'https://api.libray.com/api/library/{isbn}' + ----\ + """); + + assertThat( + curl.apply( + operation("POST", "/api/library").body(new Book(), "application/json").build(), + args(), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + [source,bash] + ---- + curl -H 'Content-Type: application/json'\\ + -d '{"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"}'\\ + -X POST 'https://api.libray.com/api/library' + ----\ + """); + + assertThat( + curl.apply( + operation("POST", "/api/library") + .parameter(Map.of("form", mapOf("name", "string", "file", "binary"))) + .consumes("multipart/form-data") + .build(), + args(), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + [source,bash] + ---- + curl -H 'Content-Type: multipart/form-data'\\ + --data-urlencode 'name=string'\\ + -F "file=@/file.extension"\\ + -X POST 'https://api.libray.com/api/library' + ----\ + """); } @Test public void httpRequest() { - assertEquals( - """ - [source,http,options="nowrap"] - ---- - GET /api/library/{isbn} HTTP/1.1 - ----\ - """, - httpRequest.apply( - operation("GET", "/api/library/{isbn}").build(), - args(), - template(), - evaluationContext(), - 1)); - - assertEquals( - """ - [source,http,options="nowrap"] - ---- - GET /api/library/{isbn} HTTP/1.1 - Accept: application/json - ----\ - """, - httpRequest.apply( - operation("GET", "/api/library/{isbn}").produces("application/json").build(), - args(), - template(), - evaluationContext(), - 1)); - - assertEquals( - """ - [source,http,options="nowrap"] - ---- - POST /api/library HTTP/1.1 - Content-Type: application/json - {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} - ----\ - """, - httpRequest.apply( - operation("POST", "/api/library").body(new Book(), "application/json").build(), - args(), - template(), - evaluationContext(), - 1)); + assertThat( + httpRequest.apply( + operation("GET", "/api/library/{isbn}").build(), + args(), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + [source,http,options="nowrap"] + ---- + GET /api/library/{isbn} HTTP/1.1 + ----\ + """); + + assertThat( + httpRequest.apply( + operation("GET", "/api/library/{isbn}").produces("application/json").build(), + args(), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + [source,http,options="nowrap"] + ---- + GET /api/library/{isbn} HTTP/1.1 + Accept: application/json + ----\ + """); + + assertThat( + httpRequest.apply( + operation("POST", "/api/library").body(new Book(), "application/json").build(), + args(), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + [source,http,options="nowrap"] + ---- + POST /api/library HTTP/1.1 + Content-Type: application/json + {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} + ----\ + """); } @Test public void httpResponse() { - assertEquals( - """ - [source,http,options="nowrap"] - ---- - HTTP/1.1 200 Success - ----\ - """, - httpResponse.apply( - operation("GET", "/api/library/{isbn}").defaultResponse().build(), - args(), - template(), - evaluationContext(), - 1)); - - assertEquals( - """ - [source,http,options="nowrap"] - ---- - HTTP/1.1 200 Success - Content-Type: application/json - ----\ - """, - httpResponse.apply( - operation("GET", "/api/library/{isbn}") - .defaultResponse() - .produces("application/json") - .build(), - args(), - template(), - evaluationContext(), - 1)); - - assertEquals( - """ - [source,http,options="nowrap"] - ---- - HTTP/1.1 201 Created - Content-Type: application/json - {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} - ----\ - """, - httpResponse.apply( - operation("POST", "/api/library") - .produces("application/json") - .response(new Book(), StatusCode.CREATED, "application/json") - .build(), - args(), - template(), - evaluationContext(), - 1)); - - assertEquals( - """ - [source,http,options="nowrap"] - ---- - HTTP/1.1 201 Created - Content-Type: application/json - {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} - ----\ - """, - httpResponse.apply( - operation("POST", "/api/library") - .produces("application/json") - .response(new Book(), StatusCode.CREATED, "application/json") - .build(), - args(), - template(), - evaluationContext(), - 1)); - - assertEquals( - """ - [source,http,options="nowrap"] - ---- - HTTP/1.1 400 Bad Request - Content-Type: application/json - {"path":"string","message":"string","code":"int32"} - ----\ - """, - httpResponse.apply( - operation("POST", "/api/library") - .produces("application/json") - .response(new Book(), StatusCode.CREATED, "application/json") - .response(new BookError(), StatusCode.BAD_REQUEST, "application/json") - .build(), - args("400"), - template(), - evaluationContext(), - 1)); + assertThat( + httpResponse.apply( + operation("GET", "/api/library/{isbn}").defaultResponse().build(), + args(), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + [source,http,options="nowrap"] + ---- + HTTP/1.1 200 Success + ----\ + """); + + assertThat( + httpResponse.apply( + operation("GET", "/api/library/{isbn}") + .defaultResponse() + .produces("application/json") + .build(), + args(), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + [source,http,options="nowrap"] + ---- + HTTP/1.1 200 Success + Content-Type: application/json + ----\ + """); + + assertThat( + httpResponse.apply( + operation("POST", "/api/library") + .produces("application/json") + .response(new Book(), StatusCode.CREATED, "application/json") + .build(), + args(), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + [source,http,options="nowrap"] + ---- + HTTP/1.1 201 Created + Content-Type: application/json + {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} + ----\ + """); + + assertThat( + httpResponse.apply( + operation("POST", "/api/library") + .produces("application/json") + .response(new Book(), StatusCode.CREATED, "application/json") + .build(), + args(), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + [source,http,options="nowrap"] + ---- + HTTP/1.1 201 Created + Content-Type: application/json + {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} + ----\ + """); + + assertThat( + httpResponse.apply( + operation("POST", "/api/library") + .produces("application/json") + .response(new Book(), StatusCode.CREATED, "application/json") + .response(new BookError(), StatusCode.BAD_REQUEST, "application/json") + .build(), + args("400"), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + [source,http,options="nowrap"] + ---- + HTTP/1.1 400 Bad Request + Content-Type: application/json + {"path":"string","message":"string","code":"int32"} + ----\ + """); } @Test public void responseFields() { - assertEquals( - """ - |=== - |Path|Type|Description - - |`+isbn+` - |`+string+` - | - - |`+title+` - |`+string+` - | - - |`+publicationDate+` - |`+date+` - | - - |`+text+` - |`+string+` - | - - |`+type+` - |`+string+` - | - - |`+authors+` - |`+[]+` - | - - |`+image+` - |`+binary+` - | - - |===\ - """, - responseFields.apply( - operation("POST", "/api/library") - .produces("application/json") - .response(new Book(), StatusCode.CREATED, "application/json") - .build(), - args(), - template(), - evaluationContext(), - 1)); + assertThat( + responseFields.apply( + operation("POST", "/api/library") + .produces("application/json") + .response(new Book(), StatusCode.CREATED, "application/json") + .build(), + args(), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + |=== + |Path|Type|Description + + |`+isbn+` + |`+string+` + | + + |`+title+` + |`+string+` + | + + |`+publicationDate+` + |`+date+` + | + + |`+text+` + |`+string+` + | + + |`+type+` + |`+string+` + | + + |`+authors+` + |`+[]+` + | + + |`+image+` + |`+binary+` + | + + |===\ + """); assertEquals( """ @@ -608,148 +633,154 @@ public void responseFields() { evaluationContext(), 1)); - assertEquals( - """ - |=== - |Path|Type|Description - - |`+path+` - |`+string+` - | - - |`+message+` - |`+string+` - | - - |`+code+` - |`+int32+` - | - - |===\ - """, - responseFields.apply( - operation("POST", "/api/library") - .produces("application/json") - .response(new Book(), StatusCode.CREATED, "application/json") - .response(new BookError(), StatusCode.BAD_REQUEST, "application/json") - .build(), - args("400"), - template(), - evaluationContext(), - 1)); + assertThat( + responseFields.apply( + operation("POST", "/api/library") + .produces("application/json") + .response(new Book(), StatusCode.CREATED, "application/json") + .response(new BookError(), StatusCode.BAD_REQUEST, "application/json") + .build(), + args("400"), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + |=== + |Path|Type|Description + + |`+path+` + |`+string+` + | + + |`+message+` + |`+string+` + | + + |`+code+` + |`+int32+` + | + + |===\ + """); } @Test public void statusCode() { - assertEquals( - """ - .Created - [source,json] - ---- - { - "isbn" : "string", - "title" : "string", - "publicationDate" : "date", - "text" : "string", - "type" : "string", - "authors" : [ ], - "image" : "binary" - } - ----\ - """, - statusCode.apply( - operation("POST", "/api/library") - .produces("application/json") - .response(new Book(), StatusCode.CREATED, "application/json") - .response(new BookError(), StatusCode.BAD_REQUEST, "application/json") - .build(), - args("201"), - template(), - evaluationContext(), - 1)); - - assertEquals( - """ - .Bad Request - [source,json] - ---- - { - "path" : "string", - "message" : "string", - "code" : "int32" - } - ----\ - """, - statusCode.apply( - operation("POST", "/api/library") - .produces("application/json") - .response(new Book(), StatusCode.CREATED, "application/json") - .response(new BookError(), StatusCode.BAD_REQUEST, "application/json") - .build(), - args("400"), - template(), - evaluationContext(), - 1)); + assertThat( + statusCode.apply( + operation("POST", "/api/library") + .produces("application/json") + .response(new Book(), StatusCode.CREATED, "application/json") + .response(new BookError(), StatusCode.BAD_REQUEST, "application/json") + .build(), + args("201"), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + .Created + [source,json] + ---- + { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "authors" : [ ], + "image" : "binary" + } + ----\ + """); + + assertThat( + statusCode.apply( + operation("POST", "/api/library") + .produces("application/json") + .response(new Book(), StatusCode.CREATED, "application/json") + .response(new BookError(), StatusCode.BAD_REQUEST, "application/json") + .build(), + args("400"), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + .Bad Request + [source,json] + ---- + { + "path" : "string", + "message" : "string", + "code" : "int32" + } + ----\ + """); } @Test public void schema() { // Request Body - assertEquals( - """ - [source,json] - ---- - {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} - ----\ - """, - schema.apply( - operation("POST", "/api/library") - .body(new Book(), "application/json") - .build() - .getRequestBody(), - args(), - template(), - evaluationContext(), - 1)); + assertThat( + schema.apply( + operation("POST", "/api/library") + .body(new Book(), "application/json") + .build() + .getRequestBody(), + args(), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + [source,json] + ---- + {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} + ----\ + """); // Response - assertEquals( - """ - [source,json] - ---- - {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} - ----\ - """, - schema.apply( - operation("POST", "/api/library") - .response(new Book(), StatusCode.OK, "application/json") - .build() - .getDefaultResponse(), - args(), - template(), - evaluationContext(), - 1)); + assertThat( + schema.apply( + operation("POST", "/api/library") + .response(new Book(), StatusCode.OK, "application/json") + .build() + .getDefaultResponse(), + args(), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + [source,json] + ---- + {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} + ----\ + """); // Schema - assertEquals( - """ - [source,json] - ---- - {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} - ----\ - """, - schema.apply( - operation("POST", "/api/library") - .response(new Book(), StatusCode.OK, "application/json") - .build() - .getDefaultResponse() - .getContent() - .get("application/json") - .getSchema(), - args(), - template(), - evaluationContext(), - 1)); + assertThat( + schema.apply( + operation("POST", "/api/library") + .response(new Book(), StatusCode.OK, "application/json") + .build() + .getDefaultResponse() + .getContent() + .get("application/json") + .getSchema(), + args(), + template(), + evaluationContext(), + 1)) + .isEqualToNormalizingNewlines( + """ + [source,json] + ---- + {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} + ----\ + """); } private Map args(Object... args) { diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java index df067159f2..39a7a32b56 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java @@ -5,6 +5,7 @@ */ package issues.i3729.api; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import io.jooby.openapi.CurrentDir; @@ -20,139 +21,140 @@ public void shouldGenerateMvcDoc(OpenAPIResult result) { @OpenAPITest(value = AppLibrary.class) public void shouldGenerateAdoc(OpenAPIResult result) { - assertEquals( - """ - = Library API. - Jooby Doc; - :doctype: book - :icons: font - :source-highlighter: highlightjs - :toc: left - :toclevels: 4 - :sectlinks: - - == Introduction - - Available data: Books and authors. - - == Support - - Write your questions at support@jooby.io - - [[overview_operations]] - == Operations - - === List Books - - Query books. By using advanced filters. - - [source,bash] - ---- - curl -H 'Accept: application/json'\\ - -X GET 'https://api.fake-museum-example.com/v1/api/library?title=string&author=string&isbn=string1&isbn=string2&isbn=string3' - ---- - - ==== Request Fields - - |=== - |Parameter|Type|Description - - |`+author+` - |`+string+` - |Book's author. Optional. - - |`+isbn+` - |`+array+` - |Book's isbn. Optional. - - |`+title+` - |`+string+` - |Book's title. - - |=== - - === Find a book by ISBN - - [source,bash] - ---- - curl -i\\ - -H 'Accept: application/json'\\ - -X GET 'https://api.fake-museum-example.com/v1/api/library/{isbn}' - ---- - - .A matching book. - [source,json] - ---- - { - "isbn" : "string", - "title" : "string", - "publicationDate" : "date", - "text" : "string", - "type" : "string", - "authors" : [ ], - "image" : "binary" - } - ---- - - .Bad Request: For bad ISBN code. - [source,json] - ---- - { - "message" : "...", - "statusCode" : 400, - "reason" : "Bad Request" - } - ---- - - .Not Found: If a book doesn't exist. - [source,json] - ---- - { - "message" : "...", - "statusCode" : 404, - "reason" : "Not Found" - } - ---- - - ==== Response Fields - - |=== - |Path|Type|Description - - |`+isbn+` - |`+string+` - |Book ISBN. Method. - - |`+title+` - |`+string+` - |Book's title. - - |`+publicationDate+` - |`+date+` - |Publication date. Format mm-dd-yyyy. - - |`+text+` - |`+string+` - |Book's content. - - |`+type+` - |`+string+` - |Book type. - - Fiction: Fiction books are based on imaginary characters and events, while non-fiction books are based o n real people and events. - - NonFiction: Non-fiction genres include biography, autobiography, history, self-help, and true crime. - - |`+authors+` - |`+[]+` - | - - |`+image+` - |`+binary+` - | - - |=== - """ - .trim(), - result.toAsciiDoc(CurrentDir.basedir("src", "test", "resources", "adoc", "library.adoc"))); + assertThat( + result.toAsciiDoc( + CurrentDir.basedir("src", "test", "resources", "adoc", "library.adoc"))) + .isEqualToIgnoringNewLines( + """ + = Library API. + Jooby Doc; + :doctype: book + :icons: font + :source-highlighter: highlightjs + :toc: left + :toclevels: 4 + :sectlinks: + + == Introduction + + Available data: Books and authors. + + == Support + + Write your questions at support@jooby.io + + [[overview_operations]] + == Operations + + === List Books + + Query books. By using advanced filters. + + [source,bash] + ---- + curl -H 'Accept: application/json'\\ + -X GET 'https://api.fake-museum-example.com/v1/api/library?title=string&author=string&isbn=string1&isbn=string2&isbn=string3' + ---- + + ==== Request Fields + + |=== + |Parameter|Type|Description + + |`+author+` + |`+string+` + |Book's author. Optional. + + |`+isbn+` + |`+array+` + |Book's isbn. Optional. + + |`+title+` + |`+string+` + |Book's title. + + |=== + + === Find a book by ISBN + + [source,bash] + ---- + curl -i\\ + -H 'Accept: application/json'\\ + -X GET 'https://api.fake-museum-example.com/v1/api/library/{isbn}' + ---- + + .A matching book. + [source,json] + ---- + { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "authors" : [ ], + "image" : "binary" + } + ---- + + .Bad Request: For bad ISBN code. + [source,json] + ---- + { + "message" : "...", + "statusCode" : 400, + "reason" : "Bad Request" + } + ---- + + .Not Found: If a book doesn't exist. + [source,json] + ---- + { + "message" : "...", + "statusCode" : 404, + "reason" : "Not Found" + } + ---- + + ==== Response Fields + + |=== + |Path|Type|Description + + |`+isbn+` + |`+string+` + |Book ISBN. Method. + + |`+title+` + |`+string+` + |Book's title. + + |`+publicationDate+` + |`+date+` + |Publication date. Format mm-dd-yyyy. + + |`+text+` + |`+string+` + |Book's content. + + |`+type+` + |`+string+` + |Book type. + - Fiction: Fiction books are based on imaginary characters and events, while non-fiction books are based o n real people and events. + - NonFiction: Non-fiction genres include biography, autobiography, history, self-help, and true crime. + + |`+authors+` + |`+[]+` + | + + |`+image+` + |`+binary+` + | + + |=== + """); } @OpenAPITest(value = ScriptLibrary.class) From 66c759491206d1cc459253c10e8c7d149e579b02 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sat, 6 Dec 2025 13:50:11 -0300 Subject: [PATCH 10/31] - integrate ascii doc into openapi generator: maven/gradle plugin - fix schema data iteration to support schema $ref - make schema filter pretty print output --- .../main/java/io/jooby/adoc/DocGenerator.java | 49 ++-- .../java/io/jooby/gradle/OpenAPITask.java | 35 ++- .../main/java/io/jooby/maven/OpenAPIMojo.java | 25 +- .../src/main/java/io/jooby/maven/RunMojo.java | 17 +- modules/jooby-openapi/pom.xml | 12 +- .../internal/openapi/AsciiDocGenerator.java | 21 ++ .../io/jooby/internal/openapi/MixinHook.java | 75 ++++++ .../internal/openapi/ModelConverterExt.java | 5 +- .../jooby/internal/openapi/ParserContext.java | 34 ++- .../openapi/asciidoc/OperationFilters.java | 47 ++-- .../internal/openapi/asciidoc/SchemaData.java | 35 ++- .../io/jooby/openapi/OpenAPIGenerator.java | 129 +++++++--- .../src/main/java/module-info.java | 4 + .../internal/openapi/asciidoc/FilterTest.java | 30 ++- .../java/io/jooby/openapi/CurrentDir.java | 15 ++ .../io/jooby/openapi/OpenAPIExtension.java | 2 +- .../src/test/java/issues/Issue1582.java | 31 +-- .../java/issues/i3729/api/ApiDocTest.java | 218 +++++++++++++++++ .../java/issues/i3729/api/AppLibrary2.java | 17 ++ .../java/issues/i3729/api/LibraryApi2.java | 94 ++++++++ .../java/issues/i3729/api/LibraryRepo.java | 87 +++++++ .../src/test/java/issues/i3820/App3820a.java | 19 ++ .../src/test/java/issues/i3820/Issue3820.java | 26 ++ .../test/java/issues/i3820/model/Address.java | 35 +++ .../test/java/issues/i3820/model/Author.java | 35 +++ .../test/java/issues/i3820/model/Book.java | 161 +++++++++++++ .../java/issues/i3820/model/Publisher.java | 46 ++++ .../src/test/resources/docs/api-guide.adoc | 228 ------------------ .../resources/docs/getting-started-guide.adoc | 175 -------------- .../test/resources/issues/i3820/schema.adoc | 1 + 30 files changed, 1168 insertions(+), 540 deletions(-) create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/MixinHook.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3729/api/AppLibrary2.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi2.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryRepo.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3820/App3820a.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3820/Issue3820.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3820/model/Address.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3820/model/Author.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3820/model/Book.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3820/model/Publisher.java delete mode 100644 modules/jooby-openapi/src/test/resources/docs/api-guide.adoc delete mode 100644 modules/jooby-openapi/src/test/resources/docs/getting-started-guide.adoc create mode 100644 modules/jooby-openapi/src/test/resources/issues/i3820/schema.adoc diff --git a/docs/src/main/java/io/jooby/adoc/DocGenerator.java b/docs/src/main/java/io/jooby/adoc/DocGenerator.java index c2e0fa0d6c..04cc4e85e8 100644 --- a/docs/src/main/java/io/jooby/adoc/DocGenerator.java +++ b/docs/src/main/java/io/jooby/adoc/DocGenerator.java @@ -87,29 +87,29 @@ public static void generate(Path basedir, boolean publish, boolean v1, boolean d pb.step(); if (doAscii) { - Asciidoctor asciidoctor = Asciidoctor.Factory.create(); - - asciidoctor.convertFile( - asciidoc.resolve("index.adoc").toFile(), - createOptions(asciidoc, outdir, version, null, asciidoc.resolve("index.adoc"))); - var index = outdir.resolve("index.html"); - Files.writeString(index, hljs(Files.readString(index))); - pb.step(); - - Stream.of(treeDirs) - .forEach( - throwingConsumer( - name -> { - Path modules = outdir.resolve(name); - Files.createDirectories(modules); - Files.walk(asciidoc.resolve(name)) - .filter(Files::isRegularFile) - .forEach( - module -> { - processModule(asciidoctor, asciidoc, module, outdir, name, version); - pb.step(); - }); - })); + try (var asciidoctor = Asciidoctor.Factory.create()) { + asciidoctor.convertFile( + asciidoc.resolve("index.adoc").toFile(), + createOptions(asciidoc, outdir, version, null, asciidoc.resolve("index.adoc"))); + var index = outdir.resolve("index.html"); + Files.writeString(index, hljs(Files.readString(index))); + pb.step(); + + Stream.of(treeDirs) + .forEach( + throwingConsumer( + name -> { + Path modules = outdir.resolve(name); + Files.createDirectories(modules); + Files.walk(asciidoc.resolve(name)) + .filter(Files::isRegularFile) + .forEach( + module -> { + processModule(asciidoctor, asciidoc, module, outdir, name, version); + pb.step(); + }); + })); + } } // LICENSE @@ -242,6 +242,7 @@ private static Options createOptions(Path basedir, Path outdir, String version, var attributes = Attributes.builder(); attributes.attribute("docfile", docfile.toString()); + attributes.attribute("stylesheet", "js/styles/site.css"); attributes.attribute("love", "♡"); attributes.attribute("docinfo", "shared"); attributes.title(title == null ? "jooby: do more! more easily!!" : "jooby: " + title); @@ -280,7 +281,7 @@ private static Options createOptions(Path basedir, Path outdir, String version, attributes.attribute("date", DateTimeFormatter.ISO_INSTANT.format(Instant.now())); OptionsBuilder options = Options.builder(); - options.backend("html"); + options.backend("html5"); options.attributes(attributes.build()); options.baseDir(basedir.toAbsolutePath().toFile()); diff --git a/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/OpenAPITask.java b/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/OpenAPITask.java index 6940c6a5ad..9dc4ba6ba4 100644 --- a/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/OpenAPITask.java +++ b/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/OpenAPITask.java @@ -17,8 +17,11 @@ import java.io.File; import java.nio.file.Path; import java.util.List; +import java.util.Map; import java.util.Optional; +import static java.util.Optional.ofNullable; + /** * Generate an OpenAPI file from a jooby application. * @@ -37,6 +40,8 @@ public class OpenAPITask extends BaseTask { private String specVersion; + private List adoc; + /** * Creates an OpenAPI task. */ @@ -61,7 +66,8 @@ public void generate() throws Throwable { .map(File::toPath); }) .distinct() - .toList(); Path outputDir = classes(getProject(), false); + .toList(); + Path outputDir = classes(getProject(), false); ClassLoader classLoader = createClassLoader(projects); @@ -82,9 +88,10 @@ public void generate() throws Throwable { OpenAPI result = tool.generate(mainClass); - for (OpenAPIGenerator.Format format : OpenAPIGenerator.Format.values()) { - Path output = tool.export(result, format); - getLogger().info(" writing: " + output); + var adocPath = ofNullable(adoc).orElse(List.of()).stream().map(File::toPath).toList(); + for (var format : OpenAPIGenerator.Format.values()) { + tool.export(result, format, Map.of("adoc", adocPath)) + .forEach(output -> getLogger().info(" writing: " + output)); } } @@ -188,6 +195,26 @@ public void setSpecVersion(String specVersion) { this.specVersion = specVersion; } + /** + * Optionally generates adoc output. + * + * @return List of adoc templates. + */ + @Input + @org.gradle.api.tasks.Optional + public List getAdoc() { + return adoc; + } + + /** + * Set adoc templates to build. + * + * @param adoc Adoc templates to build. + */ + public void setAdoc(List adoc) { + this.adoc = adoc; + } + private Optional trim(String value) { if (value == null || value.trim().isEmpty()) { return Optional.empty(); diff --git a/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/OpenAPIMojo.java b/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/OpenAPIMojo.java index 6a2db33c23..35f8d41a1d 100644 --- a/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/OpenAPIMojo.java +++ b/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/OpenAPIMojo.java @@ -5,12 +5,15 @@ */ package io.jooby.maven; +import static java.util.Optional.ofNullable; import static org.apache.maven.plugins.annotations.LifecyclePhase.PROCESS_CLASSES; import static org.apache.maven.plugins.annotations.ResolutionScope.COMPILE_PLUS_RUNTIME; +import java.io.File; import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; +import java.util.Map; import java.util.Optional; import org.apache.maven.plugins.annotations.Mojo; @@ -20,7 +23,6 @@ import edu.umd.cs.findbugs.annotations.NonNull; import edu.umd.cs.findbugs.annotations.Nullable; import io.jooby.openapi.OpenAPIGenerator; -import io.swagger.v3.oas.models.OpenAPI; /** * Generate an OpenAPI file from a jooby application. @@ -47,6 +49,8 @@ public class OpenAPIMojo extends BaseMojo { @Parameter(property = "openAPI.specVersion") private String specVersion; + @Parameter private List adoc; + @Override protected void doExecute(@NonNull List projects, @NonNull String mainClass) throws Exception { @@ -73,16 +77,17 @@ protected void doExecute(@NonNull List projects, @NonNull String m trim(includes).ifPresent(tool::setIncludes); trim(excludes).ifPresent(tool::setExcludes); - OpenAPI result = tool.generate(mainClass); + var result = tool.generate(mainClass); - for (OpenAPIGenerator.Format format : OpenAPIGenerator.Format.values()) { - Path output = tool.export(result, format); - getLog().info(" writing: " + output); + var adocPath = ofNullable(adoc).orElse(List.of()).stream().map(File::toPath).toList(); + for (var format : OpenAPIGenerator.Format.values()) { + tool.export(result, format, Map.of("adoc", adocPath)) + .forEach(output -> getLog().info(" writing: " + output)); } } private Optional trim(String value) { - if (value == null || value.trim().length() == 0) { + if (value == null || value.trim().isEmpty()) { return Optional.empty(); } return Optional.of(value.trim()); @@ -131,4 +136,12 @@ public String getSpecVersion() { public void setSpecVersion(String specVersion) { this.specVersion = specVersion; } + + public List getAdoc() { + return adoc; + } + + public void setAdoc(List adoc) { + this.adoc = adoc; + } } diff --git a/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/RunMojo.java b/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/RunMojo.java index 924e23cc07..a535bf8e1b 100644 --- a/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/RunMojo.java +++ b/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/RunMojo.java @@ -24,6 +24,8 @@ import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; import org.apache.maven.project.MavenProject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import io.jooby.run.JoobyRun; import io.jooby.run.JoobyRunOptions; @@ -42,8 +44,10 @@ @Execute(phase = PROCESS_CLASSES) public class RunMojo extends BaseMojo { + private static final Logger log = LoggerFactory.getLogger(RunMojo.class); + static { - /** Turn off shutdown hook on Server. */ + /* Turn off shutdown hook on Server. */ System.setProperty("jooby.useShutdownHook", "false"); } @@ -97,7 +101,13 @@ protected void doExecute(List projects, String mainClass) throws T var error = result.hasExceptions(); // Success? if (error) { - getLog().debug("Compilation error found: " + path); + var filename = path.getFileName().toFile().toString(); + var isSource = filename.endsWith(".java") || filename.endsWith(".kt"); + for (Throwable exception : result.getExceptions()) { + if (!isSource) { + getLog().error(exception); + } + } } return !error; }); @@ -213,8 +223,7 @@ protected void setUseTestScope(boolean useTestScope) { * @return Request. */ private MavenExecutionRequest mavenRequest(String goal) { - return DefaultMavenExecutionRequest.copy(session.getRequest()) - .setGoals(Collections.singletonList(goal)); + return DefaultMavenExecutionRequest.copy(session.getRequest()).setGoals(List.of(goal)); } private Set sourceDirectories(MavenProject project, boolean useTestScope) { diff --git a/modules/jooby-openapi/pom.xml b/modules/jooby-openapi/pom.xml index 856e5d66b2..9ad0a0bbbc 100644 --- a/modules/jooby-openapi/pom.xml +++ b/modules/jooby-openapi/pom.xml @@ -56,7 +56,11 @@ asciidoctorj 3.0.1 - + + org.asciidoctor + asciidoctorj-pdf + 2.3.23 + io.pebbletemplates pebble @@ -78,6 +82,12 @@ guava + + jakarta.data + jakarta.data-api + 1.0.1 + + org.junit.jupiter diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AsciiDocGenerator.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AsciiDocGenerator.java index 6799a31201..22e1d447d1 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AsciiDocGenerator.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AsciiDocGenerator.java @@ -13,6 +13,10 @@ import java.util.Map; import java.util.Optional; +import org.asciidoctor.Asciidoctor; +import org.asciidoctor.Options; +import org.asciidoctor.SafeMode; + import io.jooby.internal.openapi.asciidoc.Filters; import io.jooby.internal.openapi.asciidoc.Functions; import io.jooby.internal.openapi.asciidoc.SnippetResolver; @@ -41,6 +45,23 @@ public static String generate(OpenAPIExt openAPI, Path index) throws IOException return writer.toString().trim(); } + public static void export(Path baseDir, Path input, Path outputDir) { + try (var asciidoctor = Asciidoctor.Factory.create()) { + + var options = + Options.builder() + .backend("html5") + .baseDir(baseDir.toFile()) + .toDir(outputDir.toFile()) + .mkDirs(true) + .safe(SafeMode.UNSAFE) + .build(); + + // Perform the conversion + asciidoctor.convertFile(input.toFile(), options); + } + } + @SuppressWarnings("unchecked") private static PebbleEngine newEngine(OpenAPIExt openapi, Path baseDir) { var json = (openapi.getSpecVersion() == SpecVersion.V30 ? Json.mapper() : Json31.mapper()); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/MixinHook.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/MixinHook.java new file mode 100644 index 0000000000..099080a5ad --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/MixinHook.java @@ -0,0 +1,75 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.swagger.annotations.ApiModelProperty; +import jakarta.data.page.Page; +import jakarta.data.page.PageRequest; + +public class MixinHook { + + @JsonPropertyOrder({ + "content", + "numberOfElements", + "totalElements", + "totalPages", + "pageRequest", + "nextPageRequest", + "previousPageRequest" + }) + public abstract static class PageMixin implements Page { + @JsonProperty("content") + @Override + public abstract List content(); + + @JsonProperty("numberOfElements") + @Override + public abstract int numberOfElements(); + + @Override + @JsonProperty("pageRequest") + public abstract PageRequest pageRequest(); + + @Override + @JsonProperty("nextPageRequest") + public abstract PageRequest nextPageRequest(); + + @Override + @JsonProperty("previousPageRequest") + public abstract PageRequest previousPageRequest(); + + @Override + @JsonProperty("totalElements") + public abstract long totalElements(); + + @Override + @JsonProperty("totalPages") + public abstract long totalPages(); + } + + @JsonPropertyOrder({"page", "size"}) + public abstract static class PageRequestMixin implements PageRequest { + @JsonProperty("page") + @Override + @ApiModelProperty("The page to be returned") + public abstract long page(); + + @JsonProperty("size") + @Override + @ApiModelProperty("The requested size of each page") + public abstract int size(); + } + + public static void mixin(ObjectMapper mapper) { + mapper.addMixIn(jakarta.data.page.Page.class, PageMixin.class); + mapper.addMixIn(jakarta.data.page.PageRequest.class, PageRequestMixin.class); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ModelConverterExt.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ModelConverterExt.java index 32bead9525..96b5351bfa 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ModelConverterExt.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ModelConverterExt.java @@ -11,10 +11,7 @@ import java.util.Set; import com.fasterxml.jackson.databind.ObjectMapper; -import io.jooby.FileUpload; -import io.jooby.Jooby; -import io.jooby.Router; -import io.jooby.ServiceRegistry; +import io.jooby.*; import io.swagger.v3.core.converter.AnnotatedType; import io.swagger.v3.core.converter.ModelConverter; import io.swagger.v3.core.converter.ModelConverterContext; diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java index fb1e47cfae..8d953ca061 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java @@ -89,6 +89,8 @@ import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.media.StringSchema; import io.swagger.v3.oas.models.media.UUIDSchema; +import jakarta.data.page.Page; +import jakarta.data.page.PageRequest; public class ParserContext { @@ -171,13 +173,14 @@ public void setupModule(SetupContext context) { context.insertAnnotationIntrospector(new ConflictiveSetter()); } }); - /** Java8/Optional: */ + /* Java8/Optional: */ modules.add(new Jdk8Module()); modules.forEach(module -> mappers.forEach(mapper -> mapper.registerModule(module))); - /** Set class loader: */ - mappers.stream() - .forEach( - mapper -> mapper.setTypeFactory(mapper.getTypeFactory().withClassLoader(classLoader))); + /* Set class loader: */ + mappers.forEach( + mapper -> mapper.setTypeFactory(mapper.getTypeFactory().withClassLoader(classLoader))); + /* Mixin */ + mappers.forEach(MixinHook::mixin); } public Collection schemas() { @@ -403,12 +406,8 @@ public Optional schemaRef(String type) { } public Schema schema(Type type) { - if (isArray(type)) { - // For array we need internal name :S - return schema(type.getInternalName()); - } else { - return schema(type.getClassName()); - } + // For array we need internal name :S + return schema(isArray(type) ? type.getInternalName() : type.getClassName()); } private boolean isArray(Type type) { @@ -452,6 +451,19 @@ private Schema schema(JavaType type) { MapSchema mapSchema = new MapSchema(); mapSchema.setAdditionalProperties(schema(type.getContentType())); return mapSchema; + } else if (type.getRawClass() == Page.class) { + // must be embedded it mimics a List. This is bc it might have a different item type + // per operation. + var pageSchema = converters.read(type.getRawClass()).get("Page"); + // force loading of PageRequest + schema(PageRequest.class); + + var params = type.getBindings().getTypeParameters(); + if (params != null && !params.isEmpty()) { + Schema contentSchema = (Schema) pageSchema.getProperties().get("content"); + contentSchema.setItems(schema(params.getFirst())); + } + return pageSchema; } return schema(type.getRawClass()); } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java index 283a87d376..674897b54c 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java @@ -79,7 +79,11 @@ protected String doApply( if (mediaType != null) { var json = InternalContext.json(context); options.put( - "-d", "'" + json.writeValueAsString(SchemaData.from(mediaType.getSchema())) + "'"); + "-d", + "'" + + json.writeValueAsString( + SchemaData.from(mediaType.getSchema(), schemaRefResolver(context))) + + "'"); } } } else { @@ -158,7 +162,8 @@ protected String doApply( if (operation.getRequestBody() != null) { var json = InternalContext.json(context); var schema = schema(context, operation.getRequestBody()); - requestBodyString = json.writeValueAsString(SchemaData.from(schema)) + "\n"; + requestBodyString = + json.writeValueAsString(SchemaData.from(schema, schemaRefResolver(context))) + "\n"; } snippetContext.put("requestBody", requestBodyString); snippetContext.put("headers", snippetContext.get("requestHeaders")); @@ -180,7 +185,7 @@ protected String doApply( List> fields = List.of(); if (operation.getRequestBody() != null) { var schema = schema(context, operation.getRequestBody()); - fields = schemaToTable(schema); + fields = schemaToTable(schema, context); } snippetContext.put("fields", fields); return resolver.apply(id(), snippetContext); @@ -207,7 +212,8 @@ protected String doApply( var json = InternalContext.json(context); var schema = schema(context, response); if (schema != null) { - requestBodyString = json.writeValueAsString(SchemaData.from(schema)) + "\n"; + requestBodyString = + json.writeValueAsString(SchemaData.from(schema, schemaRefResolver(context))) + "\n"; } snippetContext.put("statusCode", statusCode.value()); snippetContext.put("statusReason", statusCode.reason()); @@ -231,7 +237,7 @@ protected String doApply( var statusCode = findStatusCode(args); var response = responseByStatusCode(operation, statusCode); var schema = schema(context, response); - snippetContext.put("fields", schemaToTable(schema)); + snippetContext.put("fields", schemaToTable(schema, context)); return resolver.apply(id(), snippetContext); } }, @@ -325,7 +331,12 @@ public String apply( var snippetResolver = InternalContext.resolver(context); var json = InternalContext.json(context); return snippetResolver.apply( - id(), Map.of("schema", json.writeValueAsString(SchemaData.from(schema)))); + id(), + Map.of( + "schema", + json.writer() + .withDefaultPrettyPrinter() + .writeValueAsString(SchemaData.from(schema, schemaRefResolver(context))))); } catch (PebbleException pebbleException) { throw pebbleException; } catch (Exception exception) { @@ -378,7 +389,7 @@ protected String doApply( throw new IllegalArgumentException("No schema response for: " + statusCode); } } else { - schemaData = SchemaData.from(schema); + schemaData = SchemaData.from(schema, schemaRefResolver(context)); } var responseString = json.writer().withDefaultPrettyPrinter().writeValueAsString(schemaData); snippetContext.put( @@ -496,9 +507,9 @@ protected StatusCode findStatusCode(Map args) { return null; } - protected List> schemaToTable(Schema schema) { + protected List> schemaToTable(Schema schema, EvaluationContext context) { List> fields = new ArrayList<>(); - SchemaData.from(schema) + SchemaData.from(schema, schemaRefResolver(context)) .forEach( (name, type) -> { var field = new LinkedHashMap(); @@ -554,14 +565,22 @@ protected Schema schema(EvaluationContext context, Object input) { "Unable to get schema from " + input.getClass().getName()); }; if (schema != null && schema.get$ref() != null) { - var openapi = InternalContext.openApi(context); + return schemaRefResolver(context).apply(schema.get$ref()).orElse(schema); + } + return schema; + } + + @SuppressWarnings("unchecked") + protected Function>> schemaRefResolver(EvaluationContext context) { + var openapi = InternalContext.openApi(context); + return ref -> { + var name = ref.substring("#/components/schemas/".length()); var components = openapi.getComponents(); if (components != null) { - var name = schema.get$ref().substring("#/components/schemas/".length()); - return components.getSchemas().getOrDefault(name, schema); + return Optional.ofNullable(components.getSchemas().get(name)); } - } - return schema; + return Optional.empty(); + }; } protected Multimap parseHeaders(Collection headers) { diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/SchemaData.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/SchemaData.java index 2881f6dd31..7672007545 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/SchemaData.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/SchemaData.java @@ -8,32 +8,45 @@ import static java.util.Optional.ofNullable; import java.util.*; +import java.util.function.Function; -import io.jooby.internal.openapi.ModelConvertersExt; import io.swagger.v3.oas.models.media.Schema; public class SchemaData { - public static Map from(Class schema) { - return from(ModelConvertersExt.getInstance().read(schema).get(schema.getSimpleName())); - } - - public static Map from(Schema schema) { - return ofNullable(traverse(schema.getProperties())).orElse(Map.of()); + public static Map from( + Schema schema, Function>> resolver) { + return ofNullable(traverse(schema.getProperties(), resolver)).orElse(Map.of()); } @SuppressWarnings("rawtypes") - private static Map traverse(Map properties) { + private static Map traverse( + Map properties, Function>> resolver) { if (properties != null) { Map result = new LinkedHashMap<>(); properties.forEach( (name, value) -> { - if (value.getType().equals("object")) { - result.put(name, from(value)); + if (value.getType() == null) { + // must be a reference + var ref = value.get$ref(); + if (ref != null) { + var refSchema = resolver.apply(ref); + if (refSchema.isPresent()) { + result.put(name, from(refSchema.get(), resolver)); + } else { + // resolve as empty/missing + result.put(name, Map.of()); + } + } else { + // resolve as empty/missing + result.put(name, Map.of()); + } + } else if (value.getType().equals("object")) { + result.put(name, from(value, resolver)); } else if (value.getType().equals("array")) { var array = ofNullable(value.getItems()) .map(Schema::getProperties) - .map(SchemaData::traverse) + .map(it -> traverse(it, resolver)) .map(List::of) .orElse(List.of()); result.put(name, array); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java index fe858b2f32..15f76b5fd7 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java @@ -47,7 +47,10 @@ public enum Format { /** JSON. */ JSON { @Override - public String toString(OpenAPIGenerator tool, OpenAPI result) { + @NonNull protected String toString( + @NonNull OpenAPIGenerator tool, + @NonNull OpenAPI result, + @NonNull Map options) { return tool.toJson(result); } }, @@ -55,15 +58,47 @@ public String toString(OpenAPIGenerator tool, OpenAPI result) { /** YAML. */ YAML { @Override - public String toString(OpenAPIGenerator tool, OpenAPI result) { + @NonNull protected String toString( + @NonNull OpenAPIGenerator tool, + @NonNull OpenAPI result, + @NonNull Map options) { return tool.toYaml(result); } }, ADOC { @Override - public String toString(OpenAPIGenerator tool, OpenAPI result) { - return tool.toYaml(result); + @NonNull protected String toString( + @NonNull OpenAPIGenerator tool, + @NonNull OpenAPI result, + @NonNull Map options) { + return tool.toAdoc(result, options); + } + + @SuppressWarnings("unchecked") + @NonNull @Override + public List write( + @NonNull OpenAPIGenerator tool, + @NonNull OpenAPI result, + @NonNull Map options) + throws IOException { + var files = (List) options.get("adoc"); + if (files == null || files.isEmpty()) { + // adoc generation is optional + return List.of(); + } + var outputDir = (Path) options.get("outputDir"); + var outputList = new ArrayList(); + for (var file : files) { + var opts = new HashMap<>(options); + opts.put("adoc", file); + var content = toString(tool, result, opts); + var output = outputDir.resolve(file.getFileName()); + Files.write(output, List.of(content)); + AsciiDocGenerator.export(file.getParent(), output, outputDir); + outputList.add(output); + } + return outputList; } }; @@ -83,8 +118,28 @@ public String toString(OpenAPIGenerator tool, OpenAPI result) { * @param result Model. * @return String (json or yaml content). */ - public abstract @NonNull String toString( - @NonNull OpenAPIGenerator tool, @NonNull OpenAPI result); + protected abstract @NonNull String toString( + @NonNull OpenAPIGenerator tool, + @NonNull OpenAPI result, + @NonNull Map options); + + /** + * Convert an {@link OpenAPI} model to the current format. + * + * @param tool Generator. + * @param result Model. + * @return String (json or yaml content). + */ + public @NonNull List write( + @NonNull OpenAPIGenerator tool, + @NonNull OpenAPI result, + @NonNull Map options) + throws IOException { + var output = (Path) options.get("output"); + var content = toString(tool, result, options); + Files.write(output, List.of(content)); + return List.of(output); + } } private Logger log = LoggerFactory.getLogger(getClass()); @@ -118,29 +173,31 @@ public OpenAPIGenerator() {} * @return Output file. * @throws IOException If fails to process input. */ - public @NonNull Path export(@NonNull OpenAPI openAPI, @NonNull Format format) throws IOException { + public @NonNull List export( + @NonNull OpenAPI openAPI, @NonNull Format format, @NonNull Map options) + throws IOException { Path output; if (openAPI instanceof OpenAPIExt) { - String source = ((OpenAPIExt) openAPI).getSource(); - String[] names = source.split("\\."); + var source = ((OpenAPIExt) openAPI).getSource(); + var names = source.split("\\."); output = Stream.of(names).limit(names.length - 1).reduce(outputDir, Path::resolve, Path::resolve); - String appname = names[names.length - 1]; + var appname = names[names.length - 1]; if (appname.endsWith("Kt")) { appname = appname.substring(0, appname.length() - 2); } output = output.resolve(appname + "." + format.extension()); } else { - output = outputDir.resolve("openapi." + format.extension()); + throw new ClassCastException(openAPI.getClass() + " is not a " + OpenAPIExt.class); } if (!Files.exists(output.getParent())) { Files.createDirectories(output.getParent()); } - - String content = format.toString(this, openAPI); - Files.write(output, Collections.singleton(content)); - return output; + var allOptions = new HashMap<>(options); + allOptions.put("output", output); + allOptions.put("outputDir", output.getParent()); + return format.write(this, openAPI, allOptions); } /** @@ -323,21 +380,13 @@ private void defaults(String classname, String contextPath, OpenAPIExt openapi) * @param openAPI Model. * @return YAML content. */ - public @NonNull String toAdoc(@NonNull OpenAPI openAPI) { + public @NonNull String toAdoc(@NonNull OpenAPI openAPI, @NonNull Map options) { try { - return AsciiDocGenerator.generate( - (OpenAPIExt) openAPI, - java.nio.file.Paths.get( - "Users", - "edgar", - "Source", - "jooby", - "modules", - "jooby-openapi", - "src", - "test", - "resources", - "adoc")); + var file = (Path) options.get("adoc"); + if (file == null) { + throw new IllegalArgumentException("'adoc' file is required: " + options); + } + return AsciiDocGenerator.generate((OpenAPIExt) openAPI, file); } catch (IOException x) { throw SneakyThrows.propagate(x); } @@ -484,7 +533,7 @@ public void setOutputDir(@NonNull Path outputDir) { * * @param specVersion One of 3.0 or 3.1. */ - public void setSpecVersion(SpecVersion specVersion) { + private void setSpecVersion(SpecVersion specVersion) { this.specVersion = specVersion; } @@ -494,16 +543,16 @@ public void setSpecVersion(SpecVersion specVersion) { * @param version One of 3.0 or 3.1. */ public void setSpecVersion(String version) { - if (specVersion != null) { - switch (version) { - case "v3.1", "v3.1.0", "3.1", "3.1.0": - setSpecVersion(SpecVersion.V31); - case "v3.0", "v3.0.0", "3.0", "3.0.0", "v3.0.1", "3.0.1": - setSpecVersion(SpecVersion.V30); - default: - throw new IllegalArgumentException( - "Invalid spec version: " + version + ". Supported version: [3.0.1, 3.1.0]"); - } + switch (version) { + case "v3.1", "v3.1.0", "3.1", "3.1.0", "V31": + setSpecVersion(SpecVersion.V31); + break; + case "v3.0", "v3.0.0", "3.0", "3.0.0", "v3.0.1", "3.0.1", "V30": + setSpecVersion(SpecVersion.V30); + break; + default: + throw new IllegalArgumentException( + "Invalid spec version: " + version + ". Supported version: [3.0.1, 3.1.0]"); } } diff --git a/modules/jooby-openapi/src/main/java/module-info.java b/modules/jooby-openapi/src/main/java/module-info.java index 9c74f44759..ed9e318c67 100644 --- a/modules/jooby-openapi/src/main/java/module-info.java +++ b/modules/jooby-openapi/src/main/java/module-info.java @@ -21,4 +21,8 @@ requires jdk.jshell; requires com.google.common; requires org.checkerframework.checker.qual; + requires org.asciidoctor.asciidoctorj.api; + requires jakarta.data; + requires io.swagger.annotations; + requires org.jruby; } diff --git a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java index 41b75db0f7..3f65e8cca5 100644 --- a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java +++ b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java @@ -737,7 +737,15 @@ public void schema() { """ [source,json] ---- - {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} + { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "authors" : [ ], + "image" : "binary" + } ----\ """); @@ -756,7 +764,15 @@ public void schema() { """ [source,json] ---- - {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} + { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "authors" : [ ], + "image" : "binary" + } ----\ """); @@ -778,7 +794,15 @@ public void schema() { """ [source,json] ---- - {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} + { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "authors" : [ ], + "image" : "binary" + } ----\ """); } diff --git a/modules/jooby-openapi/src/test/java/io/jooby/openapi/CurrentDir.java b/modules/jooby-openapi/src/test/java/io/jooby/openapi/CurrentDir.java index cf27d6a07d..06513e960a 100644 --- a/modules/jooby-openapi/src/test/java/io/jooby/openapi/CurrentDir.java +++ b/modules/jooby-openapi/src/test/java/io/jooby/openapi/CurrentDir.java @@ -7,9 +7,15 @@ import java.nio.file.Path; import java.nio.file.Paths; +import java.util.List; +import java.util.stream.Stream; public class CurrentDir { public static Path basedir(String... others) { + return basedir(List.of(others)); + } + + public static Path basedir(List others) { var baseDir = Paths.get(System.getProperty("user.dir")); if (!baseDir.getFileName().toString().endsWith("jooby-openapi")) { baseDir = baseDir.resolve("modules").resolve("jooby-openapi"); @@ -19,4 +25,13 @@ public static Path basedir(String... others) { } return baseDir; } + + public static Path testClass(Class clazz, String file) { + var packageDir = clazz.getPackage().getName().split("\\."); + return basedir( + Stream.concat( + Stream.concat(Stream.of("src", "test", "resources"), Stream.of(packageDir)), + Stream.of(file)) + .toList()); + } } diff --git a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIExtension.java b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIExtension.java index b6ceddf36b..3a2f3ef3ea 100644 --- a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIExtension.java +++ b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIExtension.java @@ -43,7 +43,7 @@ public Object resolveParameter(ParameterContext parameterContext, ExtensionConte : EnumSet.copyOf(Arrays.asList(metadata.debug())); OpenAPIGenerator tool = newTool(debugOptions); - tool.setSpecVersion(metadata.version()); + tool.setSpecVersion(metadata.version().name()); String templateName = metadata.templateName(); if (templateName.isEmpty()) { templateName = classname.replace(".", "/").toLowerCase() + ".yaml"; diff --git a/modules/jooby-openapi/src/test/java/issues/Issue1582.java b/modules/jooby-openapi/src/test/java/issues/Issue1582.java index b6a4f04eb8..9b4cbf04ab 100644 --- a/modules/jooby-openapi/src/test/java/issues/Issue1582.java +++ b/modules/jooby-openapi/src/test/java/issues/Issue1582.java @@ -12,6 +12,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; @@ -24,35 +26,36 @@ public class Issue1582 { @Test public void shouldGenerateOnOneLvelPackageLocation() throws IOException { - Path output = export("com.App"); - assertTrue(Files.exists(output)); - assertEquals(outDir.resolve("com").resolve("App.yaml"), output); + var output = export("com.App"); + output.forEach(it -> assertTrue(Files.exists(it))); + assertEquals(List.of(outDir.resolve("com").resolve("App.yaml")), output); } @Test public void shouldGenerateOnPackageLocation() throws IOException { - Path output = export("com.myapp.App"); - assertTrue(Files.exists(output)); - assertEquals(outDir.resolve("com").resolve("myapp").resolve("App.yaml"), output); + var output = export("com.myapp.App"); + output.forEach(it -> assertTrue(Files.exists(it))); + assertEquals(List.of(outDir.resolve("com").resolve("myapp").resolve("App.yaml")), output); } @Test public void shouldGenerateOnDeepPackageLocation() throws IOException { - Path output = export("com.foo.bar.app.App"); - assertTrue(Files.exists(output)); + var output = export("com.foo.bar.app.App"); + output.forEach(it -> assertTrue(Files.exists(it))); assertEquals( - outDir.resolve("com").resolve("foo").resolve("bar").resolve("app").resolve("App.yaml"), + List.of( + outDir.resolve("com").resolve("foo").resolve("bar").resolve("app").resolve("App.yaml")), output); } @Test public void shouldGenerateOnRootLocation() throws IOException { - Path output = export("App"); - assertTrue(Files.exists(output)); - assertEquals(outDir.resolve("App.yaml"), output); + var output = export("App"); + output.forEach(it -> assertTrue(Files.exists(it))); + assertEquals(List.of(outDir.resolve("App.yaml")), output); } - private Path export(String source) throws IOException { + private List export(String source) throws IOException { Info info = new Info(); info.setTitle("API"); info.setVersion("1.0"); @@ -64,6 +67,6 @@ private Path export(String source) throws IOException { OpenAPIGenerator generator = new OpenAPIGenerator(); generator.setOutputDir(outDir); - return generator.export(openAPI, OpenAPIGenerator.Format.YAML); + return generator.export(openAPI, OpenAPIGenerator.Format.YAML, Map.of()); } } diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java index 39a7a32b56..11aa4e9f6b 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java @@ -19,6 +19,224 @@ public void shouldGenerateMvcDoc(OpenAPIResult result) { checkResult(result); } + @OpenAPITest(value = AppLibrary2.class) + public void shouldGenerateGoodDoc(OpenAPIResult result) { + assertThat(result.toYaml()) + .isEqualToIgnoringNewLines( + """ + openapi: 3.0.1 + info: + title: Library2 API + description: Library2 API description + version: "1.0" + tags: + - name: Library + description: Available library operations. + paths: + /library/books/{isbn}: + summary: The Public Front Desk of the library. + get: + tags: + - Library + summary: Get Book Details. + description: Call this to show the details page of a specific book. + operationId: getBook + parameters: + - name: isbn + in: path + description: "The unique ID from the URL (e.g., /books/978-3-16-148410-0)" + required: true + schema: + type: string + responses: + "200": + description: The book data + content: + application/json: + schema: + $ref: "#/components/schemas/Book" + "404": + description: "Not Found: error if it doesn't exist." + /library/search: + summary: The Public Front Desk of the library. + get: + tags: + - Library + summary: Search Books. + description: "A general search bar. Users type a word, and we find matches." + operationId: searchBooks + parameters: + - name: q + in: query + description: The search term typed by the user. + schema: + type: string + responses: + "200": + description: A list of books matching that term. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Book" + /library/books: + summary: The Public Front Desk of the library. + get: + tags: + - Library + summary: Browse Books (Paginated). + operationId: getBooksByTitle + parameters: + - name: title + in: query + description: The exact book title to filter by. + schema: + type: string + - name: page + in: query + description: Which page number to load (defaults to 1). + required: true + schema: + type: integer + format: int32 + - name: size + in: query + description: How many books to show per page (defaults to 20). + required: true + schema: + type: integer + format: int32 + responses: + "200": + description: "A \\"Page\\" object containing the books and info like \\"Total\\ + \\ Pages: 5\\"." + content: + application/json: + schema: + type: object + properties: + content: + type: array + items: + $ref: "#/components/schemas/Book" + numberOfElements: + type: integer + format: int32 + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int64 + pageRequest: + $ref: "#/components/schemas/PageRequest" + nextPageRequest: + $ref: "#/components/schemas/PageRequest" + previousPageRequest: + $ref: "#/components/schemas/PageRequest" + post: + tags: + - Library + summary: Add New Book. + description: "Usage: Send a JSON packet to this URL to create a new book entry\\ + \\ in the system." + operationId: addBook + requestBody: + description: New book to add. + content: + application/json: + schema: + $ref: "#/components/schemas/Book" + required: true + responses: + "200": + description: A text message confirming success. + content: + application/json: + schema: + $ref: "#/components/schemas/Book" + components: + schemas: + Address: + type: object + properties: + street: + type: string + description: Street name. + city: + type: string + description: City name. + state: + type: string + description: State. + country: + type: string + description: Two digit country code. + description: Author address. + PageRequest: + type: object + properties: + page: + type: integer + format: int64 + size: + type: integer + format: int32 + Book: + type: object + properties: + isbn: + type: string + description: Book ISBN. Method. + title: + type: string + description: Book's title. + publicationDate: + type: string + description: Publication date. Format mm-dd-yyyy. + format: date + text: + type: string + description: Book's content. + type: + type: string + description: |- + Book type. + - Fiction: Fiction books are based on imaginary characters and events, while non-fiction books are based o n real people and events. + - NonFiction: Non-fiction genres include biography, autobiography, history, self-help, and true crime. + enum: + - Fiction + - NonFiction + authors: + uniqueItems: true + type: array + items: + $ref: "#/components/schemas/Author" + image: + type: string + format: binary + description: Book model. + Author: + type: object + properties: + ssn: + type: string + description: Social security number. + name: + type: string + description: Author's name. + address: + $ref: "#/components/schemas/Address" + books: + uniqueItems: true + type: array + description: Published books. + items: + $ref: "#/components/schemas/Book" + """); + } + @OpenAPITest(value = AppLibrary.class) public void shouldGenerateAdoc(OpenAPIResult result) { assertThat( diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/AppLibrary2.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/AppLibrary2.java new file mode 100644 index 0000000000..8a0983b7c8 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/AppLibrary2.java @@ -0,0 +1,17 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3729.api; + +import static io.jooby.openapi.MvcExtensionGenerator.toMvcExtension; + +import io.jooby.Jooby; + +public class AppLibrary2 extends Jooby { + + { + mvc(toMvcExtension(LibraryApi2.class)); + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi2.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi2.java new file mode 100644 index 0000000000..3713a4b057 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi2.java @@ -0,0 +1,94 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3729.api; + +import java.util.List; + +import io.jooby.annotation.*; +import io.jooby.exception.NotFoundException; +import jakarta.data.page.Page; +import jakarta.data.page.PageRequest; +import jakarta.inject.Inject; + +/** + * The Public Front Desk of the library. + * + * @tag Library. Available library operations. + */ +@Path("/library") +public class LibraryApi2 { + + private final LibraryRepo library; + + @Inject + public LibraryApi2(LibraryRepo library) { + this.library = library; + } + + /** + * Get Book Details. + * + *

Call this to show the details page of a specific book. + * + * @param isbn The unique ID from the URL (e.g., /books/978-3-16-148410-0) + * @return The book data + * @throws NotFoundException 404 error if it doesn't exist. + */ + @GET + @Path("/books/{isbn}") + public Book getBook(@PathParam String isbn) { + return library.findBook(isbn).orElseThrow(() -> new NotFoundException(isbn)); + } + + /** + * Search Books. + * + *

A general search bar. Users type a word, and we find matches. + * + * @param q The search term typed by the user. + * @return A list of books matching that term. + */ + @GET + @Path("/search") + public List searchBooks(@QueryParam String q) { + var pattern = "%" + (q != null ? q : "") + "%"; + + return library.searchBooks(pattern); + } + + /** + * Browse Books (Paginated). + * + * @param title The exact book title to filter by. + * @param page Which page number to load (defaults to 1). + * @param size How many books to show per page (defaults to 20). + * @return A "Page" object containing the books and info like "Total Pages: 5". + */ + @GET + @Path("/books") + public Page getBooksByTitle( + @QueryParam String title, @QueryParam int page, @QueryParam int size) { + // Ensure we have sensible defaults if the user sends nothing + int pageNum = page > 0 ? page : 1; + int pageSize = size > 0 ? size : 20; + + // Ask the database for just this specific slice of data + return library.findBooksByTitle(title, PageRequest.ofPage(pageNum).size(pageSize)); + } + + /** + * Add New Book. Usage: Send a JSON packet to this URL to create a new book entry in the system. + * + * @param book New book to add. + * @return A text message confirming success. + */ + @POST + @Path("/books") + public Book addBook(Book book) { + // Save it + return library.add(book); + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryRepo.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryRepo.java new file mode 100644 index 0000000000..30d17dd475 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryRepo.java @@ -0,0 +1,87 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3729.api; + +import java.util.List; +import java.util.Optional; + +import jakarta.data.page.Page; +import jakarta.data.page.PageRequest; +import jakarta.data.repository.*; + +/** + * The "Librarian" of our system. + * + *

This interface handles all the work of finding, saving, and removing books and authors from + * the database. You don't need to write the code for this; the system builds it automatically based + * on these method names. + */ +@Repository +public interface LibraryRepo { + + // --- Finding Items --- + + /** + * Looks up a single book using its ISBN code. + * + * @param isbn The unique code to look for. + * @return An "Optional" box that contains the book if we found it, or is empty if we didn't. + */ + @Find + Optional findBook(String isbn); + + /** Looks up an author using their ID. */ + @Find + Optional findAuthor(String ssn); + + /** + * Finds books that match a specific title. + * + *

Because there might be thousands of results, this method splits them into "pages". You ask + * for "Page 1" or "Page 5", and it gives you just that chunk. + * + * @param title The exact title to look for. + * @param pageRequest Which page of results do you want? + * @return A page containing a list of books and total count info. + */ + @Find + Page findBooksByTitle(String title, PageRequest pageRequest); + + // --- Custom Searches --- + + /** + * Search for books that have a specific word in the title. + * + *

Example: If you search for "%Harry%", it finds "Harry Potter" and "Dirty Harry". It also + * sorts the results alphabetically by title. + */ + @Query("where title like :pattern order by title") + List searchBooks(String pattern); + + /** + * A custom report that just lists the titles of new books. Useful for creating quick lists + * without loading all the book details. + * + * @param minYear The oldest year we care about (e.g., 2023). + * @return Just the names of the books. + */ + @Query("select title from Book where extract(year from publicationDate) >= :minYear") + List findRecentBookTitles(int minYear); + + // --- Saving & Deleting --- + + /** Registers a new book in the system. */ + @Insert + Book add(Book book); + + /** Saves changes made to an author's details. */ + @Update + void update(Author author); + + /** Permanently removes a book from the library. */ + @Delete + void remove(Book book); +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/App3820a.java b/modules/jooby-openapi/src/test/java/issues/i3820/App3820a.java new file mode 100644 index 0000000000..29925c495e --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3820/App3820a.java @@ -0,0 +1,19 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3820; + +import io.jooby.Jooby; +import issues.i3820.model.Book; + +public class App3820a extends Jooby { + { + post( + "/library/books", + ctx -> { + return ctx.body(Book.class); + }); + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/Issue3820.java b/modules/jooby-openapi/src/test/java/issues/i3820/Issue3820.java new file mode 100644 index 0000000000..d1a40b9de6 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3820/Issue3820.java @@ -0,0 +1,26 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3820; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.jooby.openapi.CurrentDir; +import io.jooby.openapi.OpenAPIResult; +import io.jooby.openapi.OpenAPITest; + +public class Issue3820 { + @OpenAPITest(value = App3820a.class) + public void shouldGenerateRequestBodySchema(OpenAPIResult result) { + assertThat(result.toAsciiDoc(CurrentDir.testClass(getClass(), "schema.adoc"))) + .isEqualToIgnoringNewLines( + """ + [source,json] + ---- + {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","publisher":{"id":"int64","name":"string"},"authors":[]} + ---- + """); + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/model/Address.java b/modules/jooby-openapi/src/test/java/issues/i3820/model/Address.java new file mode 100644 index 0000000000..c0a010fca8 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3820/model/Address.java @@ -0,0 +1,35 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3820.model; + +/** + * A reusable way to store address details (Street, City, Zip). We can reuse this on Authors, + * Publishers, or Users. + */ +public class Address { + /** + * The specific street address. + * + *

Includes the house number, street name, and apartment number if applicable. Example: "123 + * Maple Avenue, Apt 4B". + */ + public String street; + + /** + * The town, city, or municipality. + * + *

Used for grouping authors by location or calculating shipping regions. + */ + public String city; + + /** + * The postal or zip code. + * + *

Stored as text (String) rather than a number to support codes that start with zero (e.g., + * "02138") or contain letters (e.g., "K1A 0B1"). + */ + public String zip; +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/model/Author.java b/modules/jooby-openapi/src/test/java/issues/i3820/model/Author.java new file mode 100644 index 0000000000..2d5d4a25ec --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3820/model/Author.java @@ -0,0 +1,35 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3820.model; + +import java.util.HashSet; +import java.util.Set; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** A person who writes books. */ +public class Author { + + /** The author's unique government ID (SSN). */ + public String ssn; + + /** The full name of the author. */ + public String name; + + /** + * Where the author lives. This information is stored inside the Author table, not a separate one. + */ + public Address address; + + @JsonIgnore public Set books = new HashSet<>(); + + public Author() {} + + public Author(String ssn, String name) { + this.ssn = ssn; + this.name = name; + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/model/Book.java b/modules/jooby-openapi/src/test/java/issues/i3820/model/Book.java new file mode 100644 index 0000000000..3093abc434 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3820/model/Book.java @@ -0,0 +1,161 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3820.model; + +import java.time.LocalDate; +import java.util.HashSet; +import java.util.Set; + +/** + * Represents a physical Book in our library. + * + *

This is the main item visitors look for. It holds details like the title, the actual text + * content, and who published it. + */ +public class Book { + + /** + * The unique "barcode" for this book (ISBN). We use this to identify exactly which book edition + * we are talking about. + */ + private String isbn; + + /** The name printed on the cover. */ + private String title; + + /** When this book was released to the public. */ + private LocalDate publicationDate; + + /** + * The full story or content of the book. + * + *

Since this can be very long, we store it in a special way (Large Object) to keep the + * database fast. + */ + private String text; + + /** Categorizes the item (e.g., is it a regular Book or a Magazine?). */ + private Type type; + + /** + * The company that published this book. + * + *

Performance Note: We only load this information if you specifically ask for it ("Lazy"), + * which saves memory. + */ + private Publisher publisher; + + /** The list of people who wrote this book. */ + private Set authors = new HashSet<>(); + + /** Defines the format and release schedule of the item. */ + public enum Type { + /** + * A fictional narrative story. + * + *

Examples: "Pride and Prejudice", "Harry Potter", "Dune". These are creative works meant + * for entertainment or artistic expression. + */ + NOVEL, + + /** + * A written account of a real person's life. + * + *

Examples: "Steve Jobs" by Walter Isaacson, "The Diary of a Young Girl". These are + * non-fiction historical records of an individual. + */ + BIOGRAPHY, + + /** + * An educational book used for study. + * + *

Examples: "Calculus: Early Transcendentals", "Introduction to Java Programming". These are + * designed for students and are often used as reference material in academic courses. + */ + TEXTBOOK, + + /** + * A periodical publication intended for general readers. + * + *

Examples: Time, National Geographic, Vogue. These contain various articles, are published + * frequently (weekly/monthly), and often have a glossy format. + */ + MAGAZINE, + + /** + * A scholarly or professional publication. + * + *

Examples: The New England Journal of Medicine, Harvard Law Review. These focus on academic + * research or trade news and are written by experts for other experts. + */ + JOURNAL + } + + public Book() {} + + public Book(String isbn, String title, Type type) { + this.isbn = isbn; + this.title = title; + this.type = type; + this.text = "Content placeholder"; + } + + public String getIsbn() { + return isbn; + } + + public void setIsbn(String isbn) { + this.isbn = isbn; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public LocalDate getPublicationDate() { + return publicationDate; + } + + public void setPublicationDate(LocalDate publicationDate) { + this.publicationDate = publicationDate; + } + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } + + public Type getType() { + return type; + } + + public void setType(Type type) { + this.type = type; + } + + public Publisher getPublisher() { + return publisher; + } + + public void setPublisher(Publisher publisher) { + this.publisher = publisher; + } + + public Set getAuthors() { + return authors; + } + + public void setAuthors(Set authors) { + this.authors = authors; + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/model/Publisher.java b/modules/jooby-openapi/src/test/java/issues/i3820/model/Publisher.java new file mode 100644 index 0000000000..322e09341d --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3820/model/Publisher.java @@ -0,0 +1,46 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3820.model; + +/** A company that produces and sells books. */ +public class Publisher { + /** + * The unique internal ID for this publisher. + * + *

This is a number generated automatically by the system. Users usually don't need to memorize + * this, but it's used by the database to link books to their publishers. + */ + private Long id; + + /** + * The official business name of the publishing house. + * + *

Example: "Penguin Random House" or "O'Reilly Media". + */ + private String name; + + public Publisher() {} + + public Publisher(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } +} diff --git a/modules/jooby-openapi/src/test/resources/docs/api-guide.adoc b/modules/jooby-openapi/src/test/resources/docs/api-guide.adoc deleted file mode 100644 index 8cb817847e..0000000000 --- a/modules/jooby-openapi/src/test/resources/docs/api-guide.adoc +++ /dev/null @@ -1,228 +0,0 @@ -= RESTful Notes API Guide -Andy Wilkinson; -:doctype: book -:icons: font -:source-highlighter: highlightjs -:toc: left -:toclevels: 4 -:sectlinks: -:operation-curl-request-title: Example request -:operation-http-response-title: Example response - -[[overview]] -= Overview - -[[overview_http_verbs]] -== HTTP verbs - -RESTful notes tries to adhere as closely as possible to standard HTTP and REST conventions in its -use of HTTP verbs. - -|=== -| Verb | Usage - -| `GET` -| Used to retrieve a resource - -| `POST` -| Used to create a new resource - -| `PATCH` -| Used to update an existing resource, including partial updates - -| `DELETE` -| Used to delete an existing resource -|=== - -[[overview_http_status_codes]] -== HTTP status codes - -RESTful notes tries to adhere as closely as possible to standard HTTP and REST conventions in its -use of HTTP status codes. - -|=== -| Status code | Usage - -| `200 OK` -| The request completed successfully - -| `201 Created` -| A new resource has been created successfully. The resource's URI is available from the response's -`Location` header - -| `204 No Content` -| An update to an existing resource has been applied successfully - -| `400 Bad Request` -| The request was malformed. The response body will include an error providing further information - -| `404 Not Found` -| The requested resource did not exist -|=== - -[[overview_errors]] -== Errors - -Whenever an error response (status code >= 400) is returned, the body will contain a JSON object -that describes the problem. The error object has the following structure: - -include::{snippets}/error-example/response-fields.adoc[] - -For example, a request that attempts to apply a non-existent tag to a note will produce a -`400 Bad Request` response: - -include::{snippets}/error-example/http-response.adoc[] - -[[overview_hypermedia]] -== Hypermedia - -RESTful Notes uses hypermedia and resources include links to other resources in their -responses. Responses are in https://github.com/mikekelly/hal_specification[Hypertext -Application Language (HAL)] format. Links can be found beneath the `_links` key. Users of -the API should not create URIs themselves, instead they should use the above-described -links to navigate from resource to resource. - -[[resources]] -= Resources - - - -[[resources_index]] -== Index - -The index provides the entry point into the service. - - - -[[resources_index_access]] -=== Accessing the index - -A `GET` request is used to access the index - -operation::index-example[snippets='response-fields,http-response,links'] - - - -[[resources_notes]] -== Notes - -The Notes resources is used to create and list notes - - - -[[resources_notes_list]] -=== Listing notes - -A `GET` request will list all of the service's notes. - -operation::notes-list-example[snippets='response-fields,curl-request,http-response,links'] - - - -[[resources_notes_create]] -=== Creating a note - -A `POST` request is used to create a note. - -operation::notes-create-example[snippets='request-fields,curl-request,http-response'] - - - -[[resources_tags]] -== Tags - -The Tags resource is used to create and list tags. - - - -[[resources_tags_list]] -=== Listing tags - -A `GET` request will list all of the service's tags. - -operation::tags-list-example[snippets='response-fields,curl-request,http-response,links'] - - - -[[resources_tags_create]] -=== Creating a tag - -A `POST` request is used to create a note - -operation::tags-create-example[snippets='request-fields,curl-request,http-response'] - - - -[[resources_note]] -== Note - -The Note resource is used to retrieve, update, and delete individual notes - - - -[[resources_note_links]] -=== Links - -include::{snippets}/note-get-example/links.adoc[] - - - -[[resources_note_retrieve]] -=== Retrieve a note - -A `GET` request will retrieve the details of a note - -operation::note-get-example[snippets='response-fields,curl-request,http-response'] - - - -[[resources_note_update]] -=== Update a note - -A `PATCH` request is used to update a note - -==== Request structure - -include::{snippets}/note-update-example/request-fields.adoc[] - -To leave an attribute of a note unchanged, any of the above may be omitted from the request. - -==== Example request - -include::{snippets}/note-update-example/curl-request.adoc[] - -==== Example response - -include::{snippets}/note-update-example/http-response.adoc[] - - - -[[resources_tag]] -== Tag - -The Tag resource is used to retrieve, update, and delete individual tags - - - -[[resources_tag_links]] -=== Links - -include::{snippets}/tag-get-example/links.adoc[] - - - -[[resources_tag_retrieve]] -=== Retrieve a tag - -A `GET` request will retrieve the details of a tag - -operation::tag-get-example[snippets='response-fields,curl-request,http-response'] - - - -[[resources_tag_update]] -=== Update a tag - -A `PATCH` request is used to update a tag - -operation::tag-update-example[snippets='request-fields,curl-request,http-response'] diff --git a/modules/jooby-openapi/src/test/resources/docs/getting-started-guide.adoc b/modules/jooby-openapi/src/test/resources/docs/getting-started-guide.adoc deleted file mode 100644 index c1af376c76..0000000000 --- a/modules/jooby-openapi/src/test/resources/docs/getting-started-guide.adoc +++ /dev/null @@ -1,175 +0,0 @@ -= RESTful Notes Getting Started Guide -Andy Wilkinson; -:doctype: book -:icons: font -:source-highlighter: highlightjs -:toc: left -:toclevels: 4 -:sectlinks: - -[[introduction]] -= Introduction - -RESTful Notes is a RESTful web service for creating and storing notes. It uses hypermedia -to describe the relationships between resources and to allow navigation between them. - - - -[[getting_started_running_the_service]] -== Running the service -RESTful Notes is written using https://projects.spring.io/spring-boot[Spring Boot] which -makes it easy to get it up and running so that you can start exploring the REST API. - -The first step is to clone the Git repository: - -[source,bash] ----- -$ git clone https://github.com/spring-projects/spring-restdocs-samples ----- - -Once the clone is complete, you're ready to get the service up and running: - -[source,bash] ----- -$ cd restful-notes-spring-data-rest -$ ./mvnw clean package -$ java -jar target/*.jar ----- - -You can check that the service is up and running by executing a simple request using -cURL: - -include::{snippets}/index/1/curl-request.adoc[] - -This request should yield the following response in the -https://github.com/mikekelly/hal_specification[Hypertext Application Language (HAL)] -format: - -include::{snippets}/index/1/http-response.adoc[] - -Note the `_links` in the JSON response. They are key to navigating the API. - - - -[[getting_started_creating_a_note]] -== Creating a note -Now that you've started the service and verified that it works, the next step is to use -it to create a new note. As you saw above, the URI for working with notes is included as -a link when you perform a `GET` request against the root of the service: - -include::{snippets}/index/1/http-response.adoc[] - -To create a note, you need to execute a `POST` request to this URI including a JSON -payload containing the title and body of the note: - -include::{snippets}/creating-a-note/1/curl-request.adoc[] - -The response from this request should have a status code of `201 Created` and contain a -`Location` header whose value is the URI of the newly created note: - -include::{snippets}/creating-a-note/1/http-response.adoc[] - -To work with the newly created note you use the URI in the `Location` header. For example, -you can access the note's details by performing a `GET` request: - -include::{snippets}/creating-a-note/2/curl-request.adoc[] - -This request will produce a response with the note's details in its body: - -include::{snippets}/creating-a-note/2/http-response.adoc[] - -Note the `tags` link which we'll make use of later. - - - -[[getting_started_creating_a_tag]] -== Creating a tag -To make a note easier to find, it can be associated with any number of tags. To be able -to tag a note, you must first create the tag. - -Referring back to the response for the service's index, the URI for working with tags is -include as a link: - -include::{snippets}/index/1/http-response.adoc[] - -To create a tag you need to execute a `POST` request to this URI, including a JSON -payload containing the name of the tag: - -include::{snippets}/creating-a-note/3/curl-request.adoc[] - -The response from this request should have a status code of `201 Created` and contain a -`Location` header whose value is the URI of the newly created tag: - -include::{snippets}/creating-a-note/3/http-response.adoc[] - -To work with the newly created tag you use the URI in the `Location` header. For example -you can access the tag's details by performing a `GET` request: - -include::{snippets}/creating-a-note/4/curl-request.adoc[] - -This request will produce a response with the tag's details in its body: - -include::{snippets}/creating-a-note/4/http-response.adoc[] - - - -[[getting_started_tagging_a_note]] -== Tagging a note -A tag isn't particularly useful until it's been associated with one or more notes. There -are two ways to tag a note: when the note is first created or by updating an existing -note. We'll look at both of these in turn. - - - -[[getting_started_tagging_a_note_creating]] -=== Creating a tagged note -The process is largely the same as we saw before, but this time, in addition to providing -a title and body for the note, we'll also provide the tag that we want to be associated -with it. - -Once again we execute a `POST` request. However, this time, in an array named tags, we -include the URI of the tag we just created: - -include::{snippets}/creating-a-note/5/curl-request.adoc[] - -Once again, the response's `Location` header tells us the URI of the newly created note: - -include::{snippets}/creating-a-note/5/http-response.adoc[] - -As before, a `GET` request executed against this URI will retrieve the note's details: - -include::{snippets}/creating-a-note/6/curl-request.adoc[] -include::{snippets}/creating-a-note/6/http-response.adoc[] - -To verify that the tag has been associated with the note, we can perform a `GET` request -against the URI from the `tags` link: - -include::{snippets}/creating-a-note/7/curl-request.adoc[] - -The response embeds information about the tag that we've just associated with the note: - -include::{snippets}/creating-a-note/7/http-response.adoc[] - - - -[[getting_started_tagging_a_note_existing]] -=== Tagging an existing note -An existing note can be tagged by executing a `PATCH` request against the note's URI with -a body that contains the array of tags to be associated with the note. We'll used the -URI of the untagged note that we created earlier: - -include::{snippets}/creating-a-note/8/curl-request.adoc[] - -This request should produce a `204 No Content` response: - -include::{snippets}/creating-a-note/8/http-response.adoc[] - -When we first created this note, we noted the tags link included in its details: - -include::{snippets}/creating-a-note/2/http-response.adoc[] - -We can use that link now and execute a `GET` request to see that the note now has a -single tag: - -include::{snippets}/creating-a-note/9/curl-request.adoc[] -include::{snippets}/creating-a-note/9/http-response.adoc[] diff --git a/modules/jooby-openapi/src/test/resources/issues/i3820/schema.adoc b/modules/jooby-openapi/src/test/resources/issues/i3820/schema.adoc new file mode 100644 index 0000000000..654694c382 --- /dev/null +++ b/modules/jooby-openapi/src/test/resources/issues/i3820/schema.adoc @@ -0,0 +1 @@ +${operation("POST", "/library/books").requestBody | schema} From fa9664bab1e880f939d223bad1234983b0c4c5e3 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sat, 6 Dec 2025 19:01:47 -0300 Subject: [PATCH 11/31] - better implementation for summary/description doc - Add GET adoc function shortcut for operation --- .../internal/openapi/asciidoc/Functions.java | 13 ++ .../openapi/asciidoc/OperationFilters.java | 4 +- .../openapi/javadoc/ContentSplitter.java | 158 ++++++++++++++++ .../internal/openapi/javadoc/JavaDocNode.java | 40 ++--- ...equest.snippet => default-request.snippet} | 0 ...ponse.snippet => default-response.snippet} | 0 .../internal/openapi/asciidoc/FilterTest.java | 16 +- .../openapi/javadoc/ContentSplitterTest.java | 169 ++++++++++++++++++ .../src/test/java/issues/i3820/Issue3820.java | 13 +- 9 files changed, 372 insertions(+), 41 deletions(-) create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ContentSplitter.java rename modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/{default-http-request.snippet => default-request.snippet} (100%) rename modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/{default-http-response.snippet => default-response.snippet} (100%) create mode 100644 modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/javadoc/ContentSplitterTest.java diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java index 40e6a93e0b..c7caece504 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java @@ -15,6 +15,19 @@ import io.pebbletemplates.pebble.template.PebbleTemplate; public enum Functions implements Function { + GET { + @Override + public List getArgumentNames() { + return List.of("pattern"); + } + + @Override + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + args.put("identifier", name()); + return operation.execute(args, self, context, lineNumber); + } + }, operation { @Override public List getArgumentNames() { diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java index 674897b54c..bf3a4a5971 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java @@ -146,7 +146,7 @@ protected String doApply( }); } }, - httpRequest { + request { @Override protected String doApply( SnippetResolver resolver, @@ -191,7 +191,7 @@ protected String doApply( return resolver.apply(id(), snippetContext); } }, - httpResponse { + response { @Override protected String doApply( SnippetResolver resolver, diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ContentSplitter.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ContentSplitter.java new file mode 100644 index 0000000000..12e7e80b60 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ContentSplitter.java @@ -0,0 +1,158 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.javadoc; + +public class ContentSplitter { + + public record ContentResult(String summary, String description) {} + + public static ContentResult split(String text) { + if (text == null || text.isEmpty()) { + return new ContentResult("", ""); + } + + int len = text.length(); + int splitIndex = -1; + + // State trackers + int parenDepth = 0; // ( ) + int bracketDepth = 0; // [ ] + int braceDepth = 0; // { } + boolean inHtmlDef = false; // < ... > + boolean inCodeBlock = false; //

...
or ... + + for (int i = 0; i < len; i++) { + char c = text.charAt(i); + + // 1. Handle HTML Tags start + if (c == '<') { + // Check for

(Paragraph Split - Exclusive) + if (!inCodeBlock && !inHtmlDef && isTag(text, i, "p")) { + splitIndex = i; + break; + } + // Check for Protected Blocks (

, )
+        if (!inCodeBlock && (isTag(text, i, "pre") || isTag(text, i, "code"))) {
+          inCodeBlock = true;
+        }
+        // Check for end of Protected Blocks
+        if (inCodeBlock && (isCloseTag(text, i, "pre") || isCloseTag(text, i, "code"))) {
+          inCodeBlock = false;
+        }
+        inHtmlDef = true;
+        continue;
+      }
+
+      // 2. Handle HTML Tags end
+      if (c == '>') {
+        inHtmlDef = false;
+        continue;
+      }
+
+      // 3. Handle Nesting & Split
+      if (!inHtmlDef && !inCodeBlock) {
+        if (c == '(') {
+          parenDepth++;
+        } else if (c == ')') {
+          if (parenDepth > 0) parenDepth--;
+        } else if (c == '[') {
+          bracketDepth++;
+        } else if (c == ']') {
+          if (bracketDepth > 0) bracketDepth--;
+        } else if (c == '{') {
+          braceDepth++;
+        } else if (c == '}') {
+          if (braceDepth > 0) braceDepth--;
+        }
+        // 4. Check for Period
+        else if (c == '.') {
+          if (parenDepth == 0 && bracketDepth == 0 && braceDepth == 0) {
+            splitIndex = i + 1;
+            break;
+          }
+        }
+      }
+    }
+
+    String summary;
+    String description;
+
+    if (splitIndex == -1) {
+      summary = text.trim();
+      description = "";
+    } else {
+      summary = text.substring(0, splitIndex).trim();
+      description = text.substring(splitIndex).trim();
+    }
+
+    // Clean up: Strip 

tags without using Regex + return new ContentResult(stripParagraphTags(summary), stripParagraphTags(description)); + } + + /** + * Removes + * + *

and tags (and their attributes) from the text. Keeps content inside the tags. + */ + private static String stripParagraphTags(String text) { + if (text.isEmpty()) return text; + + StringBuilder sb = new StringBuilder(text.length()); + int len = text.length(); + + for (int i = 0; i < len; i++) { + char c = text.charAt(i); + + if (c == '<') { + // Detect or + if (isTag(text, i, "p") || isCloseTag(text, i, "p")) { + // Fast-forward until we find the closing '>' + while (i < len && text.charAt(i) != '>') { + i++; + } + // We are now at '>', loop increment will move past it + continue; + } + } + sb.append(c); + } + return sb.toString().trim(); + } + + // --- Helper Methods --- + + private static boolean isTag(String text, int i, String tagName) { + int len = tagName.length(); + if (i + 1 + len > text.length()) return false; + + // Match tagName (case insensitive) + for (int k = 0; k < len; k++) { + char c = text.charAt(i + 1 + k); + if (Character.toLowerCase(c) != tagName.charAt(k)) return false; + } + + // Check delimiter (must be '>' or whitespace or end of string) + if (i + 1 + len == text.length()) return true; + char delimiter = text.charAt(i + 1 + len); + return delimiter == '>' || Character.isWhitespace(delimiter); + } + + private static boolean isCloseTag(String text, int i, String tagName) { + int len = tagName.length(); + if (i + 2 + len > text.length()) return false; + if (text.charAt(i + 1) != '/') return false; + + // Match tagName (case insensitive) + for (int k = 0; k < len; k++) { + char c = text.charAt(i + 2 + k); + if (Character.toLowerCase(c) != tagName.charAt(k)) return false; + } + + // Check delimiter + char delimiter = text.charAt(i + 2 + len); + return delimiter == '>' || Character.isWhitespace(delimiter); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java index 6d30ff8fde..16c4b06651 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java @@ -55,29 +55,8 @@ public Map getExtensions() { } public String getSummary() { - var builder = new StringBuilder(); - for (var node : forward(javadoc, JAVADOC_TAG).toList()) { - if (node.getType() == JavadocCommentsTokenTypes.TEXT) { - var text = node.getText(); - var trimmed = text.trim(); - if (trimmed.isEmpty()) { - if (!builder.isEmpty()) { - builder.append(text); - } - } else { - builder.append(text); - } - } else if (node.getType() == JavadocCommentsTokenTypes.NEWLINE && !builder.isEmpty()) { - break; - } - var index = builder.indexOf("."); - if (index > 0) { - builder.setLength(index + 1); - break; - } - } - var string = builder.toString().trim(); - return string.isEmpty() ? null : string; + var summary = ContentSplitter.split(getText()).summary(); + return summary.isEmpty() ? null : summary; } public List getTags() { @@ -85,12 +64,8 @@ public List getTags() { } public String getDescription() { - var text = getText(); - var summary = getSummary(); - if (summary == null) { - return text; - } - return summary.equals(text) ? null : text.replaceAll(summary, "").trim(); + var description = ContentSplitter.split(getText()).description(); + return description.isEmpty() ? null : description; } public String getText() { @@ -143,7 +118,12 @@ protected static String getText(List nodes, boolean stripLeading) { if (next != null && next.getType() != JavadocCommentsTokenTypes.LEADING_ASTERISK) { builder.append(next.getText()); visited.add(next); - // visited.add(next.getNextSibling()); + } + } else if (node.getType() == JavadocCommentsTokenTypes.TAG_NAME) { + //

? + if (node.getText().equals("p")) { + // keep so we can split summary from description + builder.append("

"); } } } diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-request.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request.snippet similarity index 100% rename from modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-request.snippet rename to modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request.snippet diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-response.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response.snippet similarity index 100% rename from modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-http-response.snippet rename to modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response.snippet diff --git a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java index 3f65e8cca5..ad119beecd 100644 --- a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java +++ b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java @@ -394,7 +394,7 @@ public void curl() { @Test public void httpRequest() { assertThat( - httpRequest.apply( + request.apply( operation("GET", "/api/library/{isbn}").build(), args(), template(), @@ -409,7 +409,7 @@ public void httpRequest() { """); assertThat( - httpRequest.apply( + request.apply( operation("GET", "/api/library/{isbn}").produces("application/json").build(), args(), template(), @@ -425,7 +425,7 @@ public void httpRequest() { """); assertThat( - httpRequest.apply( + request.apply( operation("POST", "/api/library").body(new Book(), "application/json").build(), args(), template(), @@ -445,7 +445,7 @@ public void httpRequest() { @Test public void httpResponse() { assertThat( - httpResponse.apply( + response.apply( operation("GET", "/api/library/{isbn}").defaultResponse().build(), args(), template(), @@ -460,7 +460,7 @@ public void httpResponse() { """); assertThat( - httpResponse.apply( + response.apply( operation("GET", "/api/library/{isbn}") .defaultResponse() .produces("application/json") @@ -479,7 +479,7 @@ public void httpResponse() { """); assertThat( - httpResponse.apply( + response.apply( operation("POST", "/api/library") .produces("application/json") .response(new Book(), StatusCode.CREATED, "application/json") @@ -499,7 +499,7 @@ public void httpResponse() { """); assertThat( - httpResponse.apply( + response.apply( operation("POST", "/api/library") .produces("application/json") .response(new Book(), StatusCode.CREATED, "application/json") @@ -519,7 +519,7 @@ public void httpResponse() { """); assertThat( - httpResponse.apply( + response.apply( operation("POST", "/api/library") .produces("application/json") .response(new Book(), StatusCode.CREATED, "application/json") diff --git a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/javadoc/ContentSplitterTest.java b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/javadoc/ContentSplitterTest.java new file mode 100644 index 0000000000..39f2ff3f8f --- /dev/null +++ b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/javadoc/ContentSplitterTest.java @@ -0,0 +1,169 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.javadoc; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class ContentSplitterTest { + + @Test + void shouldHandleNullAndEmpty() { + assertSplit(null, "", ""); + assertSplit("", "", ""); + assertSplit(" ", "", ""); + } + + @Test + void shouldSplitOnSimplePeriod() { + assertSplit("Hello world. This is description.", "Hello world.", "This is description."); + } + + @Test + void shouldSplitOnParagraphTag() { + //

acts as the separator, exclusive + assertSplit("Hello world

Description

", "Hello world", "Description"); + + // Case insensitive

+ assertSplit("Hello world

Description

", "Hello world", "Description"); + + assertSplit( + "This is the Hello /endpoint\n

Operation description", + "This is the Hello /endpoint", + "Operation description"); + } + + @Test + void shouldPrioritizeWhateverComesFirst() { + // Period comes first + assertSplit("Summary first. Then

para

.", "Summary first.", "Then para."); + + // Paragraph comes first + assertSplit( + "Summary

with description containing.

periods.", + "Summary", + "with description containing. periods."); + } + + @Test + void shouldIgnorePeriodInsideParentheses() { + assertSplit("Jooby (v3.0) is great. Description.", "Jooby (v3.0) is great.", "Description."); + + // Nested parens + assertSplit("Text (outer (inner.)) done. Desc.", "Text (outer (inner.)) done.", "Desc."); + } + + @Test + void shouldIgnorePeriodInsideBrackets() { + assertSplit("Reference [fig. 1] is here. Next.", "Reference [fig. 1] is here.", "Next."); + } + + @Test + void shouldIgnorePeriodInsideHtmlAttributes() { + assertSplit( + "Check site. Done.", + "Check site.", + "Done."); + } + + @Test + void shouldHandleComplexHtmlAttributesInP() { + //

with attributes should still trigger split + assertSplit("Summary

Description

", "Summary", "Description"); + } + + @Test + void shouldNotSplitOnSimilarTags() { + //
 starts with p but is not a paragraph
+    assertSplit(
+        "Code 
val x = 1.0
is cool. End.", + "Code
val x = 1.0
is cool.", + "End."); + + // starts with p + assertSplit( + "Config ignored. Real split.", + "Config ignored.", + "Real split."); + } + + @Test + void shouldHandleUnbalancedNestingGracefully() { + // If user forgets to close (, we probably shouldn't crash, + // though behavior on period ignore depends on implementation. + // Logic: if depth > 0, we ignore periods. + assertSplit("Unbalanced ( paren. No split here.", "Unbalanced ( paren. No split here.", ""); + + // Unbalanced closed ) should not make depth negative + assertSplit("Unbalanced ) paren. Split.", "Unbalanced ) paren.", "Split."); + } + + @Test + void shouldHandleNoSeparators() { + String text = "Just a single sentence without periods or tags"; + assertSplit(text, text, ""); + } + + @Test + void shouldHandleLeadingAndTrailingSeparators() { + // Starts with

-> Empty summary + assertSplit("

Description only.

", "", "Description only."); + + // Ends with period -> Empty description + assertSplit("Only summary.", "Only summary.", ""); + } + + @Test + void shouldNotSplitInsidePreTags() { + // The period in 1.0 must be ignored because it is inside
...
+ assertSplit( + "Code
val x = 1.0
is cool. End.", + "Code
val x = 1.0
is cool.", + "End."); + } + + @Test + void shouldNotSplitInsideCodeTags() { + // The period in System.out must be ignored because it is inside ... + assertSplit( + "Use System.out.println for logging. Next.", + "Use System.out.println for logging.", + "Next."); + } + + @Test + void shouldHandleMixedNesting() { + // Parentheses + Code block + assertSplit( + "Check (e.g. var x = 1.0). Done.", + "Check (e.g. var x = 1.0).", + "Done."); + } + + @Test + void shouldIgnorePeriodInsideJavadocTags() { + // Test {@code ...} + assertSplit("Use {@code 1.0} version. Next.", "Use {@code 1.0} version.", "Next."); + + // Test {@link ...} + assertSplit("See {@link java.util.List}. End.", "See {@link java.util.List}.", "End."); + } + + @Test + void shouldIgnorePeriodInsideGeneralBraces() { + // Since we implemented brace tracking, this also supports standard JSON/Code blocks + assertSplit( + "Config { val x = 1.0; } allowed. Next.", "Config { val x = 1.0; } allowed.", "Next."); + } + + // Helper method to make tests readable + private void assertSplit(String input, String expectedSummary, String expectedDesc) { + var result = ContentSplitter.split(input); + assertEquals(expectedSummary, result.summary(), "Summary mismatch"); + assertEquals(expectedDesc, result.description(), "Description mismatch"); + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/Issue3820.java b/modules/jooby-openapi/src/test/java/issues/i3820/Issue3820.java index d1a40b9de6..af01426a44 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3820/Issue3820.java +++ b/modules/jooby-openapi/src/test/java/issues/i3820/Issue3820.java @@ -19,7 +19,18 @@ public void shouldGenerateRequestBodySchema(OpenAPIResult result) { """ [source,json] ---- - {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","publisher":{"id":"int64","name":"string"},"authors":[]} + { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "publisher" : { + "id" : "int64", + "name" : "string" + }, + "authors" : [ ] + } ---- """); } From af49d46ae7f9bfef064741a101816051954dab81 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sat, 6 Dec 2025 19:03:18 -0300 Subject: [PATCH 12/31] - add POST, PUT,.., adoc operations - ref #3820 --- .../internal/openapi/asciidoc/Functions.java | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java index c7caece504..2c19cde98a 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java @@ -24,7 +24,59 @@ public List getArgumentNames() { @Override public Object execute( Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { - args.put("identifier", name()); + args.put("method", name()); + return operation.execute(args, self, context, lineNumber); + } + }, + POST { + @Override + public List getArgumentNames() { + return List.of("pattern"); + } + + @Override + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + args.put("method", name()); + return operation.execute(args, self, context, lineNumber); + } + }, + PUT { + @Override + public List getArgumentNames() { + return List.of("pattern"); + } + + @Override + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + args.put("method", name()); + return operation.execute(args, self, context, lineNumber); + } + }, + PATCH { + @Override + public List getArgumentNames() { + return List.of("pattern"); + } + + @Override + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + args.put("method", name()); + return operation.execute(args, self, context, lineNumber); + } + }, + DELETE { + @Override + public List getArgumentNames() { + return List.of("pattern"); + } + + @Override + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + args.put("method", name()); return operation.execute(args, self, context, lineNumber); } }, From 620c75d251bc8d16caa1e2b3df12daf18d944d93 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sun, 7 Dec 2025 11:47:53 -0300 Subject: [PATCH 13/31] - add `path` filter with replace query and path paramters - better format for table output (long description column) - ref #3820 --- .../io/jooby/internal/openapi/OpenAPIExt.java | 2 +- .../jooby/internal/openapi/OperationExt.java | 16 ++- .../jooby/internal/openapi/RouteParser.java | 8 +- .../internal/openapi/asciidoc/Functions.java | 10 +- .../openapi/asciidoc/OperationFilters.java | 125 +++++++++++++----- .../io/jooby/openapi/OpenAPIGenerator.java | 2 +- .../default-cookie-parameters.snippet | 1 + .../templates/asciidoc/default-curl.snippet | 2 +- .../asciidoc/default-form-parameters.snippet | 1 + .../asciidoc/default-path-parameters.snippet | 1 + .../asciidoc/default-query-parameters.snippet | 1 + .../asciidoc/default-request-fields.snippet | 1 + .../asciidoc/default-request-headers.snippet | 1 + .../default-request-parameters.snippet | 4 +- .../asciidoc/default-response-fields.snippet | 1 + .../src/main/resources/source.peb | 9 ++ .../internal/openapi/asciidoc/FilterTest.java | 106 +++++++++++++-- .../io/jooby/openapi/OperationBuilder.java | 10 +- .../java/issues/i3729/api/ApiDocTest.java | 10 +- .../src/test/resources/adoc/library.adoc | 2 +- 20 files changed, 241 insertions(+), 72 deletions(-) create mode 100644 modules/jooby-openapi/src/main/resources/source.peb diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIExt.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIExt.java index 622d0eacd4..143bc40c78 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIExt.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIExt.java @@ -243,7 +243,7 @@ public OperationExt findOperationById(String operationId) { } public OperationExt findOperation(String method, String pattern) { - Predicate filter = op -> op.getPattern().equals(pattern); + Predicate filter = op -> op.getPath().equals(pattern); if (method != null) { filter = filter.and(op -> op.getMethod().equals(method)); } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OperationExt.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OperationExt.java index 50426437dc..3f3806b08e 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OperationExt.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OperationExt.java @@ -27,7 +27,7 @@ public class OperationExt extends io.swagger.v3.oas.models.Operation { @JsonIgnore private final MethodNode node; @JsonIgnore private String method; - @JsonIgnore private final String pattern; + @JsonIgnore private final String path; @JsonIgnore private Boolean hidden; @JsonIgnore private LinkedList produces = new LinkedList<>(); @JsonIgnore private LinkedList consumes = new LinkedList<>(); @@ -41,10 +41,10 @@ public class OperationExt extends io.swagger.v3.oas.models.Operation { @JsonIgnore private ClassNode controller; public OperationExt( - MethodNode node, String method, String pattern, List arguments, ResponseExt response) { + MethodNode node, String method, String path, List arguments, ResponseExt response) { this.node = node; this.method = method.toUpperCase(); - this.pattern = pattern; + this.path = path; setParameters(arguments); this.defaultResponse = response; setResponses(apiResponses(Collections.singletonList(response))); @@ -87,8 +87,8 @@ public void setMethod(String method) { this.method = method; } - public String getPattern() { - return pattern; + public String getPath() { + return path; } public List getProduces() { @@ -120,7 +120,7 @@ public void setHidden(Boolean hidden) { } public String toString() { - return getMethod() + " " + getPattern(); + return getMethod() + " " + getPath(); } public Parameter getParameter(int i) { @@ -267,4 +267,8 @@ public OperationExt copy(String pattern) { copy.setPathExtensions(getPathExtensions()); return copy; } + + public String getPath(Map pathParams) { + return Router.reverse(getPath(), pathParams); + } } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RouteParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RouteParser.java index 6201aa4236..af0e502883 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RouteParser.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RouteParser.java @@ -75,7 +75,7 @@ public List parse(ParserContext ctx, OpenAPIExt openapi) { List result = new ArrayList<>(); for (OperationExt operation : operations) { - List patterns = Router.expandOptionalVariables(operation.getPattern()); + List patterns = Router.expandOptionalVariables(operation.getPath()); if (patterns.size() == 1) { result.add(operation); } else { @@ -112,8 +112,7 @@ private static void addJavaDoc( if (operation.getController() == null) { javaDoc .flatMap( - doc -> - doc.getScript(operation.getMethod(), operation.getPattern().substring(offset))) + doc -> doc.getScript(operation.getMethod(), operation.getPath().substring(offset))) .ifPresent( scriptDoc -> { if (scriptDoc.getPath() != null) { @@ -249,8 +248,7 @@ private void uniqueOperationId(List operations) { private String operationId(OperationExt operation) { return Optional.ofNullable(operation.getOperationId()) .orElseGet( - () -> - operation.getMethod().toLowerCase() + patternToOperationId(operation.getPattern())); + () -> operation.getMethod().toLowerCase() + patternToOperationId(operation.getPath())); } private String patternToOperationId(String pattern) { diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java index 2c19cde98a..b11733e6b0 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java @@ -24,7 +24,7 @@ public List getArgumentNames() { @Override public Object execute( Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { - args.put("method", name()); + args.put("identifier", name()); return operation.execute(args, self, context, lineNumber); } }, @@ -37,7 +37,7 @@ public List getArgumentNames() { @Override public Object execute( Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { - args.put("method", name()); + args.put("identifier", name()); return operation.execute(args, self, context, lineNumber); } }, @@ -50,7 +50,7 @@ public List getArgumentNames() { @Override public Object execute( Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { - args.put("method", name()); + args.put("identifier", name()); return operation.execute(args, self, context, lineNumber); } }, @@ -63,7 +63,7 @@ public List getArgumentNames() { @Override public Object execute( Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { - args.put("method", name()); + args.put("identifier", name()); return operation.execute(args, self, context, lineNumber); } }, @@ -76,7 +76,7 @@ public List getArgumentNames() { @Override public Object execute( Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { - args.put("method", name()); + args.put("identifier", name()); return operation.execute(args, self, context, lineNumber); } }, diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java index bf3a4a5971..88cad19ecf 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java @@ -15,6 +15,7 @@ import com.google.common.base.Predicate; import com.google.common.base.Splitter; import com.google.common.collect.*; +import com.google.common.net.UrlEscapers; import edu.umd.cs.findbugs.annotations.NonNull; import edu.umd.cs.findbugs.annotations.Nullable; import io.jooby.MediaType; @@ -46,13 +47,8 @@ protected String doApply( int lineNumber) throws Exception { var options = args(args); - var method = - Optional.of(options.removeAll("-X")) - .map(Collection::iterator) - .filter(Iterator::hasNext) - .map(Iterator::next) - .orElse(operation.getMethod()) - .toUpperCase(); + var method = removeOption(options, "-X", operation.getMethod()).toUpperCase(); + var language = removeOption(options, "language", ""); /* Accept/Content-Type: */ var addAccept = true; var addContentType = true; @@ -111,11 +107,21 @@ protected String doApply( .collect(Collectors.joining("&", "?", "")); } options.put("-X", method + " '" + url + "'"); - var optionString = toString(options); - snippetContext.put("options", optionString); + var optionsString = toString(options); + snippetContext.put("options", optionsString); + snippetContext.put("language", language); return resolver.apply(id(), snippetContext); } + @NonNull private static String removeOption( + Multimap options, String name, String defaultValue) { + return Optional.of(options.removeAll(name)) + .map(Collection::iterator) + .filter(Iterator::hasNext) + .map(Iterator::next) + .orElse(defaultValue); + } + @NonNull private static Stream> encodeUrlParameter(List query) { return query.stream() .flatMap( @@ -272,6 +278,49 @@ protected String doApply( return resolver.apply(id(), snippetContext); } }, + path { + @Override + protected String doApply( + SnippetResolver resolver, + OperationExt operation, + Map snippetContext, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) { + var namedArgs = positionalArgs(args); + // Path + var path = parameters(operation, p -> "path".equals(p.getIn())); + var pathParams = new HashMap(); + path.forEach( + p -> { + pathParams.put( + p.getName(), namedArgs.getOrDefault(p.getName(), "{" + p.getName() + "}")); + }); + // QueryString + var query = + parameters( + operation, + p -> + "query".equals(p.getIn()) + && (namedArgs.isEmpty() || namedArgs.containsKey(p.getName()))); + var queryString = + query.isEmpty() + ? "" + : query.stream() + .map( + it -> { + var value = + namedArgs.getOrDefault( + it.getName(), SchemaData.shemaType(it.getSchema())); + return it.getName() + + "=" + + UrlEscapers.urlFragmentEscaper().escape(value.toString()); + }) + .collect(Collectors.joining("&", "?", "")); + return operation.getPath(pathParams) + queryString; + } + }, pathParameters { @Override protected String doApply( @@ -406,7 +455,7 @@ protected final String id() { @Override public List getArgumentNames() { - return List.of("code"); + return null; } protected abstract String doApply( @@ -465,10 +514,10 @@ protected ResponseExt responseByStatusCode( protected Map newSnippetContext(String serverUrl, OperationExt operation) { Map map = new HashMap<>(); - map.put("pattern", operation.getPattern()); - map.put("path", operation.getPattern()); + map.put("pattern", operation.getPath()); + map.put("path", operation.getPath()); map.put("method", operation.getMethod()); - map.put("url", serverUrl + operation.getPattern()); + map.put("url", serverUrl + operation.getPath()); var requestHeaders = ArrayListMultimap.create(); var responseHeaders = ArrayListMultimap.create(); var headerParams = @@ -524,14 +573,17 @@ protected List> schemaToTable(Schema schema, EvaluationCo return fields; } + protected List parameters(OperationExt operation, Predicate predicate) { + return Optional.ofNullable(operation.getParameters()).orElse(List.of()).stream() + .filter(predicate) + .sorted(Comparator.comparing(Parameter::getName)) + .toList(); + } + protected List> parametersToTable( OperationExt operation, Predicate predicate) { List> fields = new ArrayList<>(); - var parameters = - Optional.ofNullable(operation.getParameters()).orElse(List.of()).stream() - .filter(predicate) - .sorted(Comparator.comparing(Parameter::getName)) - .toList(); + var parameters = parameters(operation, predicate); parameters.forEach( it -> { var schema = it.getSchema(); @@ -601,27 +653,36 @@ protected Multimap parseHeaders(Collection headers protected static final Set READ_METHODS = Set.of("GET", "HEAD"); protected String toString(Multimap options) { - if (options.isEmpty()) { - return ""; - } var sb = new StringBuilder(); var separator = "\\\n"; var tabSize = id().length() + 1; - options.forEach( - (k, v) -> { - if (!sb.isEmpty()) { - sb.append(" ".repeat(tabSize)); - } - sb.append(k); - if (v != null && !v.isEmpty()) { - sb.append(" ").append(v); - } - sb.append(separator); - }); + for (Map.Entry entry : options.entries()) { + var k = entry.getKey(); + var v = entry.getValue(); + if (!sb.isEmpty()) { + sb.append(" ".repeat(tabSize)); + } + sb.append(k); + if (v != null && !v.isEmpty()) { + sb.append(" ").append(v); + } + sb.append(separator); + } sb.setLength(sb.length() - separator.length()); return sb.toString(); } + protected Map positionalArgs(Map args) { + var optionList = new ArrayList<>(args.values()); + Map result = new LinkedHashMap<>(); + for (int i = 0; i < optionList.size(); i += 2) { + var key = optionList.get(i).toString(); + var value = optionList.get(i + 1); + result.put(key, value); + } + return result; + } + protected Multimap args(Map args) { Multimap result = LinkedHashMultimap.create(); var optionList = new ArrayList<>(args.values()); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java index 15f76b5fd7..26fc7b6399 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java @@ -265,7 +265,7 @@ public OpenAPIGenerator() {} Map globalTags = new LinkedHashMap<>(); Paths paths = new Paths(); for (OperationExt operation : operations) { - String pattern = operation.getPattern(); + String pattern = operation.getPath(); if (!includes(pattern) || excludes(pattern)) { log.debug("skipping {}", pattern); continue; diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-cookie-parameters.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-cookie-parameters.snippet index 7b5abf7a42..7e520402a5 100644 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-cookie-parameters.snippet +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-cookie-parameters.snippet @@ -1,3 +1,4 @@ +[cols="1,3"] |=== |Parameter|Description {% for cookie in cookies %} diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-curl.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-curl.snippet index 1d9b354595..c266e29d39 100644 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-curl.snippet +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-curl.snippet @@ -1,4 +1,4 @@ -[source,bash] +[source{% if language %}, ${language}{%endif%}] ---- curl ${options} ---- diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-form-parameters.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-form-parameters.snippet index af4e93ba9e..7c0260a0b4 100644 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-form-parameters.snippet +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-form-parameters.snippet @@ -1,3 +1,4 @@ + [cols="1,1,3"] |=== |Parameter|Type|Description {% for parameter in parameters %} diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-path-parameters.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-path-parameters.snippet index af4e93ba9e..7c0260a0b4 100644 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-path-parameters.snippet +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-path-parameters.snippet @@ -1,3 +1,4 @@ + [cols="1,1,3"] |=== |Parameter|Type|Description {% for parameter in parameters %} diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-query-parameters.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-query-parameters.snippet index af4e93ba9e..3b1625d618 100644 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-query-parameters.snippet +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-query-parameters.snippet @@ -1,3 +1,4 @@ +[cols="1,1,3"] |=== |Parameter|Type|Description {% for parameter in parameters %} diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-fields.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-fields.snippet index 1e7c540340..3515a8eaf4 100644 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-fields.snippet +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-fields.snippet @@ -1,3 +1,4 @@ +[cols="1,1,3"] |=== |Path|Type|Description {% for field in fields %} diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-headers.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-headers.snippet index 72964783a6..cc0100c98f 100644 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-headers.snippet +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-headers.snippet @@ -1,3 +1,4 @@ +[cols="1,3"] |=== |Parameter|Description {% for header in headers %} diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-parameters.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-parameters.snippet index af4e93ba9e..c41ced0b95 100644 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-parameters.snippet +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-parameters.snippet @@ -1,7 +1,9 @@ +[cols="1,1,1,3"] |=== -|Parameter|Type|Description +|Parameter|In|Type|Description {% for parameter in parameters %} |`+${parameter.name}+` +|`+${parameter.in}+` |`+${parameter.type}+` |${parameter.description} {% endfor %} diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-fields.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-fields.snippet index 1e7c540340..3515a8eaf4 100644 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-fields.snippet +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-fields.snippet @@ -1,3 +1,4 @@ +[cols="1,1,3"] |=== |Path|Type|Description {% for field in fields %} diff --git a/modules/jooby-openapi/src/main/resources/source.peb b/modules/jooby-openapi/src/main/resources/source.peb new file mode 100644 index 0000000000..d64546434a --- /dev/null +++ b/modules/jooby-openapi/src/main/resources/source.peb @@ -0,0 +1,9 @@ +{%- if display == "inline" -%} +`{%- block code -%}{%- endblock -%}` +{%- else -%} +[source{% if language %}, ${language}{%endif%}] +---- +{% block code %} +{% endblock %} +---- +{%- endif -%} diff --git a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java index ad119beecd..01cf9f902c 100644 --- a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java +++ b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java @@ -74,6 +74,76 @@ public Map getGlobalVariables() { .build()); } + @Test + public void path() { + // Query parameter filtering by name + assertThat( + path.apply( + operation("GET", "/api/library/search").query("title", "isbn").build(), + args("title", "Some..."), + template(), + evaluationContext(), + 1)) + .isEqualTo("/api/library/search?title=Some..."); + // All + assertThat( + path.apply( + operation("GET", "/api/library/search").query("title", "isbn").build(), + args(), + template(), + evaluationContext(), + 1)) + .isEqualTo("/api/library/search?isbn=string&title=string"); + + // Path+Query + assertThat( + path.apply( + operation("GET", "/api/library/book/{isbn}") + .parameter( + Map.of("query", mapOf("title", "string"), "path", mapOf("isbn", "string"))) + .build(), + args("title", "Some...", "isbn", "12340"), + template(), + evaluationContext(), + 1)) + .isEqualTo("/api/library/book/12340?title=Some..."); + // Default Path + assertThat( + path.apply( + operation("GET", "/api/library/book/{isbn}") + .parameter( + Map.of("query", mapOf("title", "string"), "path", mapOf("isbn", "string"))) + .build(), + args("title", "Some..."), + template(), + evaluationContext(), + 1)) + .isEqualTo("/api/library/book/{isbn}?title=Some..."); + + // Only Path + assertThat( + path.apply( + operation("GET", "/api/library/book/{isbn}").path("isbn").build(), + args(), + template(), + evaluationContext(), + 1)) + .isEqualTo("/api/library/book/{isbn}"); + + // Defaults + assertThat( + path.apply( + operation("GET", "/api/library/book/{isbn}") + .parameter( + Map.of("query", mapOf("title", "string"), "path", mapOf("isbn", "string"))) + .build(), + args(), + template(), + evaluationContext(), + 1)) + .isEqualTo("/api/library/book/{isbn}?title=string"); + } + @Test public void requestParams() { // Query parameter @@ -86,6 +156,7 @@ public void requestParams() { 1)) .isEqualToNormalizingNewlines( """ + [cols="1,1,3"] |=== |Parameter|Type|Description @@ -109,6 +180,7 @@ public void requestParams() { 1)) .isEqualToNormalizingNewlines( """ + [cols="1,1,3"] |=== |Parameter|Type|Description @@ -129,6 +201,7 @@ public void requestParams() { 1)) .isEqualToNormalizingNewlines( """ + [cols="1,3"] |=== |Parameter|Description @@ -151,6 +224,7 @@ public void requestParams() { 1)) .isEqualToNormalizingNewlines( """ + [cols="1,1,3"] |=== |Parameter|Type|Description @@ -185,22 +259,27 @@ public void requestParams() { 1)) .isEqualToNormalizingNewlines( """ + [cols="1,1,1,3"] |=== - |Parameter|Type|Description + |Parameter|In|Type|Description |`+active+` + |`+query+` |`+true+` | |`+file+` + |`+form+` |`+binary+` | |`+isbn+` + |`+path+` |`+string+` | |`+name+` + |`+form+` |`+string+` | @@ -219,7 +298,7 @@ public void curl() { 1)) .isEqualToNormalizingNewlines( """ - [source,bash] + [source] ---- curl -X GET 'https://api.libray.com/api/library/{isbn}' ----\ @@ -229,13 +308,13 @@ public void curl() { assertThat( curl.apply( operation("GET", "/api/library/{isbn}").query("foo", "bar").build(), - args(), + args("language", "bash"), template(), evaluationContext(), 1)) .isEqualToNormalizingNewlines( """ - [source,bash] + [source, bash] ---- curl -X GET 'https://api.libray.com/api/library/{isbn}?foo=string&bar=string' ----\ @@ -251,7 +330,7 @@ public void curl() { 1)) .isEqualToNormalizingNewlines( """ - [source,bash] + [source] ---- curl --data-urlencode 'foo=string'\\ --data-urlencode 'bar=string'\\ @@ -276,7 +355,7 @@ public void curl() { 1)) .isEqualToNormalizingNewlines( """ - [source,bash] + [source] ---- curl --data-urlencode 'foo=string'\\ --data-urlencode 'bar=string'\\ @@ -294,7 +373,7 @@ public void curl() { 1)) .isEqualToNormalizingNewlines( """ - [source,bash] + [source] ---- curl -i\\ -X GET 'https://api.libray.com/api/library/{isbn}' @@ -311,7 +390,7 @@ public void curl() { 1)) .isEqualToNormalizingNewlines( """ - [source,bash] + [source] ---- curl -i\\ -X POST 'https://api.libray.com/api/library/{isbn}' @@ -328,7 +407,7 @@ public void curl() { 1)) .isEqualToNormalizingNewlines( """ - [source,bash] + [source] ---- curl -H 'Accept: application/json'\\ -X GET 'https://api.libray.com/api/library/{isbn}' @@ -345,7 +424,7 @@ public void curl() { 1)) .isEqualToNormalizingNewlines( """ - [source,bash] + [source] ---- curl -H 'Accept: application/xml'\\ -X GET 'https://api.libray.com/api/library/{isbn}' @@ -361,7 +440,7 @@ public void curl() { 1)) .isEqualToNormalizingNewlines( """ - [source,bash] + [source] ---- curl -H 'Content-Type: application/json'\\ -d '{"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"}'\\ @@ -381,7 +460,7 @@ public void curl() { 1)) .isEqualToNormalizingNewlines( """ - [source,bash] + [source] ---- curl -H 'Content-Type: multipart/form-data'\\ --data-urlencode 'name=string'\\ @@ -554,6 +633,7 @@ public void responseFields() { 1)) .isEqualToNormalizingNewlines( """ + [cols="1,1,3"] |=== |Path|Type|Description @@ -590,6 +670,7 @@ public void responseFields() { assertEquals( """ + [cols="1,1,3"] |=== |Path|Type|Description @@ -646,6 +727,7 @@ public void responseFields() { 1)) .isEqualToNormalizingNewlines( """ + [cols="1,1,3"] |=== |Path|Type|Description diff --git a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OperationBuilder.java b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OperationBuilder.java index 16ca256ade..a8813f219a 100644 --- a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OperationBuilder.java +++ b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OperationBuilder.java @@ -13,6 +13,10 @@ import java.util.List; import java.util.Map; +import org.mockito.ArgumentCaptor; +import org.mockito.stubbing.Answer; + +import io.jooby.Router; import io.jooby.StatusCode; import io.jooby.internal.openapi.*; import io.swagger.v3.core.converter.ModelConverters; @@ -106,8 +110,12 @@ public OperationBuilder method(String method) { return this; } + @SuppressWarnings("unchecked") public OperationBuilder pattern(String pattern) { - when(operation.getPattern()).thenReturn(pattern); + when(operation.getPath()).thenReturn(pattern); + ArgumentCaptor> args = ArgumentCaptor.forClass(Map.class); + when(operation.getPath(args.capture())) + .thenAnswer((Answer) invocationOnMock -> Router.reverse(pattern, args.getValue())); return this; } diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java index 11aa4e9f6b..c59b2423e3 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java @@ -268,14 +268,11 @@ public void shouldGenerateAdoc(OpenAPIResult result) { Query books. By using advanced filters. - [source,bash] - ---- - curl -H 'Accept: application/json'\\ - -X GET 'https://api.fake-museum-example.com/v1/api/library?title=string&author=string&isbn=string1&isbn=string2&isbn=string3' - ---- + Example: `/api/library?title=...` ==== Request Fields + [cols="1,1,3"] |=== |Parameter|Type|Description @@ -295,7 +292,7 @@ public void shouldGenerateAdoc(OpenAPIResult result) { === Find a book by ISBN - [source,bash] + [source] ---- curl -i\\ -H 'Accept: application/json'\\ @@ -338,6 +335,7 @@ public void shouldGenerateAdoc(OpenAPIResult result) { ==== Response Fields + [cols="1,1,3"] |=== |Path|Type|Description diff --git a/modules/jooby-openapi/src/test/resources/adoc/library.adoc b/modules/jooby-openapi/src/test/resources/adoc/library.adoc index d62abc2fa9..89928533ca 100644 --- a/modules/jooby-openapi/src/test/resources/adoc/library.adoc +++ b/modules/jooby-openapi/src/test/resources/adoc/library.adoc @@ -22,7 +22,7 @@ Write your questions at ${info.contact.email} {% set listBooks = operation("GET", "/api/library") %} ${listBooks.summary} ${listBooks.description} -${ listBooks | curl } +Example: `${ listBooks | path("title", "...") }` ==== Request Fields From 98426528bf6d7293b6e27b6b5474aaf39c6bcc78 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sun, 7 Dec 2025 20:10:49 -0300 Subject: [PATCH 14/31] - enum doc: add field doc with custom implementation - add schema function to located schemas and/or schema properties - add `display` filter --- .../io/jooby/internal/openapi/EnumSchema.java | 26 +++++ .../jooby/internal/openapi/ParserContext.java | 108 +++++++++--------- .../jooby/internal/openapi/RouteParser.java | 1 - .../internal/openapi/asciidoc/Filters.java | 48 ++++++++ .../internal/openapi/asciidoc/Functions.java | 48 ++++++++ .../openapi/asciidoc/OperationFilters.java | 12 +- .../internal/openapi/javadoc/ClassDoc.java | 10 ++ .../internal/openapi/javadoc/FieldDoc.java | 6 + .../internal/openapi/javadoc/JavaDocNode.java | 1 - .../io/jooby/openapi/OpenAPIGenerator.java | 1 + 10 files changed, 195 insertions(+), 66 deletions(-) create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/EnumSchema.java diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/EnumSchema.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/EnumSchema.java new file mode 100644 index 0000000000..f07f65fd5a --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/EnumSchema.java @@ -0,0 +1,26 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi; + +import java.util.HashMap; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.models.media.StringSchema; + +public class EnumSchema extends StringSchema { + @JsonIgnore private final Map fields = new HashMap<>(); + + public EnumSchema() {} + + public void setDescription(String name, String description) { + fields.put(name, description); + } + + public String getDescription(String name) { + return fields.get(name); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java index 8d953ca061..7546576799 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java @@ -30,20 +30,7 @@ import java.time.OffsetDateTime; import java.time.Period; import java.time.ZonedDateTime; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Currency; -import java.util.Date; -import java.util.EnumSet; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; +import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.function.Function; @@ -75,20 +62,7 @@ import io.jooby.openapi.DebugOption; import io.swagger.v3.core.util.RefUtils; import io.swagger.v3.oas.models.SpecVersion; -import io.swagger.v3.oas.models.media.ArraySchema; -import io.swagger.v3.oas.models.media.BinarySchema; -import io.swagger.v3.oas.models.media.BooleanSchema; -import io.swagger.v3.oas.models.media.ByteArraySchema; -import io.swagger.v3.oas.models.media.DateSchema; -import io.swagger.v3.oas.models.media.DateTimeSchema; -import io.swagger.v3.oas.models.media.FileSchema; -import io.swagger.v3.oas.models.media.IntegerSchema; -import io.swagger.v3.oas.models.media.MapSchema; -import io.swagger.v3.oas.models.media.NumberSchema; -import io.swagger.v3.oas.models.media.ObjectSchema; -import io.swagger.v3.oas.models.media.Schema; -import io.swagger.v3.oas.models.media.StringSchema; -import io.swagger.v3.oas.models.media.UUIDSchema; +import io.swagger.v3.oas.models.media.*; import jakarta.data.page.Page; import jakarta.data.page.PageRequest; @@ -283,7 +257,7 @@ public Schema schema(Class type) { return new ObjectSchema(); } if (type.isEnum()) { - StringSchema schema = new StringSchema(); + var schema = new EnumSchema(); EnumSet.allOf(type).forEach(e -> schema.addEnumItem(((Enum) e).name())); return schema; } @@ -333,36 +307,56 @@ private void document(Class typeName, Schema schema, ResolvedSchemaExt resolvedS .ifPresent( javadoc -> { Optional.ofNullable(javadoc.getText()).ifPresent(schema::setDescription); + // make a copy Map properties = schema.getProperties(); if (properties != null) { - properties.forEach( - (key, value) -> { - var text = javadoc.getPropertyDoc(key); - var propertyType = getPropertyType(typeName, key); - var isEnum = - propertyType != null - && propertyType.isEnum() - && resolvedSchema.referencedSchemasByType.keySet().stream() - .map(this::toClass) - .anyMatch(it -> !it.equals(propertyType)); - if (isEnum) { - javadocParser - .parse(propertyType.getName()) - .ifPresent( - enumDoc -> { - var enumDesc = enumDoc.getEnumDescription(text); - if (enumDesc != null) { - value.setDescription(enumDesc); - } - }); - } else { - value.setDescription(text); - var example = javadoc.getPropertyExample(key); - if (example != null) { - value.setExample(example); - } - } - }); + new LinkedHashMap<>(properties) + .forEach( + (key, value) -> { + var text = javadoc.getPropertyDoc(key); + var propertyType = getPropertyType(typeName, key); + var isEnum = + propertyType != null + && propertyType.isEnum() + && resolvedSchema.referencedSchemasByType.keySet().stream() + .map(this::toClass) + .anyMatch(it -> !it.equals(propertyType)); + if (isEnum) { + javadocParser + .parse(propertyType.getName()) + .ifPresent( + enumDoc -> { + var enumDesc = enumDoc.getEnumDescription(text); + if (enumDesc != null) { + EnumSchema enumSchema; + if (!(value instanceof EnumSchema)) { + enumSchema = new EnumSchema(); + value.getEnum().stream() + .forEach( + enumValue -> + enumSchema.addEnumItemObject( + enumValue.toString())); + properties.put(key, enumSchema); + } else { + enumSchema = (EnumSchema) value; + } + for (var field : enumSchema.getEnum()) { + var enumItemDesc = enumDoc.getEnumItemDescription(field); + if (enumItemDesc != null) { + enumSchema.setDescription(field, enumItemDesc); + } + } + enumSchema.setDescription(enumDesc); + } + }); + } else { + value.setDescription(text); + var example = javadoc.getPropertyExample(key); + if (example != null) { + value.setExample(example); + } + } + }); } }); } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RouteParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RouteParser.java index af0e502883..bfc05fb7f3 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RouteParser.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RouteParser.java @@ -63,7 +63,6 @@ public List parse(ParserContext ctx, OpenAPIExt openapi) { String applicationName = Optional.ofNullable(ctx.getMainClass()).orElse(ctx.getRouter().getClassName()); ClassNode application = ctx.classNode(Type.getObjectType(applicationName.replace(".", "/"))); - // JavaDoc addJavaDoc(ctx, ctx.getRouter().getClassName(), "", operations); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Filters.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Filters.java index 6ce3a4395d..3708b02bef 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Filters.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Filters.java @@ -10,12 +10,60 @@ import java.util.Map; import com.fasterxml.jackson.core.JsonProcessingException; +import io.jooby.internal.openapi.EnumSchema; import io.pebbletemplates.pebble.error.PebbleException; import io.pebbletemplates.pebble.extension.Filter; import io.pebbletemplates.pebble.template.EvaluationContext; import io.pebbletemplates.pebble.template.PebbleTemplate; +import io.swagger.v3.oas.models.media.Schema; public enum Filters implements Filter { + display { + @Override + public List getArgumentNames() { + return null; + } + + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + if (input instanceof Schema schema) { + return displaySchema(schema); + } else { + throw new IllegalArgumentException("Unsupported input type: " + input.getClass()); + } + } + + private Object displaySchema(Schema schema) { + if (schema instanceof EnumSchema enumSchema) { + var sb = new StringBuilder(); + sb.append( + """ + [cols="1,3"] + |=== + | Type | Description + + """); + for (var name : enumSchema.getEnum()) { + sb.append("\n") + .append("| *") + .append(name) + .append("*\n") + .append("| ") + .append(enumSchema.getDescription(name)) + .append("\n"); + } + return sb.append(" |===").toString(); + } + return null; + } + }, + json { @Override public List getArgumentNames() { diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java index b11733e6b0..ef82c5e656 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java @@ -8,11 +8,14 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; import io.pebbletemplates.pebble.error.PebbleException; import io.pebbletemplates.pebble.extension.Function; import io.pebbletemplates.pebble.template.EvaluationContext; import io.pebbletemplates.pebble.template.PebbleTemplate; +import io.swagger.v3.oas.models.media.Schema; public enum Functions implements Function { GET { @@ -80,6 +83,51 @@ public Object execute( return operation.execute(args, self, context, lineNumber); } }, + tag { + @Override + public List getArgumentNames() { + return List.of("name"); + } + + @Override + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + var openApi = InternalContext.openApi(context); + var name = args.get("name"); + return openApi.getTags().stream() + .filter(tag -> tag.getName().equals(name)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Tag not found: " + name)); + } + }, + schema { + @Override + public List getArgumentNames() { + return List.of("name"); + } + + @Override + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + var openApi = InternalContext.openApi(context); + var name = args.get("name").toString(); + var path = name.split("\\."); + var schema = openApi.getComponents().getSchemas().get(path[0]); + if (schema == null) { + throw new IllegalArgumentException("Schema not found: " + name); + } + for (int i = 1; i < path.length; i++) { + Schema inner = (Schema) schema.getProperties().get(path[i]); + if (inner == null) { + throw new IllegalArgumentException( + "Property not found: " + Stream.of(path).limit(i).collect(Collectors.joining("."))); + } + schema = inner; + } + + return schema; + } + }, operation { @Override public List getArgumentNames() { diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java index 88cad19ecf..e881dce295 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java @@ -65,6 +65,7 @@ protected String doApply( .getConsumes() .forEach(value -> options.put("-H", "'Content-Type: " + value + "'")); } + var parameters = Optional.ofNullable(operation.getParameters()).orElse(List.of()); /* Body */ if (operation.getRequestBody() != null) { var requestBody = operation.getRequestBody(); @@ -84,8 +85,7 @@ protected String doApply( } } else { // can be form - var form = - operation.getParameters().stream().filter(it -> "form".equals(it.getIn())).toList(); + var form = parameters.stream().filter(it -> "form".equals(it.getIn())).toList(); encodeUrlParameter(form) .forEach( it -> @@ -97,8 +97,7 @@ protected String doApply( } /* Method */ var url = snippetContext.get("url").toString(); - var query = - operation.getParameters().stream().filter(it -> "query".equals(it.getIn())).toList(); + var query = parameters.stream().filter(it -> "query".equals(it.getIn())).toList(); // query parameters if (!query.isEmpty()) { url += @@ -520,10 +519,9 @@ protected Map newSnippetContext(String serverUrl, OperationExt o map.put("url", serverUrl + operation.getPath()); var requestHeaders = ArrayListMultimap.create(); var responseHeaders = ArrayListMultimap.create(); + var parameters = Optional.ofNullable(operation.getParameters()).orElse(List.of()); var headerParams = - operation.getParameters().stream() - .filter(it -> "header".equalsIgnoreCase(it.getIn())) - .toList(); + parameters.stream().filter(it -> "header".equalsIgnoreCase(it.getIn())).toList(); operation .getProduces() .forEach( diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java index 35d7c04bda..7fb1c6136f 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java @@ -88,6 +88,16 @@ public String getEnumDescription(String text) { return text; } + public String getEnumItemDescription(String name) { + if (isEnum()) { + var field = fields.get(name); + if (field != null) { + return field.getText(); + } + } + return null; + } + private void defaultRecordMembers() { JavaDocTag.javaDocTag( javadoc, diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/FieldDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/FieldDoc.java index 13e1ba2117..6ce38c22ce 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/FieldDoc.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/FieldDoc.java @@ -17,6 +17,12 @@ public FieldDoc(JavaDocParser ctx, DetailAST node, DetailAST javadoc) { super(ctx, node, javadoc); } + @Override + public String getText() { + var text = super.getText(); + return text == null ? null : text.replace("

", "").replace("

", "").trim(); + } + public String getName() { return JavaDocSupport.getSimpleName(node); } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java index 16c4b06651..fd3459ca79 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java @@ -5,7 +5,6 @@ */ package io.jooby.internal.openapi.javadoc; -import static io.jooby.internal.openapi.javadoc.JavaDocStream.*; import static io.jooby.internal.openapi.javadoc.JavaDocStream.javadocToken; import java.util.*; diff --git a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java index 26fc7b6399..cbcf1963ca 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java @@ -241,6 +241,7 @@ public OpenAPIGenerator() {} doc.getServers().forEach(openapi::addServersItem); doc.getContact().forEach(info::setContact); doc.getLicense().forEach(info::setLicense); + doc.getTags().forEach(openapi::addTagsItem); }); } From 7ad2da41fa66f197e3c8970ffa78169ee5c4f311 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sat, 13 Dec 2025 09:11:26 -0300 Subject: [PATCH 15/31] pebble/ascii doc support: redo pebble function and filter all them follow the given patter: `{{find/locate | mutator/filter | display}}` it creates a clear and common pattern for final users --- modules/jooby-openapi/pom.xml | 5 + .../io/jooby/internal/openapi/EnumSchema.java | 9 + .../io/jooby/internal/openapi/OpenAPIExt.java | 8 +- .../jooby/internal/openapi/ParserContext.java | 1 + .../openapi/asciidoc/AsciiDocContext.java | 321 ++++++++ .../openapi/asciidoc/AutoDataFakerMapper.java | 340 +++++++++ .../internal/openapi/asciidoc/Display.java | 150 ++++ .../openapi/asciidoc/HttpMessage.java | 50 ++ .../internal/openapi/asciidoc/HttpParam.java | 30 + .../openapi/asciidoc/HttpParamList.java | 27 + .../openapi/asciidoc/HttpRequest.java | 280 +++++++ .../openapi/asciidoc/HttpResponse.java | 74 ++ .../openapi/asciidoc/InternalContext.java | 4 + .../internal/openapi/asciidoc/Lookup.java | 158 ++++ .../internal/openapi/asciidoc/Mutator.java | 199 +++++ .../internal/openapi/asciidoc/ToSnippet.java | 12 + .../openapi/asciidoc/http/RequestToCurl.java | 180 +++++ .../openapi/asciidoc/http/RequestToHttp.java | 46 ++ .../openapi/asciidoc/http/ResponseToHttp.java | 42 ++ .../openapi/asciidoc/table/ToAsciiDoc.java | 192 +++++ .../src/main/java/module-info.java | 2 + .../asciidoc/default-form-parameters.snippet | 2 +- .../openapi/templates/asciidoc/error.peb | 5 + .../asciidoc/AutoDataFakerMapperTest.java | 157 ++++ .../asciidoc/PebbleTemplateSupport.java | 48 ++ .../java/io/jooby/openapi/CurrentDir.java | 6 + .../io/jooby/openapi/OpenAPIExtension.java | 7 +- .../java/io/jooby/openapi/OpenAPIResult.java | 4 + .../java/issues/i3729/api/ApiDocTest.java | 36 +- .../{AppLibrary2.java => AppDemoLibrary.java} | 4 +- .../test/java/issues/i3729/api/BookType.java | 49 ++ .../{LibraryApi2.java => LibraryDemoApi.java} | 34 +- .../java/issues/i3820/PebbleSupportTest.java | 692 ++++++++++++++++++ .../test/java/issues/i3820/app/AppLib.java | 34 + .../test/java/issues/i3820/app/LibApi.java | 119 +++ .../test/java/issues/i3820/app/Library.java | 87 +++ .../test/java/issues/i3820/model/Book.java | 51 +- .../java/issues/i3820/model/BookType.java | 49 ++ .../src/test/resources/adoc/library.yml | 262 +++++++ pom.xml | 2 +- 40 files changed, 3690 insertions(+), 88 deletions(-) create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AutoDataFakerMapper.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpMessage.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpParam.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpParamList.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequest.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpResponse.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Lookup.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Mutator.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/ToSnippet.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/RequestToCurl.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/RequestToHttp.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/ResponseToHttp.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/table/ToAsciiDoc.java create mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/error.peb create mode 100644 modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/AutoDataFakerMapperTest.java create mode 100644 modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/PebbleTemplateSupport.java rename modules/jooby-openapi/src/test/java/issues/i3729/api/{AppLibrary2.java => AppDemoLibrary.java} (73%) create mode 100644 modules/jooby-openapi/src/test/java/issues/i3729/api/BookType.java rename modules/jooby-openapi/src/test/java/issues/i3729/api/{LibraryApi2.java => LibraryDemoApi.java} (72%) create mode 100644 modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3820/app/AppLib.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3820/app/LibApi.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3820/app/Library.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3820/model/BookType.java create mode 100644 modules/jooby-openapi/src/test/resources/adoc/library.yml diff --git a/modules/jooby-openapi/pom.xml b/modules/jooby-openapi/pom.xml index 9ad0a0bbbc..44cf438e8d 100644 --- a/modules/jooby-openapi/pom.xml +++ b/modules/jooby-openapi/pom.xml @@ -66,6 +66,11 @@ pebble + + net.datafaker + datafaker + 2.5.3 + commons-codec commons-codec diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/EnumSchema.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/EnumSchema.java index f07f65fd5a..b9555d7102 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/EnumSchema.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/EnumSchema.java @@ -13,9 +13,18 @@ public class EnumSchema extends StringSchema { @JsonIgnore private final Map fields = new HashMap<>(); + @JsonIgnore private String summary; public EnumSchema() {} + public String getSummary() { + return summary; + } + + public void setSummary(String summary) { + this.summary = summary; + } + public void setDescription(String name, String description) { fields.put(name, description); } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIExt.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIExt.java index 143bc40c78..520abecb23 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIExt.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIExt.java @@ -244,15 +244,11 @@ public OperationExt findOperationById(String operationId) { public OperationExt findOperation(String method, String pattern) { Predicate filter = op -> op.getPath().equals(pattern); - if (method != null) { - filter = filter.and(op -> op.getMethod().equals(method)); - } + filter = filter.and(op -> op.getMethod().equals(method)); return getOperations().stream() .filter(filter) .findFirst() .orElseThrow( - () -> - new IllegalArgumentException( - "Operation not found: " + (method == null ? "" : method + " ") + pattern)); + () -> new IllegalArgumentException("Operation not found: " + method + " " + pattern)); } } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java index 7546576799..671027a407 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java @@ -346,6 +346,7 @@ private void document(Class typeName, Schema schema, ResolvedSchemaExt resolvedS enumSchema.setDescription(field, enumItemDesc); } } + enumSchema.setSummary(enumDoc.getSummary()); enumSchema.setDescription(enumDesc); } }); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java new file mode 100644 index 0000000000..08a7308859 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java @@ -0,0 +1,321 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import static java.util.Optional.ofNullable; + +import java.nio.file.Path; +import java.util.*; +import java.util.function.BiConsumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; +import io.jooby.SneakyThrows; +import io.jooby.internal.openapi.OpenAPIExt; +import io.pebbletemplates.pebble.PebbleEngine; +import io.pebbletemplates.pebble.extension.AbstractExtension; +import io.pebbletemplates.pebble.extension.Filter; +import io.pebbletemplates.pebble.extension.Function; +import io.pebbletemplates.pebble.lexer.Syntax; +import io.pebbletemplates.pebble.template.EvaluationContext; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.Schema; + +public class AsciiDocContext { + public static final BiConsumer> NOOP = (name, schema) -> {}; + public static final Schema EMPTY_SCHEMA = new Schema<>(); + + private ClassLoader classLoader; + + private ObjectMapper json; + + private ObjectMapper yamlOpenApi; + + private ObjectMapper yamlOutput; + + private PebbleEngine engine; + + private OpenAPIExt openapi; + + private Path baseDir; + + private Path outputDir; + + private final AutoDataFakerMapper faker = new AutoDataFakerMapper(); + + private final Map, Map> examples = new HashMap<>(); + + public AsciiDocContext( + ClassLoader classLoader, + ObjectMapper json, + ObjectMapper yaml, + OpenAPIExt openapi, + Path baseDir, + Path outputDir) { + this.classLoader = classLoader; + this.json = json; + this.yamlOpenApi = yaml; + this.yamlOutput = newYamlOutput(); + this.engine = createEngine(json, openapi, this); + this.openapi = openapi; + this.baseDir = baseDir; + this.outputDir = outputDir; + } + + private ObjectMapper newYamlOutput() { + var factory = new YAMLFactory(); + factory.enable(YAMLGenerator.Feature.MINIMIZE_QUOTES); + factory.disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER); + return new ObjectMapper(factory); + } + + private static PebbleEngine createEngine( + ObjectMapper json, OpenAPI openapi, AsciiDocContext context) { + return new PebbleEngine.Builder() + .autoEscaping(false) + .extension( + new AbstractExtension() { + @Override + public Map getGlobalVariables() { + Map openapiRoot = json.convertValue(openapi, Map.class); + openapiRoot.put("openapi", openapi); + openapiRoot.put("_asciidocContext", context); + return openapiRoot; + } + + @Override + public Map getFunctions() { + return Lookup.lookup(); + } + + @Override + public Map getFilters() { + Map filters = new HashMap<>(); + filters.putAll(Mutator.seek()); + filters.putAll(Display.display()); + return filters; + } + }) + .syntax(new Syntax.Builder().setEnableNewLineTrimming(false).build()) + .build(); + } + + public String schemaType(Schema schema) { + var resolved = resolveSchema(schema); + return Optional.ofNullable(resolved.getFormat()).orElse(resolved.getType()); + } + + public Schema resolveSchema(Schema schema) { + if (schema == EMPTY_SCHEMA) { + return schema; + } + if (schema.get$ref() != null) { + return resolveSchemaInternal(schema.get$ref()) + .orElseThrow(() -> new NoSuchElementException("Schema not found: " + schema.get$ref())); + } + return schema; + } + + public Map schemaProperties(Schema schema) { + return traverse(schema, NOOP); + } + + @SuppressWarnings("rawtypes") + public Schema reduceSchema(Schema schema) { + var truncated = emptySchema(schema); + var properties = new LinkedHashMap(); + traverse( + schema, + (name, value) -> { + var type = value.getType(); + if ("object".equals(type)) { + var object = new Schema<>(); + object.setType(type); + properties.put(name, object); + } else if ("array".equals(type)) { + var array = new Schema<>(); + array.setType(type); + array.setItems(new Schema<>()); + properties.put(name, array); + } else { + properties.put(name, value); + } + }); + truncated.setProperties(properties); + return truncated; + } + + public Schema emptySchema(Schema schema) { + var empty = new Schema<>(); + empty.setType(schema.getType()); + empty.setName(schema.getName()); + return empty; + } + + public Map schemaExample(Schema schema) { + return examples.computeIfAbsent( + schema, + s -> + traverse( + s, + (parent, property) -> { + var enumItems = property.getEnum(); + if (enumItems == null || enumItems.isEmpty()) { + var type = schemaType(property); + var gen = faker.getGenerator(parent.getName(), property.getName(), type, type); + return gen.get(); + } else { + return enumItems.get(new Random().nextInt(enumItems.size())).toString(); + } + }, + NOOP, + NOOP)); + } + + public void traverseSchema(Schema schema, BiConsumer> consumer) { + traverse(schema, consumer); + } + + public void traverseGraph(Schema schema, BiConsumer> consumer) { + traverse(schema, consumer, consumer); + } + + private Map traverse(Schema schema, BiConsumer> consumer) { + return traverse(schema, consumer, NOOP); + } + + private Map traverse( + Schema schema, + BiConsumer> consumer, + BiConsumer> inner) { + return traverse(schema, (parent, property) -> schemaType(property), consumer, inner); + } + + private Map traverse( + Schema schema, + SneakyThrows.Function2, Schema, String> valueMapper, + BiConsumer> consumer, + BiConsumer> inner) { + var resolved = resolveSchema(schema); + var properties = resolved.getProperties(); + if (properties != null) { + Map result = new LinkedHashMap<>(); + properties.forEach( + (name, value) -> { + value = resolveSchema(value); + consumer.accept(name, value); + if (value.getType().equals("object")) { + result.put(name, traverse(value, valueMapper, inner, inner)); + } else if (value.getType().equals("array")) { + var array = + ofNullable(value.getItems()) + .map(items -> traverse(items, valueMapper, inner, inner)) + .map(List::of) + .orElse(List.of()); + result.put(name, array); + } else { + result.put(name, valueMapper.apply(resolved, value)); + } + }); + return result; + } + return Map.of(); + } + + public Schema resolveSchema(String path) { + var segments = path.split("\\."); + var schema = + resolveSchemaInternal(segments[0]) + .orElseThrow(() -> new NoSuchElementException("Schema not found: " + path)); + + for (int i = 1; i < segments.length; i++) { + Schema inner = (Schema) schema.getProperties().get(segments[i]); + if (inner == null) { + throw new IllegalArgumentException( + "Property not found: " + Stream.of(segments).limit(i).collect(Collectors.joining("."))); + } + if (inner.get$ref() != null) { + inner = + resolveSchemaInternal(inner.get$ref()) + .orElseThrow(() -> new NoSuchElementException("Schema not found: " + path)); + } + schema = inner; + } + + return schema; + } + + private Optional> resolveSchemaInternal(String name) { + var components = openapi.getComponents(); + if (components == null || components.getSchemas() == null) { + throw new NoSuchElementException("No schema found"); + } + if (name.startsWith("#/components/schemas/")) { + name = name.substring("#/components/schemas/".length()); + } + return Optional.ofNullable((Schema) components.getSchemas().get(name)); + } + + public PebbleEngine getEngine() { + return engine; + } + + public Path getBaseDir() { + return baseDir; + } + + public Path getOutputDir() { + return outputDir; + } + + public ClassLoader getClassLoader() { + return classLoader; + } + + public String toJson(Object input, boolean pretty) { + try { + var writer = pretty ? json.writer().withDefaultPrettyPrinter() : json.writer(); + return writer.writeValueAsString(input); + } catch (JsonProcessingException e) { + throw SneakyThrows.propagate(e); + } + } + + public String toYaml(Object input) { + try { + return cleanYaml( + input instanceof Map + ? yamlOutput.writeValueAsString(input) + : yamlOpenApi.writeValueAsString(input)); + } catch (JsonProcessingException e) { + throw SneakyThrows.propagate(e); + } + } + + private String cleanYaml(String value) { + return value.trim(); + } + + public ObjectMapper getJson() { + return json; + } + + public ObjectMapper getYaml() { + return yamlOpenApi; + } + + public OpenAPIExt getOpenApi() { + return openapi; + } + + public static AsciiDocContext from(EvaluationContext context) { + return (AsciiDocContext) context.getVariable("_asciidocContext"); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AutoDataFakerMapper.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AutoDataFakerMapper.java new file mode 100644 index 0000000000..db347b2ad0 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AutoDataFakerMapper.java @@ -0,0 +1,340 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.*; +import java.util.function.Supplier; + +import net.datafaker.Faker; +import net.datafaker.providers.base.AbstractProvider; + +public class AutoDataFakerMapper { + + private final Faker faker; + + // --- REGISTRIES (Functional) --- + private final Map> specificRegistry = new HashMap<>(); + private final Map> genericRegistry = new HashMap<>(); + private final Map> typeRegistry = new HashMap<>(); + + // --- SYNONYMS --- + private final Map synonymMap; + private static final Map DEFAULT_SYNONYMS = new HashMap<>(); + + static { + registerDefault("surname", "lastname"); + registerDefault("familyname", "lastname"); + registerDefault("login", "username"); + registerDefault("user", "username"); + registerDefault("fullname", "name"); + registerDefault("displayname", "name"); + registerDefault("social", "ssnvalid"); + registerDefault("ssn", "ssnvalid"); + registerDefault("mail", "emailaddress"); + registerDefault("email", "emailaddress"); + registerDefault("subject", "emailsubject"); + registerDefault("web", "url"); + registerDefault("homepage", "url"); + registerDefault("link", "url"); + registerDefault("uri", "url"); + registerDefault("avatar", "image"); + registerDefault("pic", "image"); + registerDefault("pwd", "password"); + registerDefault("pass", "password"); + registerDefault("cell", "cellphone"); + registerDefault("mobile", "cellphone"); + registerDefault("tel", "phonenumber"); + registerDefault("fax", "phonenumber"); + registerDefault("addr", "fulladdress"); + registerDefault("street", "streetaddress"); + registerDefault("postcode", "zipcode"); + registerDefault("postal", "zipcode"); + registerDefault("zip", "zipcode"); + registerDefault("town", "city"); + registerDefault("province", "state"); + registerDefault("region", "state"); + registerDefault("lat", "latitude"); + registerDefault("lon", "longitude"); + registerDefault("lng", "longitude"); + registerDefault("qty", "quantity"); + registerDefault("cost", "price"); + registerDefault("amount", "price"); + registerDefault("desc", "sentence"); + registerDefault("description", "paragraph"); + registerDefault("dept", "industry"); + registerDefault("role", "title"); + registerDefault("position", "title"); + registerDefault("dob", "birthday"); + registerDefault("born", "birthday"); + registerDefault("created", "date"); + registerDefault("modified", "date"); + registerDefault("timestamp", "date"); + registerDefault("guid", "uuid"); + } + + private static void registerDefault(String key, String value) { + DEFAULT_SYNONYMS.put(key, value); + } + + // --- CONSTRUCTORS --- + + public AutoDataFakerMapper() { + this.faker = new Faker(); + this.synonymMap = new HashMap<>(DEFAULT_SYNONYMS); + + initializeReflectionRegistry(); + initializeTypeRegistry(); + } + + public void synonyms(Map synonyms) { + synonyms.forEach((k, v) -> this.synonymMap.put(normalize(k), normalize(v))); + } + + private void initializeReflectionRegistry() { + Arrays.stream(Faker.class.getMethods()) + .filter(this::isProviderMethod) + .forEach(this::registerProvider); + } + + private void registerType(String type, Supplier supplier, String description) { + String cleanType = normalize(type); + typeRegistry.put(cleanType, fakeSupplier(supplier, description)); + } + + private static Supplier fakeSupplier(Supplier supplier, String signature) { + return new Supplier<>() { + @Override + public String get() { + return supplier.get(); + } + + @Override + public String toString() { + return signature; + } + }; + } + + private void initializeTypeRegistry() { + // domains + specificRegistry.put( + "book.isbn", fakeSupplier(() -> faker.code().isbn13(), "faker.code().isbn13()")); + + // We now register the Description alongside the Supplier + registerType("uuid", () -> faker.internet().uuid(), "faker.internet().uuid()"); + registerType("email", () -> faker.internet().emailAddress(), "faker.internet().emailAddress()"); + registerType( + "password", () -> faker.credentials().password(), "faker.credentials().password()"); + registerType("ipv4", () -> faker.internet().ipV4Address(), "faker.internet().ipV4Address()"); + registerType("ipv6", () -> faker.internet().ipV6Address(), "faker.internet().ipV6Address()"); + registerType("uri", () -> faker.internet().url(), "faker.internet().url()"); + registerType("url", () -> faker.internet().url(), "faker.internet().url()"); + registerType("hostname", () -> faker.internet().domainName(), "faker.internet().domainName()"); + + registerType( + "date", () -> faker.timeAndDate().birthday().toString(), "faker.timeAndDate().birthday()"); + registerType( + "datetime", + () -> faker.timeAndDate().past(365, java.util.concurrent.TimeUnit.DAYS).toString(), + "faker.timeAndDate().past()"); + registerType( + "time", + () -> faker.timeAndDate().birthday().toString().split(" ")[1], + "faker.timeAndDate().birthday() (time-part)"); + + registerType( + "integer", + () -> String.valueOf(faker.number().numberBetween(1, 100)), + "faker.number().numberBetween(1, 100)"); + registerType( + "int32", + () -> String.valueOf(faker.number().numberBetween(1, 100)), + "faker.number().numberBetween(1, 100)"); + registerType( + "int64", + () -> String.valueOf(faker.number().numberBetween(1, 100)), + "faker.number().numberBetween(1, 100)"); + registerType( + "float", + () -> String.valueOf(faker.number().randomDouble(2, 0, 100)), + "faker.number().randomDouble()"); + registerType( + "double", + () -> String.valueOf(faker.number().randomDouble(4, 0, 100)), + "faker.number().randomDouble()"); + registerType( + "number", + () -> String.valueOf(faker.number().numberBetween(0, 100)), + "faker.number().numberBetween(1, 100)"); + + registerType("boolean", () -> String.valueOf(faker.bool().bool()), "faker.bool().bool()"); + + registerType("string", () -> "string", "string"); + } + + private void registerProvider(Method providerMethod) { + try { + Object providerInstance = providerMethod.invoke(faker); + String domainName = normalize(providerMethod.getName()); + + Arrays.stream(providerInstance.getClass().getMethods()) + .filter(this::isValidGeneratorMethod) + .forEach(method -> registerMethod(domainName, providerInstance, method)); + + } catch (Exception ignored) { + } + } + + private void registerMethod(String domainName, Object providerInstance, Method method) { + String fieldName = normalize(method.getName()); + String signature = "faker.%s().%s()".formatted(domainName, method.getName()); + + Supplier rawGenerator = fakerSupplier(providerInstance, method, signature); + + // 1. Specific Registry + specificRegistry.put(domainName + "." + fieldName, rawGenerator); + + // add base generic only + if (method.getDeclaringClass().getPackage().equals(AbstractProvider.class.getPackage())) { + // 2. Generic Registry (First one wins) + genericRegistry.putIfAbsent(fieldName, rawGenerator); + } + } + + // --- CORE LOGIC (Unchanged) --- + public Supplier getGenerator( + String className, String fieldName, String fieldType, String defaultValue) { + var cleanClass = normalize(className); + var cleanField = normalize(fieldName); + var cleanType = normalize(fieldType); + + String resolvedField = synonymMap.getOrDefault(cleanField, cleanField); + + var specific = specificRegistry.get(cleanClass + "." + resolvedField); + if (specific != null) return wrap(specific, defaultValue); + + var generic = genericRegistry.get(resolvedField); + if (generic != null) return wrap(generic, defaultValue); + + var fuzzy = + genericRegistry.entrySet().stream() + .filter(entry -> resolvedField.contains(entry.getKey()) && entry.getKey().length() > 3) + .map(Map.Entry::getValue) + .findFirst() + .orElse(null); + + if (fuzzy != null) return wrap(fuzzy, defaultValue); + + if (!cleanType.isEmpty()) { + var typeGen = typeRegistry.get(cleanType); + if (typeGen != null) return wrap(typeGen, defaultValue); + } + + return () -> defaultValue; + } + + // --- CAPABILITY MAP (IMPROVED) --- + + /** Returns a structured map of all available capabilities with Source details. */ + public Map getCapabilityMap() { + // 1. Domains Tree: "book" -> { "title": "faker.book().title()" } + Map> domainsTree = new TreeMap<>(); + specificRegistry.forEach( + (key, signature) -> { + int dotIndex = key.indexOf('.'); + if (dotIndex > 0) { + String domain = key.substring(0, dotIndex); + String field = key.substring(dotIndex + 1); + domainsTree + .computeIfAbsent(domain, k -> new TreeMap<>()) + .put(field, signature.toString()); + } + }); + + // 2. Generics Map: "title" -> "faker.book().title()" + // (Using TreeMap for sorting) + Map genericsMap = new TreeMap<>(); + genericRegistry.forEach( + (key, signature) -> { + genericsMap.put(key, signature.toString()); + }); + + // 3. Types Map: "uuid" -> "faker.internet().uuid()" + Map typesMap = new TreeMap<>(); + typeRegistry.forEach( + (key, signature) -> { + typesMap.put(key, signature.toString()); + }); + + // 4. Synonyms Copy + Map synonymsCopy = new TreeMap<>(synonymMap); + + return Map.of( + "domains", domainsTree, + "generics", genericsMap, + "types", typesMap, + "synonyms", synonymsCopy); + } + + private static Supplier fakerSupplier(Object instance, Method method, String signature) { + return new Supplier<>() { + @Override + public String get() { + try { + return (String) method.invoke(instance); + } catch (Exception ignored) { + return null; + } + } + + @Override + public String toString() { + return signature; + } + }; + } + + private static Supplier wrap(Supplier supplier, String defaultValue) { + return new Supplier<>() { + @Override + public String get() { + try { + String res = supplier.get(); + return res != null ? res : defaultValue; + } catch (Exception e) { + return defaultValue; + } + } + + @Override + public String toString() { + return supplier.toString(); + } + }; + } + + private boolean isProviderMethod(Method m) { + return m.getParameterCount() == 0 && AbstractProvider.class.isAssignableFrom(m.getReturnType()); + } + + private boolean isValidGeneratorMethod(Method m) { + return Modifier.isPublic(m.getModifiers()) + && m.getParameterCount() == 0 + && m.getReturnType().equals(String.class) + && !isStandardMethod(m.getName()); + } + + private boolean isStandardMethod(String name) { + return "toString".equals(name); + } + + private String normalize(String input) { + if (input == null || input.isBlank()) return ""; + return input.toLowerCase().trim().replaceAll("[^a-z0-9]", ""); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java new file mode 100644 index 0000000000..4c0bff2977 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java @@ -0,0 +1,150 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.jooby.internal.openapi.OperationExt; +import io.jooby.internal.openapi.asciidoc.http.RequestToCurl; +import io.jooby.internal.openapi.asciidoc.http.RequestToHttp; +import io.jooby.internal.openapi.asciidoc.http.ResponseToHttp; +import io.jooby.internal.openapi.asciidoc.table.ToAsciiDoc; +import io.pebbletemplates.pebble.error.PebbleException; +import io.pebbletemplates.pebble.extension.Filter; +import io.pebbletemplates.pebble.extension.escaper.SafeString; +import io.pebbletemplates.pebble.template.EvaluationContext; +import io.pebbletemplates.pebble.template.PebbleTemplate; +import io.swagger.v3.oas.models.media.Schema; + +public enum Display implements Filter { + json { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + var asciidoc = InternalContext.asciidoc(context); + var pretty = args.getOrDefault("pretty", true) == Boolean.TRUE; + return new SafeString(asciidoc.toJson(toJson(asciidoc, input), pretty)); + } + }, + yaml { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + var asciidoc = InternalContext.asciidoc(context); + return new SafeString(asciidoc.toYaml(toJson(asciidoc, input))); + } + }, + table { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + var asciidoc = InternalContext.asciidoc(context); + return new SafeString(toAsciidoc(asciidoc, input).table(args)); + } + }, + list { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + var asciidoc = InternalContext.asciidoc(context); + return new SafeString(toAsciidoc(asciidoc, input).list(args)); + } + }, + curl { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + var asciidoc = InternalContext.asciidoc(context); + var curl = + switch (input) { + case OperationExt op -> + new RequestToCurl(asciidoc, new HttpRequest(asciidoc, op, args)); + case HttpRequest req -> new RequestToCurl(asciidoc, req); + default -> throw new IllegalArgumentException("Can't render: " + input); + }; + return curl.render(args); + } + }, + http { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + var asciidoc = InternalContext.asciidoc(context); + return toHttp(asciidoc, input, args).render(args); + } + + private ToSnippet toHttp(AsciiDocContext context, Object input, Map options) { + return switch (input) { + case OperationExt op -> new RequestToHttp(context, new HttpRequest(context, op, options)); + case HttpRequest req -> new RequestToHttp(context, req); + case HttpResponse rsp -> new ResponseToHttp(context, rsp); + default -> throw new IllegalArgumentException("Can't render: " + input); + }; + } + }; + + protected ToAsciiDoc toAsciidoc(AsciiDocContext context, Object input) { + return switch (input) { + case HttpRequest req -> ToAsciiDoc.parameters(context, req.getAllParameters()); + case HttpResponse rsp -> ToAsciiDoc.schema(context, rsp.getBody()); + case Schema schema -> ToAsciiDoc.schema(context, schema); + case HttpParamList paramList -> ToAsciiDoc.parameters(context, paramList); + default -> throw new IllegalArgumentException("Can't render: " + input); + }; + } + + protected Object toJson(AsciiDocContext context, Object input) { + return switch (input) { + case Schema schema -> context.schemaProperties(schema); + default -> input; + }; + } + + @Override + public List getArgumentNames() { + return List.of(); + } + + public static Map display() { + Map result = new HashMap<>(); + for (var value : values()) { + result.put(value.name(), value); + } + return result; + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpMessage.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpMessage.java new file mode 100644 index 0000000000..d67f4d953b --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpMessage.java @@ -0,0 +1,50 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.List; + +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.Schema; + +public interface HttpMessage { + + HttpParamList getHeaders(); + + HttpParamList getCookies(); + + Schema getBody(); + + AsciiDocContext context(); + + default Schema selectBody(Schema body, String modifier) { + if (body != AsciiDocContext.EMPTY_SCHEMA) { + return switch (modifier) { + case "full" -> body; + case "simple" -> context().reduceSchema(body); + default -> context().emptySchema(body); + }; + } + return body; + } + + default Schema toSchema(Content content, List contentType) { + if (content == null || content.isEmpty()) { + return null; + } + if (contentType.isEmpty()) { + // first response + return content.values().iterator().next().getSchema(); + } + for (var key : contentType) { + var mediaType = content.get(key); + if (mediaType != null) { + return mediaType.getSchema(); + } + } + return null; + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpParam.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpParam.java new file mode 100644 index 0000000000..7f0b98f726 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpParam.java @@ -0,0 +1,30 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Stream; + +import io.swagger.v3.oas.models.media.Schema; + +public record HttpParam( + String name, Schema schema, Object value, String in, String description) { + + public String get(String field) { + return switch (field) { + case "name" -> name; + case "value" -> value == null ? "" : value.toString(); + case "type" -> Optional.ofNullable(schema.getFormat()).orElse(schema.getType()); + case "in" -> in == null ? "" : in; + default -> + Stream.of(description, schema.getDescription()) + .filter(Objects::nonNull) + .findFirst() + .orElse(""); + }; + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpParamList.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpParamList.java new file mode 100644 index 0000000000..61401f187e --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpParamList.java @@ -0,0 +1,27 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.Iterator; +import java.util.List; + +import edu.umd.cs.findbugs.annotations.NonNull; + +public record HttpParamList(List parameters, List includes) + implements Iterable { + public static final List NAME_DESC = List.of("name", "description"); + public static final List NAME_TYPE_DESC = List.of("name", "type", "description"); + public static final List PARAM = List.of("name", "type", "in", "description"); + + @NonNull @Override + public Iterator iterator() { + return parameters.iterator(); + } + + public boolean isEmpty() { + return parameters.isEmpty(); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequest.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequest.java new file mode 100644 index 0000000000..17fef061b7 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequest.java @@ -0,0 +1,280 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; +import java.util.function.Predicate; + +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.ListMultimap; +import com.google.common.net.UrlEscapers; +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.Router; +import io.jooby.internal.openapi.OperationExt; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.parameters.Parameter; + +public record HttpRequest( + AsciiDocContext context, OperationExt operation, Map options) + implements HttpMessage { + + private static final Predicate NOOP = p -> true; + + public String getMethod() { + return operation.getMethod(); + } + + public String getPath() { + return operation.getPath(); + } + + public List getProduces() { + return operation.getProduces(); + } + + public List getConsumes() { + return operation.getConsumes(); + } + + @Override + public HttpParamList getHeaders() { + var requestHeaders = ArrayListMultimap.create(); + var parameters = Optional.ofNullable(operation.getParameters()).orElse(List.of()); + var headerParams = parameters.stream().filter(it -> "header".equals(it.getIn())).toList(); + operation + .getProduces() + .forEach( + value -> + requestHeaders.put( + "Accept", new HttpParam("Accept", new StringSchema(), value, "header", null))); + if (Set.of(Router.PATCH, Router.PUT, Router.POST, Router.DELETE) + .contains(operation.getMethod())) { + operation + .getConsumes() + .forEach( + value -> + requestHeaders.put( + "Content-Type", + new HttpParam("Content-Type", new StringSchema(), value, "header", null))); + } + headerParams.forEach( + it -> + requestHeaders.put( + it.getName(), + new HttpParam( + it.getName(), it.getSchema(), "{{" + it.getName() + "}}", "header", null))); + return new HttpParamList( + requestHeaders.entries().stream().map(Map.Entry::getValue).toList(), + HttpParamList.NAME_DESC); + } + + @Override + public HttpParamList getCookies() { + var parameters = Optional.ofNullable(operation.getParameters()).orElse(List.of()); + return new HttpParamList( + parameters.stream() + .filter(it -> "cookie".equals(it.getIn())) + .map( + it -> + new HttpParam( + it.getName(), + it.getSchema(), + "{{" + it.getName() + "}}", + "cookie", + it.getDescription())) + .toList(), + HttpParamList.NAME_DESC); + } + + public HttpParamList getParameters() { + return getParameterList(NOOP, Map.of(), HttpParamList.PARAM); + } + + public HttpParamList getQueryParameters() { + return getQueryParameters(Map.of()); + } + + public String getQueryString() { + var sb = new StringBuilder("?"); + + for (var param : getParameters(inFilter("query"), Map.of())) { + encode( + param.getName(), + param.getSchema(), + (schema, e) -> + Map.entry(e.getKey(), UrlEscapers.urlFragmentEscaper().escape(e.getValue())), + (name, value) -> sb.append(name).append("=").append(value).append("&")); + } + if (sb.length() > 1) { + sb.setLength(sb.length() - 1); + return sb.toString(); + } + return ""; + } + + private HttpParamList getQueryParameters(Map paramValues) { + return getParameterList(inFilter("query"), paramValues, HttpParamList.NAME_TYPE_DESC); + } + + @SuppressWarnings("unchecked") + private Schema getBody(List contentType) { + var body = + Optional.ofNullable(operation.getRequestBody()) + .map(it -> toSchema(it.getContent(), contentType)) + .map(context::resolveSchema) + .orElse(AsciiDocContext.EMPTY_SCHEMA); + return selectBody(body, options.getOrDefault("body", "full").toString()); + } + + public Schema getForm() { + return getBody(List.of("application/x-www-form-urlencoded)", "multipart/form-data")); + } + + public ListMultimap getFormUrlEncoded() { + return formUrlEncoded((schema, field) -> field); + } + + @NonNull public ListMultimap formUrlEncoded( + BiFunction, Map.Entry, Map.Entry> formatter) { + var output = ArrayListMultimap.create(); + var form = getForm(); + if (form != AsciiDocContext.EMPTY_SCHEMA) { + traverseSchema(null, form, formatter, output::put); + } + return output; + } + + private void traverseSchema( + String path, + Schema schema, + BiFunction, Map.Entry, Map.Entry> formatter, + BiConsumer consumer) { + context.traverseSchema( + schema, + (propertyName, value) -> { + var propertyPath = path == null ? propertyName : path + "." + propertyName; + if (value.getType().equals("object")) { + traverseSchema(propertyPath, value, formatter, consumer); + } else if (value.getType().equals("array")) { + traverseSchema(propertyPath + "[0]", value.getItems(), formatter, consumer); + } else { + encode(propertyPath, value, formatter, consumer); + } + }); + } + + private void encode( + String propertyName, + Schema schema, + BiFunction, Map.Entry, Map.Entry> formatter, + BiConsumer consumer) { + var names = List.of(propertyName); + var index = new AtomicInteger(0); + if (schema.getType().equals("array")) { + schema = schema.getItems(); + // shows 3 examples + names = List.of(propertyName, propertyName, propertyName); + index.set(1); + } + var schemaType = context.schemaType(schema); + if ("binary".equals(schema.getFormat())) { + schemaType = "file"; + } + var value = schemaType + "%1$s"; + for (String name : names) { + var formattedPair = + formatter.apply( + schema, + Map.entry( + name, String.format(value, (index.get() == 0 ? "" : index.getAndIncrement())))); + consumer.accept(formattedPair.getKey(), formattedPair.getValue()); + } + } + + @Override + public Schema getBody() { + return getBody(List.of()); + } + + public HttpParamList getPathParameters() { + return getParameterList(inFilter("path"), Map.of(), HttpParamList.NAME_TYPE_DESC); + } + + public HttpParamList getAllParameters() { + var parameters = new ArrayList<>(getParameters(NOOP, Map.of())); + var body = getForm(); + var bodyType = "form"; + if (body == AsciiDocContext.EMPTY_SCHEMA) { + body = getBody(); + bodyType = "body"; + } + var paramType = bodyType; + if (body != AsciiDocContext.EMPTY_SCHEMA) { + context.traverseSchema( + body, + (propertyName, schema) -> { + var p = new Parameter(); + p.setName(propertyName); + p.setSchema(schema); + p.setIn(paramType); + p.setDescription(schema.getDescription()); + parameters.add(p); + }); + } + return toParameterList(parameters, Map.of(), HttpParamList.PARAM); + } + + private HttpParamList getParameterList( + Predicate predicate, Map paramValues, List includes) { + return toParameterList(getParameters(predicate, paramValues), paramValues, includes); + } + + private HttpParamList toParameterList( + List parameters, Map paramValues, List includes) { + return new HttpParamList( + parameters.stream() + .map( + it -> + new HttpParam( + it.getName(), + context.resolveSchema(it.getSchema()), + paramValues.get(it.getName()), + it.getIn(), + it.getDescription())) + .toList(), + includes); + } + + private List getParameters( + Predicate predicate, Map paramValues) { + var parameters = Optional.ofNullable(operation.getParameters()).orElse(List.of()); + return parameters.stream().filter(predicate.and(paramValueFilter(paramValues))).toList(); + } + + private static Predicate paramValueFilter(Map paramValues) { + if (paramValues == null || paramValues.isEmpty()) { + return NOOP; + } + return p -> paramValues.containsKey(p.getName()); + } + + private static Predicate inFilter(String in) { + return p -> in.equals(p.getIn()); + } + + @NonNull @Override + public String toString() { + return getMethod() + " " + getPath(); + } + + public String getSummary() { + return operation.getSummary(); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpResponse.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpResponse.java new file mode 100644 index 0000000000..619dbb2bd8 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpResponse.java @@ -0,0 +1,74 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import io.jooby.StatusCode; +import io.jooby.internal.openapi.OperationExt; +import io.jooby.internal.openapi.ResponseExt; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.media.StringSchema; + +public record HttpResponse( + AsciiDocContext context, + OperationExt operation, + Integer statusCode, + Map options) + implements HttpMessage { + @Override + public HttpParamList getHeaders() { + return new HttpParamList( + operation.getProduces().stream() + .map(value -> new HttpParam("Content-Type", new StringSchema(), value, "header", null)) + .toList(), + HttpParamList.NAME_DESC); + } + + @Override + public HttpParamList getCookies() { + return new HttpParamList(List.of(), HttpParamList.NAME_DESC); + } + + @Override + public Schema getBody() { + return selectBody(getBody(getResponse()), options.getOrDefault("body", "full").toString()); + } + + private ResponseExt getResponse() { + if (statusCode == null) { + return operation.getDefaultResponse(); + } else { + var rsp = operation.getResponses().get(Integer.toString(statusCode)); + if (rsp == null) { + if (statusCode >= 200 && statusCode <= 299) { + // override default response + return operation.getDefaultResponse(); + } + } + return (ResponseExt) rsp; + } + } + + public StatusCode getStatusCode() { + if (statusCode == null) { + return Optional.ofNullable(getResponse()) + .map(it -> StatusCode.valueOf(Integer.parseInt(it.getCode()))) + .orElse(StatusCode.OK); + } + return StatusCode.valueOf(statusCode); + } + + @SuppressWarnings("unchecked") + private Schema getBody(ResponseExt response) { + return Optional.ofNullable(response) + .map(it -> toSchema(it.getContent(), List.of())) + .map(context::resolveSchema) + .orElse(AsciiDocContext.EMPTY_SCHEMA); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/InternalContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/InternalContext.java index 1f5181266c..f357669ae0 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/InternalContext.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/InternalContext.java @@ -27,6 +27,10 @@ public static OpenAPIExt openApi(EvaluationContext context) { return (OpenAPIExt) context.getVariable("openapi"); } + public static AsciiDocContext asciidoc(EvaluationContext context) { + return (AsciiDocContext) context.getVariable("_asciidocContext"); + } + public static SnippetResolver resolver(EvaluationContext context) { return internal(context, "resolver"); } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Lookup.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Lookup.java new file mode 100644 index 0000000000..6d4adb2dac --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Lookup.java @@ -0,0 +1,158 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.*; + +import io.pebbletemplates.pebble.extension.Function; +import io.pebbletemplates.pebble.template.EvaluationContext; +import io.pebbletemplates.pebble.template.PebbleTemplate; + +/** + * GET("path") | table GET("path") | parameters | table + * + *

schema("Book") | json schema("Book.type") | yaml + * + *

GET("path") | response | json + * + *

GET("path") | response(200) | json + * + *

GET("path") | request | json + * + *

GET("path") | request | http + * + *

GET("path") | request | body | http + */ +public enum Lookup implements Function { + operation { + @Override + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + var method = args.get("method").toString(); + var path = args.get("path").toString(); + var asciidoc = InternalContext.asciidoc(context); + return asciidoc.getOpenApi().findOperation(method, path); + } + + @Override + public List getArgumentNames() { + return List.of("method", "path"); + } + }, + GET { + @Override + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + return operation.execute(appendMethod(args), self, context, lineNumber); + } + + @Override + public List getArgumentNames() { + return List.of("path"); + } + }, + POST { + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + return operation.execute(appendMethod(args), self, context, lineNumber); + } + + @Override + public List getArgumentNames() { + return List.of("path"); + } + }, + PUT { + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + return operation.execute(appendMethod(args), self, context, lineNumber); + } + + @Override + public List getArgumentNames() { + return List.of("path"); + } + }, + PATCH { + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + return operation.execute(appendMethod(args), self, context, lineNumber); + } + + @Override + public List getArgumentNames() { + return List.of("path"); + } + }, + DELETE { + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + return operation.execute(appendMethod(args), self, context, lineNumber); + } + + @Override + public List getArgumentNames() { + return List.of("path"); + } + }, + schema { + @Override + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + var path = args.get("path").toString(); + var asciidoc = InternalContext.asciidoc(context); + return asciidoc.resolveSchema(path); + } + + @Override + public List getArgumentNames() { + return List.of("path"); + } + }, + model { + @Override + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + return schema.execute(args, self, context, lineNumber); + } + + @Override + public List getArgumentNames() { + return schema.getArgumentNames(); + } + }, + tag { + @Override + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + var asciidoc = InternalContext.asciidoc(context); + var name = args.get("name").toString(); + return asciidoc.getOpenApi().getTags().stream() + .filter(tag -> tag.getName().equalsIgnoreCase(name)) + .findFirst() + .orElseThrow(() -> new NoSuchElementException("Tag not found: " + name)); + } + + @Override + public List getArgumentNames() { + return List.of("name"); + } + }; + + protected Map appendMethod(Map args) { + Map result = new LinkedHashMap<>(args); + result.put("method", name()); + return result; + } + + public static Map lookup() { + Map result = new HashMap<>(); + for (var value : values()) { + result.put(value.name(), value); + } + return result; + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Mutator.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Mutator.java new file mode 100644 index 0000000000..54071d08b8 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Mutator.java @@ -0,0 +1,199 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import io.jooby.internal.openapi.OperationExt; +import io.pebbletemplates.pebble.error.PebbleException; +import io.pebbletemplates.pebble.extension.Filter; +import io.pebbletemplates.pebble.template.EvaluationContext; +import io.pebbletemplates.pebble.template.PebbleTemplate; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.media.Schema; + +public enum Mutator implements Filter { + example { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + if (input instanceof Schema schema) { + var asciidoc = InternalContext.asciidoc(context); + return asciidoc.schemaExample(schema); + } + return input; + } + }, + truncate { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + if (input instanceof Schema schema) { + var asciidoc = InternalContext.asciidoc(context); + return asciidoc.reduceSchema(schema); + } + return input; + } + }, + request { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + return new HttpRequest(InternalContext.asciidoc(context), toOperation(input), args); + } + }, + response { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + return new HttpResponse( + InternalContext.asciidoc(context), + toOperation(input), + Optional.ofNullable(args.get("code")) + .map(Number.class::cast) + .map(Number::intValue) + .orElse(null), + args); + } + }, + headers { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + return toHttpMessage(context, input, args).getHeaders(); + } + }, + cookies { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + return toHttpMessage(context, input, args).getCookies(); + } + }, + parameters { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + if (args.containsKey("query") || args.containsValue("query")) { + return toHttpRequest(context, input, args).getQueryParameters(); + } else if (args.containsKey("path") || args.containsValue("path")) { + return toHttpRequest(context, input, args).getPathParameters(); + } else { + return toHttpRequest(context, input, args).getParameters(); + } + } + }, + body { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + var bodyType = args.getOrDefault("type", "full"); + return toHttpMessage(context, input, Map.of("body", bodyType)).getBody(); + } + }, + form { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + return toHttpRequest(context, input, args).getForm(); + } + }; + + protected OperationExt toOperation(Object input) { + if (!(input instanceof OperationExt)) { + throw new IllegalArgumentException( + "Not an operation: " + input.getClass() + ", expecting: " + Operation.class); + } + return (OperationExt) input; + } + + protected HttpMessage toHttpMessage( + EvaluationContext context, Object input, Map options) { + return switch (input) { + case null -> throw new NullPointerException(name() + ": requires a request/response input"); + // default to http request + case OperationExt op -> new HttpRequest(InternalContext.asciidoc(context), op, options); + case HttpMessage msg -> msg; + default -> + throw new ClassCastException( + name() + ": requires a request/response input: " + input.getClass()); + }; + } + + protected HttpRequest toHttpRequest( + EvaluationContext context, Object input, Map options) { + return switch (input) { + case null -> throw new NullPointerException(name() + ": requires a request/response input"); + // default to http request + case OperationExt op -> new HttpRequest(InternalContext.asciidoc(context), op, options); + case HttpRequest msg -> msg; + default -> + throw new ClassCastException( + name() + ": requires a request/response input: " + input.getClass()); + }; + } + + @Override + public List getArgumentNames() { + return List.of(); + } + + public static Map seek() { + Map result = new HashMap<>(); + for (var value : values()) { + result.put(value.name(), value); + } + return result; + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/ToSnippet.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/ToSnippet.java new file mode 100644 index 0000000000..d3e0a82fd0 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/ToSnippet.java @@ -0,0 +1,12 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.Map; + +public interface ToSnippet { + String render(Map options); +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/RequestToCurl.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/RequestToCurl.java new file mode 100644 index 0000000000..f4b6cf9589 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/RequestToCurl.java @@ -0,0 +1,180 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc.http; + +import java.util.*; + +import com.google.common.base.Splitter; +import com.google.common.collect.LinkedHashMultimap; +import com.google.common.collect.Multimap; +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.Router; +import io.jooby.internal.openapi.asciidoc.*; + +public class RequestToCurl implements ToSnippet { + private static final CharSequence Accept = new HeaderName("Accept"); + private static final CharSequence ContentType = new HeaderName("Content-Type"); + + private final AsciiDocContext context; + private final HttpRequest request; + + public RequestToCurl(AsciiDocContext context, HttpRequest request) { + this.context = context; + this.request = request; + } + + @Override + public String render(Map args) { + var options = args(args); + var method = removeOption(options, "-X", request.getMethod()).toUpperCase(); + var language = removeOption(options, "language", null); + /* Accept/Content-Type: */ + var addAccept = true; + var addContentType = true; + if (options.containsKey("-H")) { + var headers = parseHeaders(options.get("-H")); + addAccept = !headers.containsKey(Accept); + addContentType = !headers.containsKey(ContentType); + } + if (addAccept) { + request.getProduces().forEach(value -> options.put("-H", "'Accept: " + value + "'")); + } + if (addContentType + && Set.of(Router.PATCH, Router.PUT, Router.POST, Router.DELETE).contains(method)) { + request.getConsumes().forEach(value -> options.put("-H", "'Content-Type: " + value + "'")); + } + /* Body */ + var formUrlEncoded = + request.formUrlEncoded( + (schema, field) -> { + var option = "--data-urlencode"; + var value = field.getValue(); + if ("binary".equals(schema.getFormat())) { + option = "-F"; + value = "@/file%1$s.extension"; + } + return Map.entry(option, "\"" + field.getKey() + "=" + value + "\""); + }); + if (formUrlEncoded.isEmpty()) { + var body = request.getBody(); + if (body != AsciiDocContext.EMPTY_SCHEMA) { + options.put("-d", "'" + context.toJson(context.schemaProperties(body), false) + "'"); + } + } else { + formUrlEncoded.forEach(options::put); + } + + /* Method */ + var url = request.getPath() + request.getQueryString(); + options.put("-X", method + " '" + url + "'"); + return toString(options, language); + } + + private String toString(Multimap options, String language) { + var curl = "curl"; + var sb = new StringBuilder(); + sb.append("[source"); + if (language != null) { + sb.append(", ").append(language); + } + sb.append("]\n----\n").append(curl); + var separator = "\\\n"; + var tabSize = 1; + for (var entry : options.entries()) { + var k = entry.getKey(); + var v = entry.getValue(); + sb.append(" ".repeat(tabSize)); + sb.append(k); + if (v != null && !v.isEmpty()) { + sb.append(" ").append(v); + } + sb.append(separator); + tabSize = curl.length() + 1; + } + sb.setLength(sb.length() - separator.length()); + sb.append("\n----"); + return sb.toString(); + } + + private Multimap parseHeaders(Collection headers) { + Multimap result = LinkedHashMultimap.create(); + for (var line : headers) { + if (line.startsWith("'") && line.endsWith("'")) { + line = line.substring(1, line.length() - 1); + } + var header = Splitter.on(':').trimResults().omitEmptyStrings().splitToList(line); + if (header.size() != 2) { + throw new IllegalArgumentException("Invalid header: " + line); + } + result.put(new HeaderName(header.get(0)), header.get(1)); + } + return result; + } + + @NonNull private static String removeOption( + Multimap options, String name, String defaultValue) { + return Optional.of(options.removeAll(name)) + .map(Collection::iterator) + .filter(Iterator::hasNext) + .map(Iterator::next) + .orElse(defaultValue); + } + + private Multimap args(Map args) { + Multimap result = LinkedHashMultimap.create(); + var optionList = new ArrayList<>(args.values()); + for (int i = 0; i < optionList.size(); ) { + var key = optionList.get(i).toString(); + String value = null; + if (i + 1 < optionList.size()) { + var next = optionList.get(i + 1); + if (next.toString().startsWith("-")) { + i += 1; + } else { + value = next.toString(); + i += 2; + } + } else { + i += 1; + } + result.put(key, value == null ? "" : value); + } + return result; + } + + private record HeaderName(String value) implements CharSequence { + + @Override + public int length() { + return value.length(); + } + + @Override + public boolean equals(Object obj) { + return value.equalsIgnoreCase(obj.toString()); + } + + @Override + public int hashCode() { + return value.toLowerCase().hashCode(); + } + + @Override + public char charAt(int index) { + return value.charAt(index); + } + + @NonNull @Override + public CharSequence subSequence(int start, int end) { + return value.subSequence(start, end); + } + + @Override + @NonNull public String toString() { + return value; + } + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/RequestToHttp.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/RequestToHttp.java new file mode 100644 index 0000000000..00fa249cb9 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/RequestToHttp.java @@ -0,0 +1,46 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc.http; + +import java.util.Map; + +import io.jooby.SneakyThrows; +import io.jooby.internal.openapi.asciidoc.AsciiDocContext; +import io.jooby.internal.openapi.asciidoc.HttpRequest; +import io.jooby.internal.openapi.asciidoc.ToSnippet; + +/** + * [source,http,options="nowrap"] ---- ${method} ${path} HTTP/1.1 {% for h in headers -%} ${h.name}: + * ${h.value} {% endfor -%} ${requestBody -} ---- + * + * @param context + * @param request + */ +public record RequestToHttp(AsciiDocContext context, HttpRequest request) implements ToSnippet { + @Override + public String render(Map options) { + try { + var sb = new StringBuilder(); + sb.append("[source,http,options=\"nowrap\"]").append('\n'); + sb.append("----").append('\n'); + sb.append(request.getMethod()) + .append(" ") + .append(request.getPath()) + .append(" HTTP/1.1") + .append('\n'); + for (var header : request.getHeaders()) { + sb.append(header.name()).append(": ").append(header.value()).append('\n'); + } + var schema = request.getBody(); + if (schema != AsciiDocContext.EMPTY_SCHEMA) { + sb.append(context.toJson(context.schemaProperties(schema), false)).append('\n'); + } + return sb.append("----").toString(); + } catch (Exception x) { + throw SneakyThrows.propagate(x); + } + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/ResponseToHttp.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/ResponseToHttp.java new file mode 100644 index 0000000000..bc0392ecc8 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/ResponseToHttp.java @@ -0,0 +1,42 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc.http; + +import java.util.Map; + +import io.jooby.SneakyThrows; +import io.jooby.internal.openapi.asciidoc.*; + +/** + * [source,http,options="nowrap"] ---- HTTP/1.1 ${statusCode} ${statusReason} {% for h in headers + * -%} ${h.name}: ${h.value} {% endfor -%} ${responseBody -} ---- + */ +public record ResponseToHttp(AsciiDocContext context, HttpResponse response) implements ToSnippet { + @Override + public String render(Map options) { + try { + var sb = new StringBuilder(); + sb.append("[source,http,options=\"nowrap\"]").append('\n'); + sb.append("----").append('\n'); + sb.append("HTTP/1.1 ") + .append(response.getStatusCode().value()) + .append(" ") + .append(response.getStatusCode().reason()) + .append('\n'); + for (var header : response.getHeaders()) { + sb.append(header.name()).append(": ").append(header.value()).append('\n'); + } + var schema = response.getBody(); + if (schema != AsciiDocContext.EMPTY_SCHEMA) { + sb.append(context.getJson().writeValueAsString(context.schemaProperties(schema))) + .append('\n'); + } + return sb.append("----").toString(); + } catch (Exception x) { + throw SneakyThrows.propagate(x); + } + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/table/ToAsciiDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/table/ToAsciiDoc.java new file mode 100644 index 0000000000..291310d4a4 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/table/ToAsciiDoc.java @@ -0,0 +1,192 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc.table; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.google.common.base.CaseFormat; +import io.jooby.internal.openapi.EnumSchema; +import io.jooby.internal.openapi.asciidoc.AsciiDocContext; +import io.jooby.internal.openapi.asciidoc.HttpParamList; +import io.swagger.v3.oas.models.media.Schema; + +public record ToAsciiDoc( + AsciiDocContext context, + Map> properties, + List columns, + Map additionalProperties) { + private static final String ROOT = "___root__"; + + public static ToAsciiDoc schema(AsciiDocContext context, Schema schema) { + var columns = + schema instanceof EnumSchema + ? List.of("name", "description") + : List.of("name", "type", "description"); + var properties = new LinkedHashMap>(); + properties.put(ToAsciiDoc.ROOT, schema); + context.traverseSchema(schema, properties::put); + return new ToAsciiDoc(context, properties, columns, Map.of()); + } + + public static ToAsciiDoc parameters(AsciiDocContext context, HttpParamList parameters) { + var properties = new LinkedHashMap>(); + parameters.forEach(p -> properties.put(p.name(), p.schema())); + Map additionalProperties = new LinkedHashMap<>(); + parameters.forEach( + p -> { + additionalProperties.put(p.name() + ".in", p.in()); + additionalProperties.put(p.name() + ".description", p.get("description")); + }); + return new ToAsciiDoc(context, properties, parameters.includes(), additionalProperties); + } + + public String list(Map options) { + var isEnum = properties.get(ROOT) instanceof EnumSchema; + var sb = new StringBuilder(); + if (isEnum) { + var enumSchema = (EnumSchema) properties.remove(ROOT); + for (var enumName : enumSchema.getEnum()) { + sb.append(boldCell(enumName)).append("::").append('\n'); + var enumDesc = enumSchema.getDescription(enumName); + if (enumDesc != null) { + sb.append("* ").append(enumDesc); + } + sb.append('\n'); + } + } else { + properties.remove(ROOT); + properties.forEach( + (name, value) -> { + sb.append(name).append("::").append('\n'); + sb.append("* ") + .append("type") + .append(": ") + .append(monospaceCell(context.schemaType(value))) + .append('\n'); + var in = additionalProperties.get(name + ".in"); + if (in != null) { + sb.append("* ") + .append("in") + .append(": ") + .append(monospaceCell((String) in)) + .append('\n'); + } + var isEnumProperty = value instanceof EnumSchema; + var description = + isEnumProperty ? ((EnumSchema) value).getSummary() : value.getDescription(); + if (isEnumProperty) { + sb.append("* ").append("description").append(":"); + if (description != null) { + sb.append(" ").append(description); + } + sb.append('\n'); + var enumSchema = (EnumSchema) value; + for (var enumName : enumSchema.getEnum()) { + sb.append("** ").append(boldCell(enumName)); + var enumDesc = enumSchema.getDescription(enumName); + if (enumDesc != null) { + sb.append(": ").append(enumDesc); + } + sb.append('\n'); + } + } else { + if (description != null) { + sb.append("* ").append("description").append(": ").append(description).append('\n'); + } + } + }); + } + if (!sb.isEmpty()) { + sb.setLength(sb.length() - 1); + } + return sb.toString(); + } + + public String table(Map options) { + var isEnum = properties.get(ROOT) instanceof EnumSchema; + var colList = colList(columns); + var sb = new StringBuilder(); + sb.append("|===").append('\n'); + sb.append(header(columns)).append('\n'); + if (isEnum) { + var enumSchema = (EnumSchema) properties.remove(ROOT); + for (var enumName : enumSchema.getEnum()) { + sb.append("| ").append(boldCell(enumName)).append('\n'); + var enumDesc = enumSchema.getDescription(enumName); + if (enumDesc != null) { + sb.append("| ").append(enumDesc); + } + sb.append('\n'); + } + } else { + properties.remove(ROOT); + properties.forEach( + (name, value) -> { + var isPropertyEnum = value instanceof EnumSchema; + for (int i = 0; i < columns.size(); i++) { + var column = columns.get(i); + sb.append("|").append(row(column, name, value)).append("\n"); + if (isPropertyEnum && column.equals("description")) { + colList.set(i, colList.get(i) + "a"); + var enumSchema = (EnumSchema) value; + for (var enumValue : enumSchema.getEnum()) { + sb.append("\n* ").append(boldCell(enumValue)); + var enumDesc = enumSchema.getDescription(enumValue); + if (enumDesc != null) { + sb.append(": ").append(enumDesc); + } + } + sb.append('\n'); + } + } + sb.append('\n'); + }); + } + sb.append("|==="); + return colsToString(colList) + "\n" + sb; + } + + private String colsToString(List cols) { + return cols.stream().collect(Collectors.joining(",", "[cols=\"", "\", options=\"header\"]")); + } + + private List colList(List names) { + return names.stream() + .map(it -> it.equals("description") ? "3" : "1") + .collect(Collectors.toList()); + } + + private String header(List names) { + return names.stream() + .map(it -> CaseFormat.LOWER_CAMEL.to(CaseFormat.UPPER_CAMEL, it)) + .collect(Collectors.joining("|", "|", "")); + } + + private static String monospaceCell(String value) { + return value == null || value.trim().isEmpty() ? "" : "`+" + value + "+`"; + } + + private String boldCell(String value) { + return value == null || value.trim().isEmpty() ? "" : "*" + value + "*"; + } + + private String row(String col, String property, Schema schema) { + return switch (col) { + case "name" -> monospaceCell(property); + case "type" -> monospaceCell(context.schemaType(schema)); + case "in" -> monospaceCell((String) additionalProperties.get(property + "." + col)); + case "description" -> + (schema instanceof EnumSchema enumSchema + ? enumSchema.getSummary() + : (String) + additionalProperties.getOrDefault(property + "." + col, schema.getDescription())); + default -> (String) additionalProperties.get(property + "." + col); + }; + } +} diff --git a/modules/jooby-openapi/src/main/java/module-info.java b/modules/jooby-openapi/src/main/java/module-info.java index ed9e318c67..41341a1cc6 100644 --- a/modules/jooby-openapi/src/main/java/module-info.java +++ b/modules/jooby-openapi/src/main/java/module-info.java @@ -25,4 +25,6 @@ requires jakarta.data; requires io.swagger.annotations; requires org.jruby; + requires net.datafaker; + requires com.fasterxml.jackson.dataformat.yaml; } diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-form-parameters.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-form-parameters.snippet index 7c0260a0b4..3b1625d618 100644 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-form-parameters.snippet +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-form-parameters.snippet @@ -1,4 +1,4 @@ - [cols="1,1,3"] +[cols="1,1,3"] |=== |Parameter|Type|Description {% for parameter in parameters %} diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/error.peb b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/error.peb new file mode 100644 index 0000000000..7f609728a5 --- /dev/null +++ b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/error.peb @@ -0,0 +1,5 @@ +{ + "message": "{{ message }}", + "statusCode": "{{ statusCode }}", + "reason": "{{ statusCodeReason }}" +} diff --git a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/AutoDataFakerMapperTest.java b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/AutoDataFakerMapperTest.java new file mode 100644 index 0000000000..234630db51 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/AutoDataFakerMapperTest.java @@ -0,0 +1,157 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import java.util.function.Supplier; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import com.fasterxml.jackson.core.JsonProcessingException; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AutoDataFakerMapperTest { + + private AutoDataFakerMapper mapper; + + @BeforeAll + void setup() { + // Initialize with a custom override for testing + mapper = new AutoDataFakerMapper(); + mapper.synonyms(Map.of("sku_id", "ean13")); + } + + @Test + void testExactMatchByClassAndField() { + // Book.title exists in Datafaker + Supplier generator = mapper.getGenerator("Book", "title", "string", "fail"); + String result = generator.get(); + + assertThat(result).isNotEqualTo("fail").isNotEmpty(); + } + + @Test + void testAuthorSSN() { + Supplier generator = mapper.getGenerator("Author", "ssn", "string", "fail"); + String result = generator.get(); + + assertThat(result).as("SSN format validation").matches("^\\d{3}-\\d{2}-\\d{4}$"); + } + + @Test + void testGenericMatchByField() { + // "firstName" is generic, should map to Name.firstName + Supplier generator = mapper.getGenerator("User", "firstName", "string", "fail"); + String result = generator.get(); + + assertThat(result).isNotEqualTo("fail").doesNotContain("fail"); + } + + @Test + void testSynonymHandling() { + // "dob" is a synonym for "birthday" + Supplier generator = mapper.getGenerator("User", "dob", "date", "fail"); + String result = generator.get(); + + // Datafaker birthday usually returns a date string + assertThat(result).isNotEqualTo("fail"); + } + + @Test + void testSynonymNormalization() { + // "e-mail" -> normalize -> "email" -> maps to internet().emailAddress() + Supplier generator = mapper.getGenerator("User", "e-mail", "string", "fail"); + String result = generator.get(); + + assertThat(result).contains("@"); + } + + @Test + void testFieldTypeFallback() { + // "unknown_field_xyz" does not exist in Faker. + // It should fallback to "date-time" type logic. + Supplier generator = + mapper.getGenerator("Log", "unknown_field_xyz", "date-time", "fail"); + String result = generator.get(); + + assertThat(result).isNotEqualTo("fail"); + } + + @Test + void testFieldTypeUUID() { + Supplier generator = mapper.getGenerator("Table", "pk_id", "uuid", "fail"); + String result = generator.get(); + + assertThat(result).hasSize(36); // UUID length + } + + @Test + void testCustomUserSynonym() { + // We registered "sku_id" -> "ean13" in setup() + Supplier generator = mapper.getGenerator("Product", "sku_id", "string", "fail"); + String result = generator.get(); + + // EAN13 is numbers + assertThat(result).matches("\\d+"); + } + + @Test + void testFuzzyMatching() { + // "customer_email_address" -> contains "email" -> maps to email provider + Supplier generator = + mapper.getGenerator("Customer", "customer_email_address", "string", "fail"); + String result = generator.get(); + + assertThat(result).contains("@"); + } + + @Test + void testCompleteFallback() { + // Nothing matches this + Supplier generator = + mapper.getGenerator("Alien", "warp_speed", "unknown_type", "DEFAULT_VALUE"); + String result = generator.get(); + + assertThat(result).isEqualTo("DEFAULT_VALUE"); + } + + @Test + @SuppressWarnings("unchecked") + void testCapabilityMapStructure() throws JsonProcessingException { + Map capabilities = mapper.getCapabilityMap(); + + // 1. Check Top Level Keys + assertThat(capabilities).containsKeys("domains", "generics", "types", "synonyms"); + + // 2. Check Domains: "book" -> { "title": "faker.book().title()" } + Map> domains = + (Map>) capabilities.get("domains"); + assertThat(domains).containsKey("book"); + + Map bookFields = domains.get("book"); + assertThat(bookFields).containsKey("title"); + assertThat(bookFields.get("title")).contains("faker.book().title"); + + // 3. Check Generics: "title" -> "faker.book().title()" + Map generics = (Map) capabilities.get("generics"); + assertThat(generics).containsKey("firstname"); + assertThat(generics.get("firstname")).contains("faker.name().firstName"); + + // 4. Check Types: "uuid" -> description + Map types = (Map) capabilities.get("types"); + assertThat(types).containsKey("uuid"); + assertThat(types.get("uuid")).contains("faker.internet().uuid"); + + // 5. Check Synonyms + Map synonyms = (Map) capabilities.get("synonyms"); + assertThat(synonyms).containsEntry("surname", "lastname"); + assertThat(synonyms).containsEntry("skuid", "ean13"); + } +} diff --git a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/PebbleTemplateSupport.java b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/PebbleTemplateSupport.java new file mode 100644 index 0000000000..45367be64d --- /dev/null +++ b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/PebbleTemplateSupport.java @@ -0,0 +1,48 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.io.StringWriter; + +import org.assertj.core.api.AbstractStringAssert; + +import io.jooby.SneakyThrows; +import io.jooby.internal.openapi.OpenAPIExt; +import io.jooby.openapi.CurrentDir; +import io.swagger.v3.core.util.Json31; +import io.swagger.v3.core.util.Yaml31; + +public class PebbleTemplateSupport { + + private final AsciiDocContext context; + + public PebbleTemplateSupport(OpenAPIExt openapi) { + var baseDir = CurrentDir.testClass(getClass()); + var outDir = CurrentDir.basedir("target").resolve("asciidoc-out"); + var classLoader = getClass().getClassLoader(); + this.context = + new AsciiDocContext( + classLoader, Json31.mapper(), Yaml31.mapper(), openapi, baseDir, outDir); + } + + public AbstractStringAssert evaluateThat(String input) throws IOException { + return assertThat(evaluate(input)); + } + + public void evaluate(String input, SneakyThrows.Consumer consumer) throws IOException { + consumer.accept(evaluate(input)); + } + + public String evaluate(String input) throws IOException { + var template = context.getEngine().getLiteralTemplate(input); + var writer = new StringWriter(); + template.evaluate(writer); + return writer.toString(); + } +} diff --git a/modules/jooby-openapi/src/test/java/io/jooby/openapi/CurrentDir.java b/modules/jooby-openapi/src/test/java/io/jooby/openapi/CurrentDir.java index 06513e960a..ddc21c3cfe 100644 --- a/modules/jooby-openapi/src/test/java/io/jooby/openapi/CurrentDir.java +++ b/modules/jooby-openapi/src/test/java/io/jooby/openapi/CurrentDir.java @@ -26,6 +26,12 @@ public static Path basedir(List others) { return baseDir; } + public static Path testClass(Class clazz) { + var packageDir = clazz.getPackage().getName().split("\\."); + return basedir( + Stream.concat(Stream.of("src", "test", "resources"), Stream.of(packageDir)).toList()); + } + public static Path testClass(Class clazz, String file) { var packageDir = clazz.getPackage().getName().split("\\."); return basedir( diff --git a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIExtension.java b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIExtension.java index 3a2f3ef3ea..0668c56492 100644 --- a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIExtension.java +++ b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIExtension.java @@ -26,7 +26,9 @@ public boolean supportsParameter( ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { Parameter parameter = parameterContext.getParameter(); - return parameter.getType() == RouteIterator.class || parameter.getType() == OpenAPIResult.class; + return parameter.getType() == RouteIterator.class + || parameter.getType() == OpenAPIResult.class + || parameter.getType() == OpenAPIExt.class; } @Override @@ -66,6 +68,9 @@ public Object resolveParameter(ParameterContext parameterContext, ExtensionConte if (parameter.getType() == OpenAPIResult.class) { return result; } + if (parameter.getType() == OpenAPIExt.class) { + return result.getOpenAPI(); + } RouteIterator iterator = result.iterator(metadata.ignoreArguments()); getStore(context).put("iterator", iterator); return iterator; diff --git a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIResult.java b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIResult.java index 6004cb8921..a049459b2e 100644 --- a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIResult.java +++ b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIResult.java @@ -37,6 +37,10 @@ public RouteIterator iterator(boolean ignoreArgs) { return new RouteIterator(openAPI == null ? List.of() : openAPI.getOperations(), ignoreArgs); } + public OpenAPIExt getOpenAPI() { + return openAPI; + } + public String toYaml() { return toYaml(true); } diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java index c59b2423e3..7413b13bbb 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java @@ -19,27 +19,25 @@ public void shouldGenerateMvcDoc(OpenAPIResult result) { checkResult(result); } - @OpenAPITest(value = AppLibrary2.class) + @OpenAPITest(value = AppDemoLibrary.class) public void shouldGenerateGoodDoc(OpenAPIResult result) { assertThat(result.toYaml()) .isEqualToIgnoringNewLines( """ openapi: 3.0.1 info: - title: Library2 API - description: Library2 API description + title: DemoLibrary API + description: DemoLibrary API description version: "1.0" - tags: - - name: Library - description: Available library operations. paths: /library/books/{isbn}: summary: The Public Front Desk of the library. get: tags: - Library - summary: Get Book Details. - description: Call this to show the details page of a specific book. + summary: Get Specific Book Details + description: View the full information for a single specific book using its + unique ISBN. operationId: getBook parameters: - name: isbn @@ -62,13 +60,14 @@ public void shouldGenerateGoodDoc(OpenAPIResult result) { get: tags: - Library - summary: Search Books. - description: "A general search bar. Users type a word, and we find matches." + summary: Quick Search + description: "Find books by a partial title (e.g., searching \\"Harry\\" finds\\ + \\ \\"Harry Potter\\")." operationId: searchBooks parameters: - name: q in: query - description: The search term typed by the user. + description: The word or phrase to search for. schema: type: string responses: @@ -80,12 +79,18 @@ public void shouldGenerateGoodDoc(OpenAPIResult result) { type: array items: $ref: "#/components/schemas/Book" + x-badges: + - name: Beta + position: before + color: purple /library/books: summary: The Public Front Desk of the library. get: tags: - Library - summary: Browse Books (Paginated). + summary: Browse Books (Paginated) + description: "Look up a specific book title where there might be many editions\\ + \\ or copies, splitting the results into manageable pages." operationId: getBooksByTitle parameters: - name: title @@ -137,10 +142,9 @@ public void shouldGenerateGoodDoc(OpenAPIResult result) { $ref: "#/components/schemas/PageRequest" post: tags: - - Library - summary: Add New Book. - description: "Usage: Send a JSON packet to this URL to create a new book entry\\ - \\ in the system." + - Inventory + summary: Add New Book + description: Register a new book in the system. operationId: addBook requestBody: description: New book to add. diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/AppLibrary2.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/AppDemoLibrary.java similarity index 73% rename from modules/jooby-openapi/src/test/java/issues/i3729/api/AppLibrary2.java rename to modules/jooby-openapi/src/test/java/issues/i3729/api/AppDemoLibrary.java index 8a0983b7c8..16e2158d6f 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/AppLibrary2.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/AppDemoLibrary.java @@ -9,9 +9,9 @@ import io.jooby.Jooby; -public class AppLibrary2 extends Jooby { +public class AppDemoLibrary extends Jooby { { - mvc(toMvcExtension(LibraryApi2.class)); + mvc(toMvcExtension(LibraryDemoApi.class)); } } diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/BookType.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/BookType.java new file mode 100644 index 0000000000..5808ec8d6e --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/BookType.java @@ -0,0 +1,49 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3729.api; + +/** Defines the format and release schedule of the item. */ +public enum BookType { + /** + * A fictional narrative story. + * + *

Examples: "Pride and Prejudice", "Harry Potter", "Dune". These are creative works meant for + * entertainment or artistic expression. + */ + NOVEL, + + /** + * A written account of a real person's life. + * + *

Examples: "Steve Jobs" by Walter Isaacson, "The Diary of a Young Girl". These are + * non-fiction historical records of an individual. + */ + BIOGRAPHY, + + /** + * An educational book used for study. + * + *

Examples: "Calculus: Early Transcendentals", "Introduction to Java Programming". These are + * designed for students and are often used as reference material in academic courses. + */ + TEXTBOOK, + + /** + * A periodical publication intended for general readers. + * + *

Examples: Time, National Geographic, Vogue. These contain various articles, are published + * frequently (weekly/monthly), and often have a glossy format. + */ + MAGAZINE, + + /** + * A scholarly or professional publication. + * + *

Examples: The New England Journal of Medicine, Harvard Law Review. These focus on academic + * research or trade news and are written by experts for other experts. + */ + JOURNAL +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi2.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryDemoApi.java similarity index 72% rename from modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi2.java rename to modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryDemoApi.java index 3713a4b057..e4824f10e1 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi2.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryDemoApi.java @@ -13,29 +13,26 @@ import jakarta.data.page.PageRequest; import jakarta.inject.Inject; -/** - * The Public Front Desk of the library. - * - * @tag Library. Available library operations. - */ +/** The Public Front Desk of the library. */ @Path("/library") -public class LibraryApi2 { +public class LibraryDemoApi { private final LibraryRepo library; @Inject - public LibraryApi2(LibraryRepo library) { + public LibraryDemoApi(LibraryRepo library) { this.library = library; } /** - * Get Book Details. + * Get Specific Book Details * - *

Call this to show the details page of a specific book. + *

View the full information for a single specific book using its unique ISBN. * * @param isbn The unique ID from the URL (e.g., /books/978-3-16-148410-0) * @return The book data * @throws NotFoundException 404 error if it doesn't exist. + * @tag Library */ @GET @Path("/books/{isbn}") @@ -44,12 +41,14 @@ public Book getBook(@PathParam String isbn) { } /** - * Search Books. + * Quick Search * - *

A general search bar. Users type a word, and we find matches. + *

Find books by a partial title (e.g., searching "Harry" finds "Harry Potter"). * - * @param q The search term typed by the user. + * @param q The word or phrase to search for. * @return A list of books matching that term. + * @x-badges [{name:Beta, position:before, color:purple}] + * @tag Library */ @GET @Path("/search") @@ -60,12 +59,16 @@ public List searchBooks(@QueryParam String q) { } /** - * Browse Books (Paginated). + * Browse Books (Paginated) + * + *

Look up a specific book title where there might be many editions or copies, splitting the + * results into manageable pages. * * @param title The exact book title to filter by. * @param page Which page number to load (defaults to 1). * @param size How many books to show per page (defaults to 20). * @return A "Page" object containing the books and info like "Total Pages: 5". + * @tag Library */ @GET @Path("/books") @@ -80,10 +83,13 @@ public Page getBooksByTitle( } /** - * Add New Book. Usage: Send a JSON packet to this URL to create a new book entry in the system. + * Add New Book + * + *

Register a new book in the system. * * @param book New book to add. * @return A text message confirming success. + * @tag Inventory */ @POST @Path("/books") diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java b/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java new file mode 100644 index 0000000000..79e02d8b72 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java @@ -0,0 +1,692 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3820; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; + +import io.jooby.internal.openapi.OpenAPIExt; +import io.jooby.internal.openapi.asciidoc.PebbleTemplateSupport; +import io.jooby.openapi.OpenAPITest; +import io.swagger.v3.core.util.Json31; +import io.swagger.v3.core.util.Yaml31; +import issues.i3820.app.AppLib; + +public class PebbleSupportTest { + + @OpenAPITest(value = AppLib.class) + public void openApi(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(openapi); + templates.evaluate( + "{{openapi | json}}", + output -> { + Json31.mapper().readTree(output); + }); + + templates.evaluate( + "{{GET(\"/library/search\") | json}}", + output -> { + Json31.mapper().readTree(output); + }); + + templates.evaluate( + "{{openapi | yaml}}", + output -> { + Yaml31.mapper().readTree(output); + }); + + templates.evaluate( + "{{GET(\"/library/search\") | yaml}}", + output -> { + Yaml31.mapper().readTree(output); + }); + } + + @OpenAPITest(value = AppLib.class) + public void tags(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(openapi); + templates + .evaluateThat("{{tag(\"Library\").description }}") + .isEqualTo( + "Outlines the available actions in the Library System API. The system is designed to" + + " allow users to search for books, view details, and manage the library" + + " inventory."); + } + + @OpenAPITest(value = AppLib.class) + public void schema(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(openapi); + + templates + .evaluateThat("{{schema(\"Book\") | truncate | json}}") + .isEqualTo( + """ + { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "publisher" : { }, + "authors" : [ { } ] + }\ + """); + + templates + .evaluateThat("{{schema(\"Book\") | truncate | yaml}}") + .isEqualTo( + """ + isbn: string + title: string + publicationDate: date + text: string + type: string + publisher: {} + authors: + - {}\ + """); + + // example on same schema must generate same output + var output = templates.evaluate("{{schema(\"Book\") | example | json}}"); + assertEquals(output, templates.evaluate("{{schema(\"Book\") | example | json}}")); + + var yamlOutput = templates.evaluate("{{model(\"Book\") | example | yaml}}"); + assertEquals(yamlOutput, templates.evaluate("{{model(\"Book\") | example | yaml}}")); + + templates + .evaluateThat("{{schema(\"Book\") | json}}") + .isEqualToIgnoringNewLines( + """ + { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "publisher" : { + "id" : "int64", + "name" : "string" + }, + "authors" : [ { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "zip" : "string" + } + } ] + } + """); + + templates + .evaluateThat("{{schema(\"Address\") | table }}") + .isEqualToIgnoringNewLines( + """ + [cols="1,1,3", options="header"] + |=== + |Name|Type|Description + |`+street+` + |`+string+` + |The specific street address. Includes the house number, street name, and apartment number if applicable. Example: "123 Maple Avenue, Apt 4B". + + |`+city+` + |`+string+` + |The town, city, or municipality. Used for grouping authors by location or calculating shipping regions. + + |`+zip+` + |`+string+` + |The postal or zip code. Stored as text (String) rather than a number to support codes that start with zero (e.g., "02138") or contain letters (e.g., "K1A 0B1"). + + |===\ + """); + + templates + .evaluateThat("{{schema(\"Address\") | list }}") + .isEqualToIgnoringNewLines( + """ + street:: + * type: `+string+` + * description: The specific street address. Includes the house number, street name, and apartment number if applicable. Example: "123 Maple Avenue, Apt 4B". + city:: + * type: `+string+` + * description: The town, city, or municipality. Used for grouping authors by location or calculating shipping regions. + zip:: + * type: `+string+` + * description: The postal or zip code. Stored as text (String) rather than a number to support codes that start with zero (e.g., "02138") or contain letters (e.g., "K1A 0B1").\ + """); + + templates + .evaluateThat("{{schema(\"Book.type\") | list }}") + .isEqualToIgnoringNewLines( + """ + *NOVEL*:: + * A fictional narrative story. Examples: "Pride and Prejudice", "Harry Potter", "Dune". These are creative works meant for entertainment or artistic expression. + *BIOGRAPHY*:: + * A written account of a real person's life. Examples: "Steve Jobs" by Walter Isaacson, "The Diary of a Young Girl". These are non-fiction historical records of an individual. + *TEXTBOOK*:: + * An educational book used for study. Examples: "Calculus: Early Transcendentals", "Introduction to Java Programming". These are designed for students and are often used as reference material in academic courses. + *MAGAZINE*:: + * A periodical publication intended for general readers. Examples: Time, National Geographic, Vogue. These contain various articles, are published frequently (weekly/monthly), and often have a glossy format. + *JOURNAL*:: + * A scholarly or professional publication. Examples: The New England Journal of Medicine, Harvard Law Review. These focus on academic research or trade news and are written by experts for other experts.\ + """); + templates + .evaluateThat("{{schema(\"Book.type\") | table }}") + .isEqualToIgnoringNewLines( + """ + [cols="1,3", options="header"] + |=== + |Name|Description + | *NOVEL* + | A fictional narrative story. Examples: "Pride and Prejudice", "Harry Potter", "Dune". These are creative works meant for entertainment or artistic expression. + | *BIOGRAPHY* + | A written account of a real person's life. Examples: "Steve Jobs" by Walter Isaacson, "The Diary of a Young Girl". These are non-fiction historical records of an individual. + | *TEXTBOOK* + | An educational book used for study. Examples: "Calculus: Early Transcendentals", "Introduction to Java Programming". These are designed for students and are often used as reference material in academic courses. + | *MAGAZINE* + | A periodical publication intended for general readers. Examples: Time, National Geographic, Vogue. These contain various articles, are published frequently (weekly/monthly), and often have a glossy format. + | *JOURNAL* + | A scholarly or professional publication. Examples: The New England Journal of Medicine, Harvard Law Review. These focus on academic research or trade news and are written by experts for other experts. + |===\ + """); + } + + @OpenAPITest(value = AppLib.class) + public void curl(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(openapi); + + templates + .evaluateThat("{{POST(\"/library/authors\") | curl }}") + .isEqualTo( + """ + [source] + ---- + curl --data-urlencode "ssn=string"\\ + --data-urlencode "name=string"\\ + --data-urlencode "address.street=string"\\ + --data-urlencode "address.city=string"\\ + --data-urlencode "address.zip=string"\\ + -X POST '/library/authors' + ----\ + """); + + templates + .evaluateThat("{{POST(\"/library/books\") | request | curl }}") + .isEqualTo( + """ + [source] + ---- + curl -H 'Accept: application/json'\\ + -H 'Content-Type: application/json'\\ + -d '{"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","publisher":{"id":"int64","name":"string"},"authors":[{"ssn":"string","name":"string","address":{"street":"string","city":"string","zip":"string"}}]}'\\ + -X POST '/library/books' + ----\ + """); + + templates + .evaluateThat("{{GET(\"/library/books\") | request | curl }}") + .isEqualTo( + """ + [source] + ---- + curl -H 'Accept: application/json'\\ + -X GET '/library/books?title=string&page=int32&size=int32' + ----\ + """); + + templates + .evaluateThat("{{GET(\"/library/books/{isbn}\") | request | curl }}") + .isEqualTo( + """ + [source] + ---- + curl -X GET '/library/books/{isbn}' + ----\ + """); + + templates + .evaluateThat("{{GET(\"/library/books/{isbn}\") | request | curl(\"-i\") }}") + .isEqualTo( + """ + [source] + ---- + curl -i\\ + -X GET '/library/books/{isbn}' + ----\ + """); + + templates + .evaluateThat( + "{{GET(\"/library/books/{isbn}\") | request | curl(\"-i\", \"-X\", \"POST\") }}") + .isEqualTo( + """ + [source] + ---- + curl -i\\ + -X POST '/library/books/{isbn}' + ----\ + """); + + templates + .evaluateThat( + "{{GET(\"/library/books/{isbn}\") | request | curl(\"-i\", \"-X\", \"POST\") }}") + .isEqualTo( + """ + [source] + ---- + curl -i\\ + -X POST '/library/books/{isbn}' + ----\ + """); + + templates + .evaluateThat( + "{{GET(\"/library/books\") | request | curl(\"-H\", \"'Accept: application/xml'\") }}") + .isEqualTo( + """ + [source] + ---- + curl -H 'Accept: application/xml'\\ + -X GET '/library/books?title=string&page=int32&size=int32' + ----\ + """); + } + + @OpenAPITest(value = AppLib.class) + public void response(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(openapi); + + /* Error response code: */ + templates + .evaluateThat("{{GET(\"/library/books/{isbn}\") | response(code=404) | http }}") + .isEqualTo( + """ + [source,http,options="nowrap"] + ---- + HTTP/1.1 404 Not Found + ----\ + """); + + /* Override default response code: */ + templates + .evaluateThat("{{POST(\"/library/books\") | response(code=201) | http }}") + .isEqualTo( + """ + [source,http,options="nowrap"] + ---- + HTTP/1.1 201 Created + Content-Type: application/json + {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","publisher":{"id":"int64","name":"string"},"authors":[{"ssn":"string","name":"string","address":{"street":"string","city":"string","zip":"string"}}]} + ----\ + """); + + /* Default response */ + templates + .evaluateThat("{{POST(\"/library/books\") | response | http }}") + .isEqualTo( + """ + [source,http,options="nowrap"] + ---- + HTTP/1.1 200 Success + Content-Type: application/json + {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","publisher":{"id":"int64","name":"string"},"authors":[{"ssn":"string","name":"string","address":{"street":"string","city":"string","zip":"string"}}]} + ----\ + """); + + templates + .evaluateThat("{{POST(\"/library/books\") | response | list }}") + .isEqualTo( + """ + isbn:: + * type: `+string+` + * description: The unique "barcode" for this book (ISBN). We use this to identify exactly which book edition we are talking about. + title:: + * type: `+string+` + * description: The name printed on the cover. + publicationDate:: + * type: `+date+` + * description: When this book was released to the public. + text:: + * type: `+string+` + * description: The full story or content of the book. Since this can be very long, we store it in a special way (Large Object) to keep the database fast. + type:: + * type: `+string+` + * description: Defines the format and release schedule of the item. + ** *NOVEL*: A fictional narrative story. Examples: "Pride and Prejudice", "Harry Potter", "Dune". These are creative works meant for entertainment or artistic expression. + ** *BIOGRAPHY*: A written account of a real person's life. Examples: "Steve Jobs" by Walter Isaacson, "The Diary of a Young Girl". These are non-fiction historical records of an individual. + ** *TEXTBOOK*: An educational book used for study. Examples: "Calculus: Early Transcendentals", "Introduction to Java Programming". These are designed for students and are often used as reference material in academic courses. + ** *MAGAZINE*: A periodical publication intended for general readers. Examples: Time, National Geographic, Vogue. These contain various articles, are published frequently (weekly/monthly), and often have a glossy format. + ** *JOURNAL*: A scholarly or professional publication. Examples: The New England Journal of Medicine, Harvard Law Review. These focus on academic research or trade news and are written by experts for other experts. + publisher:: + * type: `+object+` + * description: A company that produces and sells books. + authors:: + * type: `+array+` + * description: The list of people who wrote this book.\ + """); + + templates + .evaluateThat("{{POST(\"/library/books\") | response | table }}") + .isEqualTo( + """ + [cols="1,1,3a", options="header"] + |=== + |Name|Type|Description + |`+isbn+` + |`+string+` + |The unique "barcode" for this book (ISBN). We use this to identify exactly which book edition we are talking about. + + |`+title+` + |`+string+` + |The name printed on the cover. + + |`+publicationDate+` + |`+date+` + |When this book was released to the public. + + |`+text+` + |`+string+` + |The full story or content of the book. Since this can be very long, we store it in a special way (Large Object) to keep the database fast. + + |`+type+` + |`+string+` + |Defines the format and release schedule of the item. + + * *NOVEL*: A fictional narrative story. Examples: "Pride and Prejudice", "Harry Potter", "Dune". These are creative works meant for entertainment or artistic expression. + * *BIOGRAPHY*: A written account of a real person's life. Examples: "Steve Jobs" by Walter Isaacson, "The Diary of a Young Girl". These are non-fiction historical records of an individual. + * *TEXTBOOK*: An educational book used for study. Examples: "Calculus: Early Transcendentals", "Introduction to Java Programming". These are designed for students and are often used as reference material in academic courses. + * *MAGAZINE*: A periodical publication intended for general readers. Examples: Time, National Geographic, Vogue. These contain various articles, are published frequently (weekly/monthly), and often have a glossy format. + * *JOURNAL*: A scholarly or professional publication. Examples: The New England Journal of Medicine, Harvard Law Review. These focus on academic research or trade news and are written by experts for other experts. + + |`+publisher+` + |`+object+` + |A company that produces and sells books. + + |`+authors+` + |`+array+` + |The list of people who wrote this book. + + |===\ + """); + } + + @OpenAPITest(value = AppLib.class) + public void request(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(openapi); + + templates + .evaluateThat("{{POST(\"/library/books\") | request | list }}") + .isEqualTo( + """ + isbn:: + * type: `+string+` + * in: `+body+` + * description: The unique "barcode" for this book (ISBN). We use this to identify exactly which book edition we are talking about. + title:: + * type: `+string+` + * in: `+body+` + * description: The name printed on the cover. + publicationDate:: + * type: `+date+` + * in: `+body+` + * description: When this book was released to the public. + text:: + * type: `+string+` + * in: `+body+` + * description: The full story or content of the book. Since this can be very long, we store it in a special way (Large Object) to keep the database fast. + type:: + * type: `+string+` + * in: `+body+` + * description: Defines the format and release schedule of the item. + ** *NOVEL*: A fictional narrative story. Examples: "Pride and Prejudice", "Harry Potter", "Dune". These are creative works meant for entertainment or artistic expression. + ** *BIOGRAPHY*: A written account of a real person's life. Examples: "Steve Jobs" by Walter Isaacson, "The Diary of a Young Girl". These are non-fiction historical records of an individual. + ** *TEXTBOOK*: An educational book used for study. Examples: "Calculus: Early Transcendentals", "Introduction to Java Programming". These are designed for students and are often used as reference material in academic courses. + ** *MAGAZINE*: A periodical publication intended for general readers. Examples: Time, National Geographic, Vogue. These contain various articles, are published frequently (weekly/monthly), and often have a glossy format. + ** *JOURNAL*: A scholarly or professional publication. Examples: The New England Journal of Medicine, Harvard Law Review. These focus on academic research or trade news and are written by experts for other experts. + publisher:: + * type: `+object+` + * in: `+body+` + * description: A company that produces and sells books. + authors:: + * type: `+array+` + * in: `+body+` + * description: The list of people who wrote this book.\ + """); + + templates + .evaluateThat("{{POST(\"/library/books\") | request | table }}") + .isEqualTo( + """ + [cols="1,1,1,3a", options="header"] + |=== + |Name|Type|In|Description + |`+isbn+` + |`+string+` + |`+body+` + |The unique "barcode" for this book (ISBN). We use this to identify exactly which book edition we are talking about. + + |`+title+` + |`+string+` + |`+body+` + |The name printed on the cover. + + |`+publicationDate+` + |`+date+` + |`+body+` + |When this book was released to the public. + + |`+text+` + |`+string+` + |`+body+` + |The full story or content of the book. Since this can be very long, we store it in a special way (Large Object) to keep the database fast. + + |`+type+` + |`+string+` + |`+body+` + |Defines the format and release schedule of the item. + + * *NOVEL*: A fictional narrative story. Examples: "Pride and Prejudice", "Harry Potter", "Dune". These are creative works meant for entertainment or artistic expression. + * *BIOGRAPHY*: A written account of a real person's life. Examples: "Steve Jobs" by Walter Isaacson, "The Diary of a Young Girl". These are non-fiction historical records of an individual. + * *TEXTBOOK*: An educational book used for study. Examples: "Calculus: Early Transcendentals", "Introduction to Java Programming". These are designed for students and are often used as reference material in academic courses. + * *MAGAZINE*: A periodical publication intended for general readers. Examples: Time, National Geographic, Vogue. These contain various articles, are published frequently (weekly/monthly), and often have a glossy format. + * *JOURNAL*: A scholarly or professional publication. Examples: The New England Journal of Medicine, Harvard Law Review. These focus on academic research or trade news and are written by experts for other experts. + + |`+publisher+` + |`+object+` + |`+body+` + |A company that produces and sells books. + + |`+authors+` + |`+array+` + |`+body+` + |The list of people who wrote this book. + + |===\ + """); + + templates + .evaluateThat("{{GET(\"/library/books\") | request | table }}") + .isEqualTo( + """ + [cols="1,1,1,3", options="header"] + |=== + |Name|Type|In|Description + |`+title+` + |`+string+` + |`+query+` + |The exact book title to filter by. + + |`+page+` + |`+int32+` + |`+query+` + |Which page number to load (defaults to 1). + + |`+size+` + |`+int32+` + |`+query+` + |How many books to show per page (defaults to 20). + + |===\ + """); + + templates + .evaluateThat("{{GET(\"/library/books\") | request | parameters('query') | table }}") + .isEqualTo( + """ + [cols="1,1,3", options="header"] + |=== + |Name|Type|Description + |`+title+` + |`+string+` + |The exact book title to filter by. + + |`+page+` + |`+int32+` + |Which page number to load (defaults to 1). + + |`+size+` + |`+int32+` + |How many books to show per page (defaults to 20). + + |===\ + """); + + templates + .evaluateThat("{{GET(\"/library/books\") | request | parameters('path') | table }}") + .isEqualTo( + """ + [cols="1,1,3", options="header"] + |=== + |Name|Type|Description + |===\ + """); + + templates + .evaluateThat("{{GET(\"/library/books\") | parameters('path') | table }}") + .isEqualTo( + """ + [cols="1,1,3", options="header"] + |=== + |Name|Type|Description + |===\ + """); + + templates + .evaluateThat("{{GET(\"/library/books\") | cookies | table }}") + .isEqualTo( + """ + [cols="1,3", options="header"] + |=== + |Name|Description + |===\ + """); + + templates + .evaluateThat("{{POST(\"/library/books\") | request(body=\"none\") | http }}") + .isEqualTo( + """ + [source,http,options="nowrap"] + ---- + POST /library/books HTTP/1.1 + Accept: application/json + Content-Type: application/json + {} + ----\ + """); + + // example on same schema must generate same output + templates + .evaluateThat("{{GET(\"/library/books\") | request }}") + .isEqualTo("GET /library/books"); + + // example on same schema must generate same output + templates + .evaluateThat("{{GET(\"/library/books\") | request | http }}") + .isEqualTo( + """ + [source,http,options="nowrap"] + ---- + GET /library/books HTTP/1.1 + Accept: application/json + ----\ + """); + + templates + .evaluateThat("{{POST(\"/library/books\") | request | http }}") + .isEqualTo( + """ + [source,http,options="nowrap"] + ---- + POST /library/books HTTP/1.1 + Accept: application/json + Content-Type: application/json + {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","publisher":{"id":"int64","name":"string"},"authors":[{"ssn":"string","name":"string","address":{"street":"string","city":"string","zip":"string"}}]} + ----\ + """); + + templates + .evaluateThat("{{POST(\"/library/books\") | request | headers | table }}") + .isEqualTo( + """ + [cols="1,3", options="header"] + |=== + |Name|Description + |`+Accept+` + | + + |`+Content-Type+` + | + + |===\ + """); + + templates + .evaluateThat("{{POST(\"/library/books\") | request | body | table }}") + .isEqualTo( + """ + [cols="1,1,3a", options="header"] + |=== + |Name|Type|Description + |`+isbn+` + |`+string+` + |The unique "barcode" for this book (ISBN). We use this to identify exactly which book edition we are talking about. + + |`+title+` + |`+string+` + |The name printed on the cover. + + |`+publicationDate+` + |`+date+` + |When this book was released to the public. + + |`+text+` + |`+string+` + |The full story or content of the book. Since this can be very long, we store it in a special way (Large Object) to keep the database fast. + + |`+type+` + |`+string+` + |Defines the format and release schedule of the item. + + * *NOVEL*: A fictional narrative story. Examples: "Pride and Prejudice", "Harry Potter", "Dune". These are creative works meant for entertainment or artistic expression. + * *BIOGRAPHY*: A written account of a real person's life. Examples: "Steve Jobs" by Walter Isaacson, "The Diary of a Young Girl". These are non-fiction historical records of an individual. + * *TEXTBOOK*: An educational book used for study. Examples: "Calculus: Early Transcendentals", "Introduction to Java Programming". These are designed for students and are often used as reference material in academic courses. + * *MAGAZINE*: A periodical publication intended for general readers. Examples: Time, National Geographic, Vogue. These contain various articles, are published frequently (weekly/monthly), and often have a glossy format. + * *JOURNAL*: A scholarly or professional publication. Examples: The New England Journal of Medicine, Harvard Law Review. These focus on academic research or trade news and are written by experts for other experts. + + |`+publisher+` + |`+object+` + |A company that produces and sells books. + + |`+authors+` + |`+array+` + |The list of people who wrote this book. + + |===\ + """); + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/app/AppLib.java b/modules/jooby-openapi/src/test/java/issues/i3820/app/AppLib.java new file mode 100644 index 0000000000..b725aae6e3 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3820/app/AppLib.java @@ -0,0 +1,34 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3820.app; + +import static io.jooby.openapi.MvcExtensionGenerator.toMvcExtension; + +import io.jooby.Jooby; + +/** + * Library API. + * + *

An imaginary, but delightful Library API for interacting with library services and + * information. Built with love by https://jooby.io. + * + * @version 1.0.0 + * @license.name Apache 2.0 + * @license.url http://www.apache.org/licenses/LICENSE-2.0.html + * @contact.name Jooby Demo + * @contact.url https://jooby.io + * @contact.email support@jooby.io + * @server.url https://library.jooby.io + * @x-logo.url https://redoredocly.github.io/redoc/museum-logo.png + * @tag Library. Outlines the available actions in the Library System API. The system is designed to + * allow users to search for books, view details, and manage the library inventory. + * @tag Inventory. Managing Inventory + */ +public class AppLib extends Jooby { + { + mvc(toMvcExtension(LibApi.class)); + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/app/LibApi.java b/modules/jooby-openapi/src/test/java/issues/i3820/app/LibApi.java new file mode 100644 index 0000000000..dd3711008d --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3820/app/LibApi.java @@ -0,0 +1,119 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3820.app; + +import java.util.List; + +import io.jooby.annotation.*; +import io.jooby.exception.NotFoundException; +import issues.i3820.model.Author; +import issues.i3820.model.Book; +import jakarta.data.page.Page; +import jakarta.data.page.PageRequest; +import jakarta.inject.Inject; + +/** The Public Front Desk of the library. */ +@Path("/library") +public class LibApi { + + private final Library library; + + @Inject + public LibApi(Library library) { + this.library = library; + } + + /** + * Get Specific Book Details + * + *

View the full information for a single specific book using its unique ISBN. + * + * @param isbn The unique ID from the URL (e.g., /books/978-3-16-148410-0) + * @return The book data + * @throws NotFoundException 404 error if it doesn't exist. + * @tag Library + */ + @GET + @Path("/books/{isbn}") + public Book getBook(@PathParam String isbn) { + return library.findBook(isbn).orElseThrow(() -> new NotFoundException(isbn)); + } + + /** + * Quick Search + * + *

Find books by a partial title (e.g., searching "Harry" finds "Harry Potter"). + * + * @param q The word or phrase to search for. + * @return A list of books matching that term. + * @x-badges [{name:Beta, position:before, color:purple}] + * @tag Library + */ + @GET + @Path("/search") + public List searchBooks(@QueryParam String q) { + var pattern = "%" + (q != null ? q : "") + "%"; + + return library.searchBooks(pattern); + } + + /** + * Browse Books (Paginated) + * + *

Look up a specific book title where there might be many editions or copies, splitting the + * results into manageable pages. + * + * @param title The exact book title to filter by. + * @param page Which page number to load (defaults to 1). + * @param size How many books to show per page (defaults to 20). + * @return A "Page" object containing the books and info like "Total Pages: 5". + * @tag Library + */ + @GET + @Path("/books") + @Produces("application/json") + public Page getBooksByTitle( + @QueryParam String title, @QueryParam int page, @QueryParam int size) { + // Ensure we have sensible defaults if the user sends nothing + int pageNum = page > 0 ? page : 1; + int pageSize = size > 0 ? size : 20; + + // Ask the database for just this specific slice of data + return library.findBooksByTitle(title, PageRequest.ofPage(pageNum).size(pageSize)); + } + + /** + * Add New Book + * + *

Register a new book in the system. + * + * @param book New book to add. + * @return A text message confirming success. + * @tag Inventory + */ + @POST + @Path("/books") + @Consumes("application/json") + @Produces("application/json") + public Book addBook(Book book) { + // Save it + return library.add(book); + } + + /** + * Add New Author + * + * @param author New author to add. + * @return Created author. + * @tag Inventory + */ + @POST + @Path("/authors") + public Author addAuthor(@FormParam Author author) { + // Save it + return author; + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/app/Library.java b/modules/jooby-openapi/src/test/java/issues/i3820/app/Library.java new file mode 100644 index 0000000000..3d4fa9ee52 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3820/app/Library.java @@ -0,0 +1,87 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3820.app; + +import java.util.List; +import java.util.Optional; + +import issues.i3820.model.*; +import jakarta.data.page.Page; +import jakarta.data.page.PageRequest; +import jakarta.data.repository.*; + +/** + * The "Librarian" of our system. + * + *

This interface handles all the work of finding, saving, and removing books and authors from + * the database. You don't need to write the code for this; the system builds it automatically based + * on these method names. + */ +public interface Library { + + // --- Finding Items --- + + /** + * Looks up a single book using its ISBN code. + * + * @param isbn The unique code to look for. + * @return An "Optional" box that contains the book if we found it, or is empty if we didn't. + */ + @Find + Optional findBook(String isbn); + + /** Looks up an author using their ID. */ + @Find + Optional findAuthor(String ssn); + + /** + * Finds books that match a specific title. + * + *

Because there might be thousands of results, this method splits them into "pages". You ask + * for "Page 1" or "Page 5", and it gives you just that chunk. + * + * @param title The exact title to look for. + * @param pageRequest Which page of results do you want? + * @return A page containing a list of books and total count info. + */ + @Find + Page findBooksByTitle(String title, PageRequest pageRequest); + + // --- Custom Searches --- + + /** + * Search for books that have a specific word in the title. + * + *

Example: If you search for "%Harry%", it finds "Harry Potter" and "Dirty Harry". It also + * sorts the results alphabetically by title. + */ + @Query("where title like :pattern order by title") + List searchBooks(String pattern); + + /** + * A custom report that just lists the titles of new books. Useful for creating quick lists + * without loading all the book details. + * + * @param minYear The oldest year we care about (e.g., 2023). + * @return Just the names of the books. + */ + @Query("select title from Book where extract(year from publicationDate) >= :minYear") + List findRecentBookTitles(int minYear); + + // --- Saving & Deleting --- + + /** Registers a new book in the system. */ + @Insert + Book add(Book book); + + /** Saves changes made to an author's details. */ + @Update + void update(Author author); + + /** Permanently removes a book from the library. */ + @Delete + void remove(Book book); +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/model/Book.java b/modules/jooby-openapi/src/test/java/issues/i3820/model/Book.java index 3093abc434..3d9a7c3038 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3820/model/Book.java +++ b/modules/jooby-openapi/src/test/java/issues/i3820/model/Book.java @@ -38,7 +38,7 @@ public class Book { private String text; /** Categorizes the item (e.g., is it a regular Book or a Magazine?). */ - private Type type; + private BookType type; /** * The company that published this book. @@ -51,52 +51,9 @@ public class Book { /** The list of people who wrote this book. */ private Set authors = new HashSet<>(); - /** Defines the format and release schedule of the item. */ - public enum Type { - /** - * A fictional narrative story. - * - *

Examples: "Pride and Prejudice", "Harry Potter", "Dune". These are creative works meant - * for entertainment or artistic expression. - */ - NOVEL, - - /** - * A written account of a real person's life. - * - *

Examples: "Steve Jobs" by Walter Isaacson, "The Diary of a Young Girl". These are - * non-fiction historical records of an individual. - */ - BIOGRAPHY, - - /** - * An educational book used for study. - * - *

Examples: "Calculus: Early Transcendentals", "Introduction to Java Programming". These are - * designed for students and are often used as reference material in academic courses. - */ - TEXTBOOK, - - /** - * A periodical publication intended for general readers. - * - *

Examples: Time, National Geographic, Vogue. These contain various articles, are published - * frequently (weekly/monthly), and often have a glossy format. - */ - MAGAZINE, - - /** - * A scholarly or professional publication. - * - *

Examples: The New England Journal of Medicine, Harvard Law Review. These focus on academic - * research or trade news and are written by experts for other experts. - */ - JOURNAL - } - public Book() {} - public Book(String isbn, String title, Type type) { + public Book(String isbn, String title, BookType type) { this.isbn = isbn; this.title = title; this.type = type; @@ -135,11 +92,11 @@ public void setText(String text) { this.text = text; } - public Type getType() { + public BookType getType() { return type; } - public void setType(Type type) { + public void setType(BookType type) { this.type = type; } diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/model/BookType.java b/modules/jooby-openapi/src/test/java/issues/i3820/model/BookType.java new file mode 100644 index 0000000000..c0dd6d6382 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3820/model/BookType.java @@ -0,0 +1,49 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3820.model; + +/** Defines the format and release schedule of the item. */ +public enum BookType { + /** + * A fictional narrative story. + * + *

Examples: "Pride and Prejudice", "Harry Potter", "Dune". These are creative works meant for + * entertainment or artistic expression. + */ + NOVEL, + + /** + * A written account of a real person's life. + * + *

Examples: "Steve Jobs" by Walter Isaacson, "The Diary of a Young Girl". These are + * non-fiction historical records of an individual. + */ + BIOGRAPHY, + + /** + * An educational book used for study. + * + *

Examples: "Calculus: Early Transcendentals", "Introduction to Java Programming". These are + * designed for students and are often used as reference material in academic courses. + */ + TEXTBOOK, + + /** + * A periodical publication intended for general readers. + * + *

Examples: Time, National Geographic, Vogue. These contain various articles, are published + * frequently (weekly/monthly), and often have a glossy format. + */ + MAGAZINE, + + /** + * A scholarly or professional publication. + * + *

Examples: The New England Journal of Medicine, Harvard Law Review. These focus on academic + * research or trade news and are written by experts for other experts. + */ + JOURNAL +} diff --git a/modules/jooby-openapi/src/test/resources/adoc/library.yml b/modules/jooby-openapi/src/test/resources/adoc/library.yml new file mode 100644 index 0000000000..beae502fd9 --- /dev/null +++ b/modules/jooby-openapi/src/test/resources/adoc/library.yml @@ -0,0 +1,262 @@ +openapi: 3.1.0 +info: + title: Library API. + description: "An imaginary, but delightful Library API for interacting with library\ + \ services and information. Built with love by https://jooby.io." + contact: + name: Jooby Demo + url: https://jooby.io + email: support@jooby.io + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + version: 1.0.0 + x-logo: + url: https://redoredocly.github.io/redoc/museum-logo.png +servers: + - url: https://library.jooby.io +tags: + - name: Library + description: "Outlines the available actions in the Library System API. The system\ + \ is designed to allow users to search for books, view details, and manage the\ + \ library inventory." + - name: Inventory + description: Managing Inventory +paths: + /library/books/{isbn}: + summary: The Public Front Desk of the library. + get: + tags: + - Library + summary: Get Specific Book Details + description: View the full information for a single specific book using its + unique ISBN. + operationId: getBook + parameters: + - name: isbn + in: path + description: "The unique ID from the URL (e.g., /books/978-3-16-148410-0)" + required: true + schema: + type: string + responses: + "200": + description: The book data + content: + application/json: + schema: + $ref: "#/components/schemas/Book" + "404": + description: "Not Found: error if it doesn't exist." + /library/search: + summary: The Public Front Desk of the library. + get: + tags: + - Library + summary: Quick Search + description: "Find books by a partial title (e.g., searching \"Harry\" finds\ + \ \"Harry Potter\")." + operationId: searchBooks + parameters: + - name: q + in: query + description: The word or phrase to search for. + schema: + type: string + responses: + "200": + description: A list of books matching that term. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Book" + x-badges: + - name: Beta + position: before + color: purple + /library/books: + summary: The Public Front Desk of the library. + get: + tags: + - Library + summary: Browse Books (Paginated) + description: "Look up a specific book title where there might be many editions\ + \ or copies, splitting the results into manageable pages." + operationId: getBooksByTitle + parameters: + - name: title + in: query + description: The exact book title to filter by. + schema: + type: string + - name: page + in: query + description: Which page number to load (defaults to 1). + required: true + schema: + type: integer + format: int32 + - name: size + in: query + description: How many books to show per page (defaults to 20). + required: true + schema: + type: integer + format: int32 + responses: + "200": + description: "A \"Page\" object containing the books and info like \"Total\ + \ Pages: 5\"." + content: + application/json: + schema: + type: object + properties: + content: + type: array + items: + $ref: "#/components/schemas/Book" + numberOfElements: + type: integer + format: int32 + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int64 + pageRequest: + $ref: "#/components/schemas/PageRequest" + nextPageRequest: + $ref: "#/components/schemas/PageRequest" + previousPageRequest: + $ref: "#/components/schemas/PageRequest" + post: + tags: + - Inventory + summary: Add New Book + description: Register a new book in the system. + operationId: addBook + requestBody: + description: New book to add. + content: + application/json: + schema: + $ref: "#/components/schemas/Book" + required: true + responses: + "200": + description: A text message confirming success. + content: + application/json: + schema: + $ref: "#/components/schemas/Book" +components: + schemas: + Address: + type: object + description: "A reusable way to store address details (Street, City, Zip). We\ + \ can reuse this on Authors, Publishers, or Users." + properties: + street: + type: string + description: "The specific street address. Includes the house number, street\ + \ name, and apartment number if applicable. Example: \"123 Maple Avenue,\ + \ Apt 4B\"." + city: + type: string + description: "The town, city, or municipality. Used for grouping authors\ + \ by location or calculating shipping regions." + zip: + type: string + description: "The postal or zip code. Stored as text (String) rather than\ + \ a number to support codes that start with zero (e.g., \"02138\") or\ + \ contain letters (e.g., \"K1A 0B1\")." + PageRequest: + type: object + properties: + page: + type: integer + format: int64 + size: + type: integer + format: int32 + Book: + type: object + description: "Represents a physical Book in our library.

This is the main\ + \ item visitors look for. It holds details like the title, the actual text\ + \ content, and who published it.

" + properties: + isbn: + type: string + description: The unique "barcode" for this book (ISBN). We use this to identify + exactly which book edition we are talking about. + title: + type: string + description: The name printed on the cover. + publicationDate: + type: string + format: date + description: When this book was released to the public. + text: + type: string + description: "The full story or content of the book. Since this can be\ + \ very long, we store it in a special way (Large Object) to keep the database\ + \ fast." + type: + type: string + description: |- + Categorizes the item (e.g., is it a regular Book or a Magazine?). + - NOVEL: A fictional narrative story. Examples: "Pride and Prejudice", "Harry Potter", "Dune". These are creative works meant for entertainment or artistic expression. + - BIOGRAPHY: A written account of a real person's life. Examples: "Steve Jobs" by Walter Isaacson, "The Diary of a Young Girl". These are non-fiction historical records of an individual. + - TEXTBOOK: An educational book used for study. Examples: "Calculus: Early Transcendentals", "Introduction to Java Programming". These are designed for students and are often used as reference material in academic courses. + - MAGAZINE: A periodical publication intended for general readers. Examples: Time, National Geographic, Vogue. These contain various articles, are published frequently (weekly/monthly), and often have a glossy format. + - JOURNAL: A scholarly or professional publication. Examples: The New England Journal of Medicine, Harvard Law Review. These focus on academic research or trade news and are written by experts for other experts. + enum: + - NOVEL + - BIOGRAPHY + - TEXTBOOK + - MAGAZINE + - JOURNAL + publisher: + $ref: "#/components/schemas/Publisher" + description: "The company that published this book. Performance Note: We\ + \ only load this information if you specifically ask for it (\"Lazy\"\ + ), which saves memory." + authors: + type: array + description: The list of people who wrote this book. + items: + $ref: "#/components/schemas/Author" + uniqueItems: true + Author: + type: object + description: A person who writes books. + properties: + ssn: + type: string + description: The author's unique government ID (SSN). + name: + type: string + description: The full name of the author. + address: + $ref: "#/components/schemas/Address" + description: "Where the author lives. This information is stored inside\ + \ the Author table, not a separate one." + Publisher: + type: object + description: A company that produces and sells books. + properties: + id: + type: integer + format: int64 + description: "The unique internal ID for this publisher. This is a number\ + \ generated automatically by the system. Users usually don't need to memorize\ + \ this, but it's used by the database to link books to their publishers." + name: + type: string + description: "The official business name of the publishing house. Example:\ + \ \"Penguin Random House\" or \"O'Reilly Media\"." + diff --git a/pom.xml b/pom.xml index e740af01c9..8d9097401c 100644 --- a/pom.xml +++ b/pom.xml @@ -60,7 +60,7 @@ 2.3.34 4.5.0 1.3.7 - 4.0.0 + 4.1.0 2.20.1 2.13.2 3.0.1 From 15c3547f545418e5ede9f1c78b1291da7a44f013 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sat, 13 Dec 2025 19:12:14 -0300 Subject: [PATCH 16/31] - implementation complete - redo entire pebble/template support - it is lot better now <3 --- jooby/src/main/java/io/jooby/StatusCode.java | 13 -- .../internal/openapi/AsciiDocGenerator.java | 151 --------------- .../jooby/internal/openapi/ParameterExt.java | 24 ++- .../jooby/internal/openapi/ParserContext.java | 4 +- .../openapi/asciidoc/AsciiDocContext.java | 155 +++++++++------ .../internal/openapi/asciidoc/Display.java | 63 +++++- .../openapi/asciidoc/HttpMessage.java | 4 +- .../internal/openapi/asciidoc/HttpParam.java | 30 --- .../openapi/asciidoc/HttpRequest.java | 183 ++++++++---------- .../openapi/asciidoc/HttpResponse.java | 22 ++- .../internal/openapi/asciidoc/Mutator.java | 43 ++-- ...{HttpParamList.java => ParameterList.java} | 15 +- .../openapi/asciidoc/http/RequestToHttp.java | 6 +- .../openapi/asciidoc/http/ResponseToHttp.java | 8 +- .../openapi/asciidoc/table/ToAsciiDoc.java | 40 ++-- .../io/jooby/openapi/OpenAPIGenerator.java | 16 +- .../src/main/java/module-info.java | 2 + .../asciidoc/PebbleTemplateSupport.java | 11 +- .../java/io/jooby/openapi/OpenAPIResult.java | 7 +- .../java/issues/i3729/api/ApiDocTest.java | 93 ++++++--- .../src/test/java/issues/i3820/Issue3820.java | 15 +- .../java/issues/i3820/PebbleSupportTest.java | 168 ++++++++++++++-- .../src/test/resources/adoc/library.adoc | 25 +-- .../test/resources/issues/i3820/schema.adoc | 2 +- 24 files changed, 603 insertions(+), 497 deletions(-) delete mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AsciiDocGenerator.java delete mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpParam.java rename modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/{HttpParamList.java => ParameterList.java} (58%) diff --git a/jooby/src/main/java/io/jooby/StatusCode.java b/jooby/src/main/java/io/jooby/StatusCode.java index e32ff6cf7e..0292bcf902 100644 --- a/jooby/src/main/java/io/jooby/StatusCode.java +++ b/jooby/src/main/java/io/jooby/StatusCode.java @@ -917,27 +917,14 @@ public final class StatusCode { private final String reason; - private final transient boolean unknown; - private StatusCode(final int value, final String reason) { this.value = value; this.reason = reason; - this.unknown = false; } private StatusCode(final int value) { this.value = value; this.reason = Integer.toString(value); - this.unknown = true; - } - - /** - * True for custom status code. - * - * @return True for custom status code. - */ - public boolean isUnknown() { - return unknown; } /** diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AsciiDocGenerator.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AsciiDocGenerator.java deleted file mode 100644 index 22e1d447d1..0000000000 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AsciiDocGenerator.java +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.openapi; - -import java.io.IOException; -import java.io.StringWriter; -import java.nio.file.Path; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -import org.asciidoctor.Asciidoctor; -import org.asciidoctor.Options; -import org.asciidoctor.SafeMode; - -import io.jooby.internal.openapi.asciidoc.Filters; -import io.jooby.internal.openapi.asciidoc.Functions; -import io.jooby.internal.openapi.asciidoc.SnippetResolver; -import io.pebbletemplates.pebble.PebbleEngine; -import io.pebbletemplates.pebble.attributes.AttributeResolver; -import io.pebbletemplates.pebble.extension.*; -import io.pebbletemplates.pebble.lexer.Syntax; -import io.pebbletemplates.pebble.operator.BinaryOperator; -import io.pebbletemplates.pebble.operator.UnaryOperator; -import io.pebbletemplates.pebble.tokenParser.TokenParser; -import io.swagger.v3.core.util.Json; -import io.swagger.v3.core.util.Json31; -import io.swagger.v3.core.util.Yaml; -import io.swagger.v3.core.util.Yaml31; -import io.swagger.v3.oas.models.SpecVersion; -import io.swagger.v3.oas.models.servers.Server; - -public class AsciiDocGenerator { - - public static String generate(OpenAPIExt openAPI, Path index) throws IOException { - var engine = newEngine(openAPI, index.getParent()); - var template = engine.getTemplate(index.toAbsolutePath().toString()); - var writer = new StringWriter(); - var context = new HashMap(); - template.evaluate(writer, context); - return writer.toString().trim(); - } - - public static void export(Path baseDir, Path input, Path outputDir) { - try (var asciidoctor = Asciidoctor.Factory.create()) { - - var options = - Options.builder() - .backend("html5") - .baseDir(baseDir.toFile()) - .toDir(outputDir.toFile()) - .mkDirs(true) - .safe(SafeMode.UNSAFE) - .build(); - - // Perform the conversion - asciidoctor.convertFile(input.toFile(), options); - } - } - - @SuppressWarnings("unchecked") - private static PebbleEngine newEngine(OpenAPIExt openapi, Path baseDir) { - var json = (openapi.getSpecVersion() == SpecVersion.V30 ? Json.mapper() : Json31.mapper()); - var yaml = (openapi.getSpecVersion() == SpecVersion.V30 ? Yaml.mapper() : Yaml31.mapper()); - var snippetResolver = new SnippetResolver(baseDir.resolve("snippet")); - var serverUrl = - Optional.ofNullable(openapi.getServers()) - .map(List::getFirst) - .map(Server::getUrl) - .orElse(""); - var openapiRoot = json.convertValue(openapi, Map.class); - openapiRoot.put("openapi", openapi); - openapiRoot.put( - "internal", - Map.of("resolver", snippetResolver, "serverUrl", serverUrl, "json", json, "yaml", yaml)); - var engine = newEngine(new OpenApiSupport(openapiRoot)); - snippetResolver.setEngine(engine); - return engine; - } - - private static PebbleEngine newEngine(OpenApiSupport extension) { - // 1. Define the custom syntax using a builder - return new PebbleEngine.Builder() - .extension(extension) - .autoEscaping(false) - .syntax( - new Syntax.Builder() - .setPrintOpenDelimiter("${") - .setPrintCloseDelimiter("}") - .setEnableNewLineTrimming(false) - .build()) - .build(); - } - - private static class OpenApiSupport implements Extension { - private final Map vars; - - public OpenApiSupport(Map vars) { - this.vars = vars; - } - - @Override - public Map getFilters() { - return Filters.allFilters(); - } - - @Override - public Map getTests() { - return Map.of(); - } - - @Override - public Map getFunctions() { - return Functions.fn(); - } - - @Override - public List getTokenParsers() { - return List.of(); - } - - @Override - public List getBinaryOperators() { - return List.of(); - } - - @Override - public List getUnaryOperators() { - return List.of(); - } - - @Override - public Map getGlobalVariables() { - return vars; - } - - @Override - public List getNodeVisitors() { - return List.of(); - } - - @Override - public List getAttributeResolver() { - return List.of(); - } - } -} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParameterExt.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParameterExt.java index 16c05e9a1e..b8782d79d7 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParameterExt.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParameterExt.java @@ -8,8 +8,12 @@ import java.util.Objects; import com.fasterxml.jackson.annotation.JsonIgnore; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.parameters.Parameter; -public class ParameterExt extends io.swagger.v3.oas.models.parameters.Parameter { +public class ParameterExt extends Parameter { @JsonIgnore private String javaType; @JsonIgnore private Object defaultValue; @@ -54,4 +58,22 @@ public void setRequired(Boolean required) { public String toString() { return javaType + " " + getName(); } + + public static Parameter header(@NonNull String name, @Nullable String value) { + return basic(name, "header", value); + } + + public static Parameter cookie(@NonNull String name, @Nullable String value) { + return basic(name, "cookie", value); + } + + public static Parameter basic(@NonNull String name, @NonNull String in, @Nullable String value) { + ParameterExt param = new ParameterExt(); + param.setName(name); + param.setIn(in); + param.setDefaultValue(value); + param.setSchema(new StringSchema()); + param.setJavaType(String.class.getName()); + return param; + } } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java index 671027a407..9acac6cd72 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java @@ -123,7 +123,7 @@ private ParserContext( } private void jacksonModules(ClassLoader classLoader, List mappers) { - /** Kotlin module? */ + /* Kotlin module? */ List modules = new ArrayList<>(2); try { var kotlinModuleClass = @@ -138,7 +138,7 @@ private void jacksonModules(ClassLoader classLoader, List mappers) | InvocationTargetException x) { // Sshhhhh } - /** Ignore some conflictive setter in Jooby API: */ + /* Ignore some conflictive setter in Jooby API: */ modules.add( new SimpleModule("jooby-openapi") { @Override diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java index 08a7308859..33588ec16b 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java @@ -7,12 +7,18 @@ import static java.util.Optional.ofNullable; +import java.io.IOException; +import java.io.StringWriter; import java.nio.file.Path; import java.util.*; import java.util.function.BiConsumer; import java.util.stream.Collectors; import java.util.stream.Stream; +import org.asciidoctor.Asciidoctor; +import org.asciidoctor.Options; +import org.asciidoctor.SafeMode; + import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; @@ -24,6 +30,10 @@ import io.pebbletemplates.pebble.extension.Filter; import io.pebbletemplates.pebble.extension.Function; import io.pebbletemplates.pebble.lexer.Syntax; +import io.pebbletemplates.pebble.loader.ClasspathLoader; +import io.pebbletemplates.pebble.loader.DelegatingLoader; +import io.pebbletemplates.pebble.loader.FileLoader; +import io.pebbletemplates.pebble.loader.Loader; import io.pebbletemplates.pebble.template.EvaluationContext; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.media.Schema; @@ -32,8 +42,6 @@ public class AsciiDocContext { public static final BiConsumer> NOOP = (name, schema) -> {}; public static final Schema EMPTY_SCHEMA = new Schema<>(); - private ClassLoader classLoader; - private ObjectMapper json; private ObjectMapper yamlOpenApi; @@ -44,29 +52,41 @@ public class AsciiDocContext { private OpenAPIExt openapi; - private Path baseDir; - - private Path outputDir; - private final AutoDataFakerMapper faker = new AutoDataFakerMapper(); private final Map, Map> examples = new HashMap<>(); - public AsciiDocContext( - ClassLoader classLoader, - ObjectMapper json, - ObjectMapper yaml, - OpenAPIExt openapi, - Path baseDir, - Path outputDir) { - this.classLoader = classLoader; + public AsciiDocContext(Path baseDir, ObjectMapper json, ObjectMapper yaml, OpenAPIExt openapi) { this.json = json; this.yamlOpenApi = yaml; this.yamlOutput = newYamlOutput(); - this.engine = createEngine(json, openapi, this); + this.engine = createEngine(baseDir, json, openapi, this); this.openapi = openapi; - this.baseDir = baseDir; - this.outputDir = outputDir; + } + + public String generate(Path index) throws IOException { + var template = engine.getTemplate(index.getFileName().toString()); + var writer = new StringWriter(); + var context = new HashMap(); + template.evaluate(writer, context); + return writer.toString().trim(); + } + + public void export(Path input, Path outputDir) { + try (var asciidoctor = Asciidoctor.Factory.create()) { + + var options = + Options.builder() + .backend("html5") + .baseDir(input.getParent().toFile()) + .toDir(outputDir.toFile()) + .mkDirs(true) + .safe(SafeMode.UNSAFE) + .build(); + + // Perform the conversion + asciidoctor.convertFile(input.toFile(), options); + } } private ObjectMapper newYamlOutput() { @@ -77,15 +97,25 @@ private ObjectMapper newYamlOutput() { } private static PebbleEngine createEngine( - ObjectMapper json, OpenAPI openapi, AsciiDocContext context) { + Path baseDir, ObjectMapper json, OpenAPI openapi, AsciiDocContext context) { + List> loaders = + List.of(new FileLoader(baseDir.toAbsolutePath().toString()), new ClasspathLoader()); return new PebbleEngine.Builder() .autoEscaping(false) + .loader(new DelegatingLoader(loaders)) .extension( new AbstractExtension() { @Override public Map getGlobalVariables() { Map openapiRoot = json.convertValue(openapi, Map.class); openapiRoot.put("openapi", openapi); + + // make in to work without literal + openapiRoot.put("query", "query"); + openapiRoot.put("path", "path"); + openapiRoot.put("header", "header"); + openapiRoot.put("cookie", "cookie"); + openapiRoot.put("_asciidocContext", context); return openapiRoot; } @@ -109,13 +139,18 @@ public Map getFilters() { public String schemaType(Schema schema) { var resolved = resolveSchema(schema); - return Optional.ofNullable(resolved.getFormat()).orElse(resolved.getType()); + return Optional.ofNullable(resolved.getFormat()).orElse(resolveType(resolved)); } - public Schema resolveSchema(Schema schema) { - if (schema == EMPTY_SCHEMA) { - return schema; + private String resolveType(Schema schema) { + var resolved = resolveSchema(schema); + if (resolved.getType() == null) { + return resolved.getTypes().iterator().next(); } + return resolved.getType(); + } + + public Schema resolveSchema(Schema schema) { if (schema.get$ref() != null) { return resolveSchemaInternal(schema.get$ref()) .orElseThrow(() -> new NoSuchElementException("Schema not found: " + schema.get$ref())); @@ -134,7 +169,7 @@ public Schema reduceSchema(Schema schema) { traverse( schema, (name, value) -> { - var type = value.getType(); + var type = resolveType(value); if ("object".equals(type)) { var object = new Schema<>(); object.setType(type); @@ -154,7 +189,7 @@ public Schema reduceSchema(Schema schema) { public Schema emptySchema(Schema schema) { var empty = new Schema<>(); - empty.setType(schema.getType()); + empty.setType(resolveType(schema)); empty.setName(schema.getName()); return empty; } @@ -164,6 +199,7 @@ public Map schemaExample(Schema schema) { schema, s -> traverse( + new HashSet<>(), s, (parent, property) -> { var enumItems = property.getEnum(); @@ -183,10 +219,6 @@ public void traverseSchema(Schema schema, BiConsumer> consu traverse(schema, consumer); } - public void traverseGraph(Schema schema, BiConsumer> consumer) { - traverse(schema, consumer, consumer); - } - private Map traverse(Schema schema, BiConsumer> consumer) { return traverse(schema, consumer, NOOP); } @@ -195,36 +227,46 @@ private Map traverse( Schema schema, BiConsumer> consumer, BiConsumer> inner) { - return traverse(schema, (parent, property) -> schemaType(property), consumer, inner); + return traverse( + new HashSet<>(), schema, (parent, property) -> schemaType(property), consumer, inner); } private Map traverse( + Set visited, Schema schema, SneakyThrows.Function2, Schema, String> valueMapper, BiConsumer> consumer, BiConsumer> inner) { + if (schema == EMPTY_SCHEMA) { + return Map.of(); + } var resolved = resolveSchema(schema); - var properties = resolved.getProperties(); - if (properties != null) { - Map result = new LinkedHashMap<>(); - properties.forEach( - (name, value) -> { - value = resolveSchema(value); - consumer.accept(name, value); - if (value.getType().equals("object")) { - result.put(name, traverse(value, valueMapper, inner, inner)); - } else if (value.getType().equals("array")) { - var array = - ofNullable(value.getItems()) - .map(items -> traverse(items, valueMapper, inner, inner)) - .map(List::of) - .orElse(List.of()); - result.put(name, array); - } else { - result.put(name, valueMapper.apply(resolved, value)); - } - }); - return result; + if (visited.add(resolved)) { + var properties = resolved.getProperties(); + if (properties != null) { + Map result = new LinkedHashMap<>(); + properties.forEach( + (name, value) -> { + var resolvedValue = resolveSchema(value); + var valueType = resolveType(resolvedValue); + consumer.accept(name, resolvedValue); + if ("object".equals(valueType)) { + result.put(name, traverse(visited, resolvedValue, valueMapper, inner, inner)); + } else if ("array".equals(valueType)) { + var array = + ofNullable(resolvedValue.getItems()) + .map( + items -> + traverse(visited, resolveSchema(items), valueMapper, inner, inner)) + .map(List::of) + .orElse(List.of()); + result.put(name, array); + } else { + result.put(name, valueMapper.apply(resolved, resolvedValue)); + } + }); + return result; + } } return Map.of(); } @@ -248,7 +290,6 @@ public Schema resolveSchema(String path) { } schema = inner; } - return schema; } @@ -267,18 +308,6 @@ public PebbleEngine getEngine() { return engine; } - public Path getBaseDir() { - return baseDir; - } - - public Path getOutputDir() { - return outputDir; - } - - public ClassLoader getClassLoader() { - return classLoader; - } - public String toJson(Object input, boolean pretty) { try { var writer = pretty ? json.writer().withDefaultPrettyPrinter() : json.writer(); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java index 4c0bff2977..8870875d43 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java @@ -33,7 +33,16 @@ public Object apply( throws PebbleException { var asciidoc = InternalContext.asciidoc(context); var pretty = args.getOrDefault("pretty", true) == Boolean.TRUE; - return new SafeString(asciidoc.toJson(toJson(asciidoc, input), pretty)); + return wrap( + asciidoc.toJson(toJson(asciidoc, input), pretty), + args.getOrDefault("wrap", Boolean.TRUE) == Boolean.TRUE, + "[source, json]\n----\n", + "\n----"); + } + + @Override + public List getArgumentNames() { + return List.of("wrap"); } }, yaml { @@ -46,7 +55,16 @@ public Object apply( int lineNumber) throws PebbleException { var asciidoc = InternalContext.asciidoc(context); - return new SafeString(asciidoc.toYaml(toJson(asciidoc, input))); + return wrap( + asciidoc.toYaml(toJson(asciidoc, input)), + args.getOrDefault("wrap", Boolean.TRUE) == Boolean.TRUE, + "[source, yaml]\n----\n", + "\n----"); + } + + @Override + public List getArgumentNames() { + return List.of("wrap"); } }, table { @@ -61,6 +79,11 @@ public Object apply( var asciidoc = InternalContext.asciidoc(context); return new SafeString(toAsciidoc(asciidoc, input).table(args)); } + + @Override + public List getArgumentNames() { + return List.of("columns"); + } }, list { @Override @@ -95,6 +118,36 @@ public Object apply( return curl.render(args); } }, + path { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + var asciidoc = InternalContext.asciidoc(context); + var request = + switch (input) { + case OperationExt op -> new HttpRequest(asciidoc, op, args); + case HttpRequest req -> req; + default -> throw new IllegalArgumentException("Can't render: " + input); + }; + var pathParams = new HashMap(); + request + .getParameters(List.of("path"), List.of()) + .forEach( + p -> { + pathParams.put( + p.getName(), args.getOrDefault(p.getName(), "{" + p.getName() + "}")); + }); + // QueryString + pathParams.keySet().forEach(args::remove); + var queryString = request.getQueryString(args); + return request.operation().getPath(pathParams) + queryString; + } + }, http { @Override public Object apply( @@ -123,7 +176,7 @@ protected ToAsciiDoc toAsciidoc(AsciiDocContext context, Object input) { case HttpRequest req -> ToAsciiDoc.parameters(context, req.getAllParameters()); case HttpResponse rsp -> ToAsciiDoc.schema(context, rsp.getBody()); case Schema schema -> ToAsciiDoc.schema(context, schema); - case HttpParamList paramList -> ToAsciiDoc.parameters(context, paramList); + case ParameterList paramList -> ToAsciiDoc.parameters(context, paramList); default -> throw new IllegalArgumentException("Can't render: " + input); }; } @@ -135,6 +188,10 @@ protected Object toJson(AsciiDocContext context, Object input) { }; } + protected SafeString wrap(String content, boolean wrap, String prefix, String suffix) { + return new SafeString(wrap ? prefix + content + suffix : content); + } + @Override public List getArgumentNames() { return List.of(); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpMessage.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpMessage.java index d67f4d953b..85a5a01cbc 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpMessage.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpMessage.java @@ -12,9 +12,9 @@ public interface HttpMessage { - HttpParamList getHeaders(); + ParameterList getHeaders(); - HttpParamList getCookies(); + ParameterList getCookies(); Schema getBody(); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpParam.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpParam.java deleted file mode 100644 index 7f0b98f726..0000000000 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpParam.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.openapi.asciidoc; - -import java.util.Objects; -import java.util.Optional; -import java.util.stream.Stream; - -import io.swagger.v3.oas.models.media.Schema; - -public record HttpParam( - String name, Schema schema, Object value, String in, String description) { - - public String get(String field) { - return switch (field) { - case "name" -> name; - case "value" -> value == null ? "" : value.toString(); - case "type" -> Optional.ofNullable(schema.getFormat()).orElse(schema.getType()); - case "in" -> in == null ? "" : in; - default -> - Stream.of(description, schema.getDescription()) - .filter(Objects::nonNull) - .findFirst() - .orElse(""); - }; - } -} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequest.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequest.java index 17fef061b7..5926d30d70 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequest.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequest.java @@ -11,22 +11,44 @@ import java.util.function.BiFunction; import java.util.function.Predicate; +import com.fasterxml.jackson.annotation.JsonIncludeProperties; import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.ListMultimap; import com.google.common.net.UrlEscapers; import edu.umd.cs.findbugs.annotations.NonNull; import io.jooby.Router; import io.jooby.internal.openapi.OperationExt; +import io.jooby.internal.openapi.ParameterExt; import io.swagger.v3.oas.models.media.Schema; -import io.swagger.v3.oas.models.media.StringSchema; import io.swagger.v3.oas.models.parameters.Parameter; +@JsonIncludeProperties({"path", "method"}) public record HttpRequest( AsciiDocContext context, OperationExt operation, Map options) implements HttpMessage { private static final Predicate NOOP = p -> true; + private List allParameters() { + var parameters = new ArrayList<>(getImplicitHeaders()); + parameters.addAll(Optional.ofNullable(operation.getParameters()).orElse(List.of())); + return parameters; + } + + private List getImplicitHeaders() { + var implicitHeaders = new ArrayList(); + operation + .getProduces() + .forEach(value -> implicitHeaders.add(ParameterExt.header("Accept", value))); + if (Set.of(Router.PATCH, Router.PUT, Router.POST, Router.DELETE) + .contains(operation.getMethod())) { + operation + .getConsumes() + .forEach(value -> implicitHeaders.add(ParameterExt.header("Content-Type", value))); + } + return implicitHeaders; + } + public String getMethod() { return operation.getMethod(); } @@ -44,72 +66,74 @@ public List getConsumes() { } @Override - public HttpParamList getHeaders() { - var requestHeaders = ArrayListMultimap.create(); - var parameters = Optional.ofNullable(operation.getParameters()).orElse(List.of()); - var headerParams = parameters.stream().filter(it -> "header".equals(it.getIn())).toList(); - operation - .getProduces() - .forEach( - value -> - requestHeaders.put( - "Accept", new HttpParam("Accept", new StringSchema(), value, "header", null))); - if (Set.of(Router.PATCH, Router.PUT, Router.POST, Router.DELETE) - .contains(operation.getMethod())) { - operation - .getConsumes() - .forEach( - value -> - requestHeaders.put( - "Content-Type", - new HttpParam("Content-Type", new StringSchema(), value, "header", null))); - } - headerParams.forEach( - it -> - requestHeaders.put( - it.getName(), - new HttpParam( - it.getName(), it.getSchema(), "{{" + it.getName() + "}}", "header", null))); - return new HttpParamList( - requestHeaders.entries().stream().map(Map.Entry::getValue).toList(), - HttpParamList.NAME_DESC); + public ParameterList getHeaders() { + return new ParameterList( + allParameters().stream().filter(inFilter("header")).toList(), ParameterList.NAME_DESC); } @Override - public HttpParamList getCookies() { - var parameters = Optional.ofNullable(operation.getParameters()).orElse(List.of()); - return new HttpParamList( - parameters.stream() - .filter(it -> "cookie".equals(it.getIn())) - .map( - it -> - new HttpParam( - it.getName(), - it.getSchema(), - "{{" + it.getName() + "}}", - "cookie", - it.getDescription())) - .toList(), - HttpParamList.NAME_DESC); + public ParameterList getCookies() { + return new ParameterList( + allParameters().stream().filter(inFilter("cookie")).toList(), ParameterList.NAME_DESC); + } + + public ParameterList getQuery() { + return new ParameterList( + allParameters().stream().filter(inFilter("query")).toList(), ParameterList.NAME_TYPE_DESC); } - public HttpParamList getParameters() { - return getParameterList(NOOP, Map.of(), HttpParamList.PARAM); + public ParameterList getParameters() { + return getParameterList(NOOP, ParameterList.PARAM); } - public HttpParamList getQueryParameters() { - return getQueryParameters(Map.of()); + public ParameterList getParameters(List in, List includes) { + var show = + in.isEmpty() || in.contains("*") + ? ParameterList.PARAM + : (in.size() == 1 && in.contains("cookie") || in.contains("header")) + ? ParameterList.NAME_DESC + : ParameterList.NAME_TYPE_DESC; + return getParameterList(toFilter(in, includes), show); + } + + private Predicate toFilter(List in, List includes) { + Predicate inFilter; + if (in.isEmpty()) { + inFilter = NOOP; + } else { + inFilter = null; + for (var type : in) { + var itFilter = inFilter(type); + if (inFilter == null) { + inFilter = itFilter; + } else { + inFilter = inFilter.or(itFilter); + } + } + } + Predicate paramFilter = NOOP; + if (!includes.isEmpty()) { + paramFilter = p -> includes.contains(p.getName()); + } + return inFilter.and(paramFilter); } public String getQueryString() { + return getQueryString(Map.of()); + } + + public String getQueryString(Map filter) { var sb = new StringBuilder("?"); - for (var param : getParameters(inFilter("query"), Map.of())) { + for (var param : getParameters(List.of("query"), filter.keySet().stream().toList())) { encode( param.getName(), param.getSchema(), (schema, e) -> - Map.entry(e.getKey(), UrlEscapers.urlFragmentEscaper().escape(e.getValue())), + Map.entry( + e.getKey(), + UrlEscapers.urlFragmentEscaper() + .escape(filter.getOrDefault(e.getKey(), e.getValue()).toString())), (name, value) -> sb.append(name).append("=").append(value).append("&")); } if (sb.length() > 1) { @@ -119,10 +143,6 @@ public String getQueryString() { return ""; } - private HttpParamList getQueryParameters(Map paramValues) { - return getParameterList(inFilter("query"), paramValues, HttpParamList.NAME_TYPE_DESC); - } - @SuppressWarnings("unchecked") private Schema getBody(List contentType) { var body = @@ -130,6 +150,7 @@ private Schema getBody(List contentType) { .map(it -> toSchema(it.getContent(), contentType)) .map(context::resolveSchema) .orElse(AsciiDocContext.EMPTY_SCHEMA); + return selectBody(body, options.getOrDefault("body", "full").toString()); } @@ -137,10 +158,6 @@ public Schema getForm() { return getBody(List.of("application/x-www-form-urlencoded)", "multipart/form-data")); } - public ListMultimap getFormUrlEncoded() { - return formUrlEncoded((schema, field) -> field); - } - @NonNull public ListMultimap formUrlEncoded( BiFunction, Map.Entry, Map.Entry> formatter) { var output = ArrayListMultimap.create(); @@ -203,12 +220,8 @@ public Schema getBody() { return getBody(List.of()); } - public HttpParamList getPathParameters() { - return getParameterList(inFilter("path"), Map.of(), HttpParamList.NAME_TYPE_DESC); - } - - public HttpParamList getAllParameters() { - var parameters = new ArrayList<>(getParameters(NOOP, Map.of())); + public ParameterList getAllParameters() { + var parameters = allParameters(); var body = getForm(); var bodyType = "form"; if (body == AsciiDocContext.EMPTY_SCHEMA) { @@ -228,45 +241,21 @@ public HttpParamList getAllParameters() { parameters.add(p); }); } - return toParameterList(parameters, Map.of(), HttpParamList.PARAM); + return new ParameterList(parameters, ParameterList.PARAM); } - private HttpParamList getParameterList( - Predicate predicate, Map paramValues, List includes) { - return toParameterList(getParameters(predicate, paramValues), paramValues, includes); + private ParameterList getParameterList(Predicate predicate, List includes) { + return new ParameterList(getParameters(predicate), includes); } - private HttpParamList toParameterList( - List parameters, Map paramValues, List includes) { - return new HttpParamList( - parameters.stream() - .map( - it -> - new HttpParam( - it.getName(), - context.resolveSchema(it.getSchema()), - paramValues.get(it.getName()), - it.getIn(), - it.getDescription())) - .toList(), - includes); - } - - private List getParameters( - Predicate predicate, Map paramValues) { - var parameters = Optional.ofNullable(operation.getParameters()).orElse(List.of()); - return parameters.stream().filter(predicate.and(paramValueFilter(paramValues))).toList(); - } - - private static Predicate paramValueFilter(Map paramValues) { - if (paramValues == null || paramValues.isEmpty()) { - return NOOP; - } - return p -> paramValues.containsKey(p.getName()); + private List getParameters(Predicate predicate) { + return predicate == NOOP + ? allParameters() + : allParameters().stream().filter(predicate).toList(); } private static Predicate inFilter(String in) { - return p -> in.equals(p.getIn()); + return p -> "*".equals(in) || in.equals(p.getIn()); } @NonNull @Override diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpResponse.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpResponse.java index 619dbb2bd8..0dbf6dbf4c 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpResponse.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpResponse.java @@ -9,12 +9,15 @@ import java.util.Map; import java.util.Optional; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import edu.umd.cs.findbugs.annotations.NonNull; import io.jooby.StatusCode; import io.jooby.internal.openapi.OperationExt; +import io.jooby.internal.openapi.ParameterExt; import io.jooby.internal.openapi.ResponseExt; import io.swagger.v3.oas.models.media.Schema; -import io.swagger.v3.oas.models.media.StringSchema; +@JsonIgnoreProperties({"context", "operation", "options"}) public record HttpResponse( AsciiDocContext context, OperationExt operation, @@ -22,17 +25,17 @@ public record HttpResponse( Map options) implements HttpMessage { @Override - public HttpParamList getHeaders() { - return new HttpParamList( + public ParameterList getHeaders() { + return new ParameterList( operation.getProduces().stream() - .map(value -> new HttpParam("Content-Type", new StringSchema(), value, "header", null)) + .map(value -> ParameterExt.header("Content-Type", value)) .toList(), - HttpParamList.NAME_DESC); + ParameterList.NAME_DESC); } @Override - public HttpParamList getCookies() { - return new HttpParamList(List.of(), HttpParamList.NAME_DESC); + public ParameterList getCookies() { + return new ParameterList(List.of(), ParameterList.NAME_DESC); } @Override @@ -71,4 +74,9 @@ private Schema getBody(ResponseExt response) { .map(context::resolveSchema) .orElse(AsciiDocContext.EMPTY_SCHEMA); } + + @NonNull @Override + public String toString() { + return operation.getMethod() + " " + operation.getPath(); + } } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Mutator.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Mutator.java index 54071d08b8..de89810225 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Mutator.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Mutator.java @@ -82,7 +82,7 @@ public Object apply( args); } }, - headers { + parameters { @Override public Object apply( Object input, @@ -91,37 +91,22 @@ public Object apply( EvaluationContext context, int lineNumber) throws PebbleException { - return toHttpMessage(context, input, args).getHeaders(); + var in = normalizeList(args.getOrDefault("in", "*")); + var includes = normalizeList(args.getOrDefault("includes", List.of())); + return toHttpRequest(context, input, args).getParameters(in, includes); } - }, - cookies { - @Override - public Object apply( - Object input, - Map args, - PebbleTemplate self, - EvaluationContext context, - int lineNumber) - throws PebbleException { - return toHttpMessage(context, input, args).getCookies(); + + @SuppressWarnings({"rawtypes", "unchecked"}) + private List normalizeList(Object value) { + if (value instanceof List valueList) { + return valueList; + } + return value == null ? List.of() : List.of(value.toString()); } - }, - parameters { + @Override - public Object apply( - Object input, - Map args, - PebbleTemplate self, - EvaluationContext context, - int lineNumber) - throws PebbleException { - if (args.containsKey("query") || args.containsValue("query")) { - return toHttpRequest(context, input, args).getQueryParameters(); - } else if (args.containsKey("path") || args.containsValue("path")) { - return toHttpRequest(context, input, args).getPathParameters(); - } else { - return toHttpRequest(context, input, args).getParameters(); - } + public List getArgumentNames() { + return List.of("in", "includes"); } }, body { diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpParamList.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/ParameterList.java similarity index 58% rename from modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpParamList.java rename to modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/ParameterList.java index 61401f187e..8410697540 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpParamList.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/ParameterList.java @@ -7,21 +7,30 @@ import java.util.Iterator; import java.util.List; +import java.util.stream.Collectors; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import edu.umd.cs.findbugs.annotations.NonNull; +import io.swagger.v3.oas.models.parameters.Parameter; -public record HttpParamList(List parameters, List includes) - implements Iterable { +@JsonIgnoreProperties({"includes"}) +public record ParameterList(List parameters, List includes) + implements Iterable { public static final List NAME_DESC = List.of("name", "description"); public static final List NAME_TYPE_DESC = List.of("name", "type", "description"); public static final List PARAM = List.of("name", "type", "in", "description"); @NonNull @Override - public Iterator iterator() { + public Iterator iterator() { return parameters.iterator(); } public boolean isEmpty() { return parameters.isEmpty(); } + + @NonNull @Override + public String toString() { + return parameters.stream().map(Parameter::getName).collect(Collectors.joining(", ")); + } } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/RequestToHttp.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/RequestToHttp.java index 00fa249cb9..b30f1381a6 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/RequestToHttp.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/RequestToHttp.java @@ -8,6 +8,7 @@ import java.util.Map; import io.jooby.SneakyThrows; +import io.jooby.internal.openapi.ParameterExt; import io.jooby.internal.openapi.asciidoc.AsciiDocContext; import io.jooby.internal.openapi.asciidoc.HttpRequest; import io.jooby.internal.openapi.asciidoc.ToSnippet; @@ -32,7 +33,10 @@ public String render(Map options) { .append(" HTTP/1.1") .append('\n'); for (var header : request.getHeaders()) { - sb.append(header.name()).append(": ").append(header.value()).append('\n'); + sb.append(header.getName()) + .append(": ") + .append(((ParameterExt) header).getDefaultValue()) + .append('\n'); } var schema = request.getBody(); if (schema != AsciiDocContext.EMPTY_SCHEMA) { diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/ResponseToHttp.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/ResponseToHttp.java index bc0392ecc8..69b404f0c5 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/ResponseToHttp.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/ResponseToHttp.java @@ -8,12 +8,9 @@ import java.util.Map; import io.jooby.SneakyThrows; +import io.jooby.internal.openapi.ParameterExt; import io.jooby.internal.openapi.asciidoc.*; -/** - * [source,http,options="nowrap"] ---- HTTP/1.1 ${statusCode} ${statusReason} {% for h in headers - * -%} ${h.name}: ${h.value} {% endfor -%} ${responseBody -} ---- - */ public record ResponseToHttp(AsciiDocContext context, HttpResponse response) implements ToSnippet { @Override public String render(Map options) { @@ -27,7 +24,8 @@ public String render(Map options) { .append(response.getStatusCode().reason()) .append('\n'); for (var header : response.getHeaders()) { - sb.append(header.name()).append(": ").append(header.value()).append('\n'); + var value = ((ParameterExt) header).getDefaultValue(); + sb.append(header.getName()).append(": ").append(value).append('\n'); } var schema = response.getBody(); if (schema != AsciiDocContext.EMPTY_SCHEMA) { diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/table/ToAsciiDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/table/ToAsciiDoc.java index 291310d4a4..a03fe74b07 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/table/ToAsciiDoc.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/table/ToAsciiDoc.java @@ -13,7 +13,7 @@ import com.google.common.base.CaseFormat; import io.jooby.internal.openapi.EnumSchema; import io.jooby.internal.openapi.asciidoc.AsciiDocContext; -import io.jooby.internal.openapi.asciidoc.HttpParamList; +import io.jooby.internal.openapi.asciidoc.ParameterList; import io.swagger.v3.oas.models.media.Schema; public record ToAsciiDoc( @@ -34,14 +34,14 @@ public static ToAsciiDoc schema(AsciiDocContext context, Schema schema) { return new ToAsciiDoc(context, properties, columns, Map.of()); } - public static ToAsciiDoc parameters(AsciiDocContext context, HttpParamList parameters) { + public static ToAsciiDoc parameters(AsciiDocContext context, ParameterList parameters) { var properties = new LinkedHashMap>(); - parameters.forEach(p -> properties.put(p.name(), p.schema())); + parameters.forEach(p -> properties.put(p.getName(), p.getSchema())); Map additionalProperties = new LinkedHashMap<>(); parameters.forEach( p -> { - additionalProperties.put(p.name() + ".in", p.in()); - additionalProperties.put(p.name() + ".description", p.get("description")); + additionalProperties.put(p.getName() + ".in", p.getIn()); + additionalProperties.put(p.getName() + ".description", p.getDescription()); }); return new ToAsciiDoc(context, properties, parameters.includes(), additionalProperties); } @@ -108,8 +108,10 @@ public String list(Map options) { return sb.toString(); } + @SuppressWarnings({"unchecked"}) public String table(Map options) { var isEnum = properties.get(ROOT) instanceof EnumSchema; + var columns = (List) options.getOrDefault("columns", this.columns); var colList = colList(columns); var sb = new StringBuilder(); sb.append("|===").append('\n'); @@ -176,17 +178,23 @@ private String boldCell(String value) { return value == null || value.trim().isEmpty() ? "" : "*" + value + "*"; } + private String nullSafe(String value) { + return value == null || value.trim().isEmpty() ? "" : value; + } + private String row(String col, String property, Schema schema) { - return switch (col) { - case "name" -> monospaceCell(property); - case "type" -> monospaceCell(context.schemaType(schema)); - case "in" -> monospaceCell((String) additionalProperties.get(property + "." + col)); - case "description" -> - (schema instanceof EnumSchema enumSchema - ? enumSchema.getSummary() - : (String) - additionalProperties.getOrDefault(property + "." + col, schema.getDescription())); - default -> (String) additionalProperties.get(property + "." + col); - }; + return nullSafe( + switch (col) { + case "name" -> monospaceCell(property); + case "type" -> monospaceCell(context.schemaType(schema)); + case "in" -> monospaceCell((String) additionalProperties.get(property + "." + col)); + case "description" -> + (schema instanceof EnumSchema enumSchema + ? enumSchema.getSummary() + : (String) + additionalProperties.getOrDefault( + property + "." + col, schema.getDescription())); + default -> throw new IllegalArgumentException("Unknown property: " + col); + }); } } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java index cbcf1963ca..b73fe20638 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java @@ -21,6 +21,7 @@ import io.jooby.Router; import io.jooby.SneakyThrows; import io.jooby.internal.openapi.*; +import io.jooby.internal.openapi.asciidoc.AsciiDocContext; import io.jooby.internal.openapi.javadoc.JavaDocParser; import io.swagger.v3.core.util.*; import io.swagger.v3.oas.models.OpenAPI; @@ -89,13 +90,14 @@ public List write( } var outputDir = (Path) options.get("outputDir"); var outputList = new ArrayList(); + var context = tool.createAsciidoc(files.getFirst().getParent(), (OpenAPIExt) result); for (var file : files) { var opts = new HashMap<>(options); opts.put("adoc", file); var content = toString(tool, result, opts); var output = outputDir.resolve(file.getFileName()); Files.write(output, List.of(content)); - AsciiDocGenerator.export(file.getParent(), output, outputDir); + context.export(output, outputDir); outputList.add(output); } return outputList; @@ -162,6 +164,8 @@ public List write( private SpecVersion specVersion = SpecVersion.V30; + private AsciiDocContext asciidoc; + /** Default constructor. */ public OpenAPIGenerator() {} @@ -387,7 +391,7 @@ private void defaults(String classname, String contextPath, OpenAPIExt openapi) if (file == null) { throw new IllegalArgumentException("'adoc' file is required: " + options); } - return AsciiDocGenerator.generate((OpenAPIExt) openAPI, file); + return createAsciidoc(file.getParent(), (OpenAPIExt) openAPI).generate(file); } catch (IOException x) { throw SneakyThrows.propagate(x); } @@ -474,7 +478,7 @@ public Path getBasedir() { } /** - * Set output directory used by {@link #export(OpenAPI, Format)} operation. + * Set output directory used by {@link #export(OpenAPI, Format, Map)} operation. * *

Defaults to {@link #getBasedir()}. * @@ -521,7 +525,7 @@ public void setExcludes(@Nullable String excludes) { } /** - * Set output directory used by {@link #export(OpenAPI, Format)}. + * Set output directory used by {@link #export(OpenAPI, Format, Map)}. * * @param outputDir Output directory. */ @@ -557,6 +561,10 @@ public void setSpecVersion(String version) { } } + protected AsciiDocContext createAsciidoc(Path basedir, OpenAPIExt openapi) { + return new AsciiDocContext(basedir, jsonMapper(), yamlMapper(), openapi); + } + private String appname(String classname) { String name = classname; int i = name.lastIndexOf('.'); diff --git a/modules/jooby-openapi/src/main/java/module-info.java b/modules/jooby-openapi/src/main/java/module-info.java index 41341a1cc6..0606a6a0ed 100644 --- a/modules/jooby-openapi/src/main/java/module-info.java +++ b/modules/jooby-openapi/src/main/java/module-info.java @@ -27,4 +27,6 @@ requires org.jruby; requires net.datafaker; requires com.fasterxml.jackson.dataformat.yaml; + requires io.swagger.models; + requires com.fasterxml.jackson.annotation; } diff --git a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/PebbleTemplateSupport.java b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/PebbleTemplateSupport.java index 45367be64d..9355840288 100644 --- a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/PebbleTemplateSupport.java +++ b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/PebbleTemplateSupport.java @@ -9,12 +9,12 @@ import java.io.IOException; import java.io.StringWriter; +import java.nio.file.Path; import org.assertj.core.api.AbstractStringAssert; import io.jooby.SneakyThrows; import io.jooby.internal.openapi.OpenAPIExt; -import io.jooby.openapi.CurrentDir; import io.swagger.v3.core.util.Json31; import io.swagger.v3.core.util.Yaml31; @@ -22,13 +22,8 @@ public class PebbleTemplateSupport { private final AsciiDocContext context; - public PebbleTemplateSupport(OpenAPIExt openapi) { - var baseDir = CurrentDir.testClass(getClass()); - var outDir = CurrentDir.basedir("target").resolve("asciidoc-out"); - var classLoader = getClass().getClassLoader(); - this.context = - new AsciiDocContext( - classLoader, Json31.mapper(), Yaml31.mapper(), openapi, baseDir, outDir); + public PebbleTemplateSupport(Path basedir, OpenAPIExt openapi) { + this.context = new AsciiDocContext(basedir, Json31.mapper(), Yaml31.mapper(), openapi); } public AbstractStringAssert evaluateThat(String input) throws IOException { diff --git a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIResult.java b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIResult.java index a049459b2e..c11fb966d4 100644 --- a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIResult.java +++ b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIResult.java @@ -11,8 +11,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.jooby.SneakyThrows; -import io.jooby.internal.openapi.AsciiDocGenerator; import io.jooby.internal.openapi.OpenAPIExt; +import io.jooby.internal.openapi.asciidoc.AsciiDocContext; import io.swagger.v3.core.util.Json; import io.swagger.v3.core.util.Yaml; import io.swagger.v3.parser.OpenAPIV3Parser; @@ -112,11 +112,12 @@ public String toAsciiDoc(Path index, boolean validate) { } throw new IllegalStateException( "Invalid OpenAPI specification:\n\t- " - + result.getMessages().stream().collect(Collectors.joining("\n\t- ")).trim() + + String.join("\n\t- ", result.getMessages()).trim() + "\n\n" + json); } - return AsciiDocGenerator.generate(openAPI, index); + var asciiDoc = new AsciiDocContext(index.getParent(), this.json, this.yaml, openAPI); + return asciiDoc.generate(index); } catch (Exception x) { throw SneakyThrows.propagate(x); } diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java index 7413b13bbb..380065b552 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java @@ -276,9 +276,12 @@ public void shouldGenerateAdoc(OpenAPIResult result) { ==== Request Fields - [cols="1,1,3"] + [cols="1,1,3", options="header"] |=== - |Parameter|Type|Description + |Name|Type|Description + |`+title+` + |`+string+` + |Book's title. |`+author+` |`+string+` @@ -288,10 +291,6 @@ public void shouldGenerateAdoc(OpenAPIResult result) { |`+array+` |Book's isbn. Optional. - |`+title+` - |`+string+` - |Book's title. - |=== === Find a book by ISBN @@ -300,11 +299,11 @@ public void shouldGenerateAdoc(OpenAPIResult result) { ---- curl -i\\ -H 'Accept: application/json'\\ - -X GET 'https://api.fake-museum-example.com/v1/api/library/{isbn}' + -X GET '/api/library/{isbn}' ---- - .A matching book. - [source,json] + .GET /api/library/{isbn} + [source, json] ---- { "isbn" : "string", @@ -312,37 +311,74 @@ public void shouldGenerateAdoc(OpenAPIResult result) { "publicationDate" : "date", "text" : "string", "type" : "string", - "authors" : [ ], + "authors" : [ { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "state" : "string", + "country" : "string" + }, + "books" : [ { } ] + } ], "image" : "binary" } ---- - .Bad Request: For bad ISBN code. - [source,json] + .GET /api/library/{isbn} + [source, json] ---- { - "message" : "...", - "statusCode" : 400, - "reason" : "Bad Request" + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "authors" : [ { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "state" : "string", + "country" : "string" + }, + "books" : [ { } ] + } ], + "image" : "binary" } ---- - .Not Found: If a book doesn't exist. - [source,json] + .GET /api/library/{isbn} + [source, json] ---- { - "message" : "...", - "statusCode" : 404, - "reason" : "Not Found" + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "authors" : [ { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "state" : "string", + "country" : "string" + }, + "books" : [ { } ] + } ], + "image" : "binary" } ---- ==== Response Fields - [cols="1,1,3"] + [cols="1,1,3a", options="header"] |=== - |Path|Type|Description - + |Name|Type|Description |`+isbn+` |`+string+` |Book ISBN. Method. @@ -361,19 +397,20 @@ public void shouldGenerateAdoc(OpenAPIResult result) { |`+type+` |`+string+` - |Book type. - - Fiction: Fiction books are based on imaginary characters and events, while non-fiction books are based o n real people and events. - - NonFiction: Non-fiction genres include biography, autobiography, history, self-help, and true crime. + |Books can be broadly categorized into fiction and non-fiction. + + * *Fiction*: Fiction books are based on imaginary characters and events, while non-fiction books are based o n real people and events. + * *NonFiction*: Non-fiction genres include biography, autobiography, history, self-help, and true crime. |`+authors+` - |`+[]+` + |`+array+` | |`+image+` |`+binary+` | - |=== + |===\ """); } diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/Issue3820.java b/modules/jooby-openapi/src/test/java/issues/i3820/Issue3820.java index af01426a44..bdebb90c98 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3820/Issue3820.java +++ b/modules/jooby-openapi/src/test/java/issues/i3820/Issue3820.java @@ -17,8 +17,6 @@ public void shouldGenerateRequestBodySchema(OpenAPIResult result) { assertThat(result.toAsciiDoc(CurrentDir.testClass(getClass(), "schema.adoc"))) .isEqualToIgnoringNewLines( """ - [source,json] - ---- { "isbn" : "string", "title" : "string", @@ -29,9 +27,16 @@ public void shouldGenerateRequestBodySchema(OpenAPIResult result) { "id" : "int64", "name" : "string" }, - "authors" : [ ] - } - ---- + "authors" : [ { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "zip" : "string" + } + } ] + }\ """); } } diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java b/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java index 79e02d8b72..b1bb08d56d 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java +++ b/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java @@ -11,24 +11,86 @@ import io.jooby.internal.openapi.OpenAPIExt; import io.jooby.internal.openapi.asciidoc.PebbleTemplateSupport; +import io.jooby.openapi.CurrentDir; import io.jooby.openapi.OpenAPITest; import io.swagger.v3.core.util.Json31; import io.swagger.v3.core.util.Yaml31; +import io.swagger.v3.oas.models.SpecVersion; +import issues.i3729.api.AppLibrary; import issues.i3820.app.AppLib; public class PebbleSupportTest { + @OpenAPITest(value = AppLibrary.class) + public void bodyBug(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + templates + .evaluateThat("{{ GET(\"/api/library/{isbn}\") | response | body | json(false) }}") + .isEqualToIgnoringNewLines( + """ + { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "authors" : [ { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "state" : "string", + "country" : "string" + }, + "books" : [ { } ] + } ], + "image" : "binary" + }\ + """); + } + + @OpenAPITest(value = AppLib.class, version = SpecVersion.V31) + public void shouldSupportJsonSchemaInV31(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + templates + .evaluateThat("{{ POST(\"/library/books\") | request | body | json(false) }}") + .isEqualToIgnoringNewLines( + """ + { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "publisher" : { + "id" : "int64", + "name" : "string" + }, + "authors" : [ { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "zip" : "string" + } + } ] + }\ + """); + } + @OpenAPITest(value = AppLib.class) public void openApi(OpenAPIExt openapi) throws IOException { - var templates = new PebbleTemplateSupport(openapi); + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); templates.evaluate( - "{{openapi | json}}", + "{{openapi | json(wrap=false) }}", output -> { Json31.mapper().readTree(output); }); templates.evaluate( - "{{GET(\"/library/search\") | json}}", + "{{GET(\"/library/search\") | json(false)}}", output -> { Json31.mapper().readTree(output); }); @@ -48,7 +110,7 @@ public void openApi(OpenAPIExt openapi) throws IOException { @OpenAPITest(value = AppLib.class) public void tags(OpenAPIExt openapi) throws IOException { - var templates = new PebbleTemplateSupport(openapi); + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); templates .evaluateThat("{{tag(\"Library\").description }}") .isEqualTo( @@ -59,12 +121,14 @@ public void tags(OpenAPIExt openapi) throws IOException { @OpenAPITest(value = AppLib.class) public void schema(OpenAPIExt openapi) throws IOException { - var templates = new PebbleTemplateSupport(openapi); + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); templates .evaluateThat("{{schema(\"Book\") | truncate | json}}") .isEqualTo( """ + [source, json] + ---- { "isbn" : "string", "title" : "string", @@ -73,11 +137,12 @@ public void schema(OpenAPIExt openapi) throws IOException { "type" : "string", "publisher" : { }, "authors" : [ { } ] - }\ + } + ----\ """); templates - .evaluateThat("{{schema(\"Book\") | truncate | yaml}}") + .evaluateThat("{{schema(\"Book\") | truncate | yaml(false) }}") .isEqualTo( """ isbn: string @@ -98,7 +163,7 @@ public void schema(OpenAPIExt openapi) throws IOException { assertEquals(yamlOutput, templates.evaluate("{{model(\"Book\") | example | yaml}}")); templates - .evaluateThat("{{schema(\"Book\") | json}}") + .evaluateThat("{{schema(\"Book\") | json(false) }}") .isEqualToIgnoringNewLines( """ { @@ -198,7 +263,7 @@ public void schema(OpenAPIExt openapi) throws IOException { @OpenAPITest(value = AppLib.class) public void curl(OpenAPIExt openapi) throws IOException { - var templates = new PebbleTemplateSupport(openapi); + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); templates .evaluateThat("{{POST(\"/library/authors\") | curl }}") @@ -299,7 +364,7 @@ public void curl(OpenAPIExt openapi) throws IOException { @OpenAPITest(value = AppLib.class) public void response(OpenAPIExt openapi) throws IOException { - var templates = new PebbleTemplateSupport(openapi); + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); /* Error response code: */ templates @@ -417,12 +482,42 @@ public void response(OpenAPIExt openapi) throws IOException { @OpenAPITest(value = AppLib.class) public void request(OpenAPIExt openapi) throws IOException { - var templates = new PebbleTemplateSupport(openapi); + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + + templates + .evaluateThat("{{GET(\"/library/books\") | request | path }}") + .isEqualTo("/library/books?title=string&page=int32&size=int32"); + + templates + .evaluateThat("{{GET(\"/library/books\") | request | path(title=\"...\") }}") + .isEqualTo("/library/books?title=..."); + + templates + .evaluateThat("{{GET(\"/library/books\") | request | path(title=\"...\", page=1) }}") + .isEqualTo("/library/books?title=...&page=1"); + + templates + .evaluateThat("{{GET(\"/library/books\") | request | path(title=\"word space\", page=1) }}") + .isEqualTo("/library/books?title=word%20space&page=1"); + + templates + .evaluateThat("{{GET(\"/library/books/{isbn}\") | request | path }}") + .isEqualTo("/library/books/{isbn}"); + + templates + .evaluateThat("{{GET(\"/library/books/{isbn}\") | request | path(isbn=123) }}") + .isEqualTo("/library/books/123"); templates .evaluateThat("{{POST(\"/library/books\") | request | list }}") .isEqualTo( """ + Accept:: + * type: `+string+` + * in: `+header+` + Content-Type:: + * type: `+string+` + * in: `+header+` isbn:: * type: `+string+` * in: `+body+` @@ -465,6 +560,16 @@ public void request(OpenAPIExt openapi) throws IOException { [cols="1,1,1,3a", options="header"] |=== |Name|Type|In|Description + |`+Accept+` + |`+string+` + |`+header+` + | + + |`+Content-Type+` + |`+string+` + |`+header+` + | + |`+isbn+` |`+string+` |`+body+` @@ -516,6 +621,11 @@ public void request(OpenAPIExt openapi) throws IOException { [cols="1,1,1,3", options="header"] |=== |Name|Type|In|Description + |`+Accept+` + |`+string+` + |`+header+` + | + |`+title+` |`+string+` |`+query+` @@ -535,7 +645,7 @@ public void request(OpenAPIExt openapi) throws IOException { """); templates - .evaluateThat("{{GET(\"/library/books\") | request | parameters('query') | table }}") + .evaluateThat("{{GET(\"/library/books\") | request | parameters(query) | table }}") .isEqualTo( """ [cols="1,1,3", options="header"] @@ -556,6 +666,21 @@ public void request(OpenAPIExt openapi) throws IOException { |===\ """); + templates + .evaluateThat( + "{{GET(\"/library/books\") | request | parameters(query, ['title']) | table }}") + .isEqualTo( + """ + [cols="1,1,3", options="header"] + |=== + |Name|Type|Description + |`+title+` + |`+string+` + |The exact book title to filter by. + + |===\ + """); + templates .evaluateThat("{{GET(\"/library/books\") | request | parameters('path') | table }}") .isEqualTo( @@ -577,7 +702,7 @@ public void request(OpenAPIExt openapi) throws IOException { """); templates - .evaluateThat("{{GET(\"/library/books\") | cookies | table }}") + .evaluateThat("{{GET(\"/library/books\") | parameters(cookie) | table }}") .isEqualTo( """ [cols="1,3", options="header"] @@ -630,7 +755,7 @@ public void request(OpenAPIExt openapi) throws IOException { """); templates - .evaluateThat("{{POST(\"/library/books\") | request | headers | table }}") + .evaluateThat("{{POST(\"/library/books\") | request | parameters(header) | table }}") .isEqualTo( """ [cols="1,3", options="header"] @@ -645,6 +770,21 @@ public void request(OpenAPIExt openapi) throws IOException { |===\ """); + templates + .evaluateThat( + "{{POST(\"/library/books\") | request | parameters(header) | table(['name']) }}") + .isEqualTo( + """ + [cols="1", options="header"] + |=== + |Name + |`+Accept+` + + |`+Content-Type+` + + |===\ + """); + templates .evaluateThat("{{POST(\"/library/books\") | request | body | table }}") .isEqualTo( diff --git a/modules/jooby-openapi/src/test/resources/adoc/library.adoc b/modules/jooby-openapi/src/test/resources/adoc/library.adoc index 89928533ca..3b3e10873c 100644 --- a/modules/jooby-openapi/src/test/resources/adoc/library.adoc +++ b/modules/jooby-openapi/src/test/resources/adoc/library.adoc @@ -1,4 +1,4 @@ -= ${info.title} += {{ info.title }} Jooby Doc; :doctype: book :icons: font @@ -9,35 +9,38 @@ Jooby Doc; == Introduction -${info.description} +{{ info.description }} == Support -Write your questions at ${info.contact.email} +Write your questions at {{ info.contact.email }} [[overview_operations]] == Operations === List Books {% set listBooks = operation("GET", "/api/library") %} -${listBooks.summary} ${listBooks.description} +{{ listBooks.summary }} {{ listBooks.description }} -Example: `${ listBooks | path("title", "...") }` +Example: `{{ listBooks | path(title="...") }}` ==== Request Fields -${ listBooks | queryParameters } +{{ listBooks | parameters(query) | table }} === Find a book by ISBN {% set bookByISBN = operation("GET", "/api/library/{isbn}") %} -${ bookByISBN | curl("-i") } +{{ bookByISBN | curl("-i") }} -${ bookByISBN | statusCode(200) } +.{{ bookByISBN | response(200) }} +{{ bookByISBN | response(200) | body | json }} -${ bookByISBN | statusCode(400) } +.{{ bookByISBN | response(400) }} +{{ bookByISBN | response(400) | body | json }} -${ bookByISBN | statusCode(404) } +.{{ bookByISBN | response(404) }} +{{ bookByISBN | response(404) | body | json }} ==== Response Fields -${ bookByISBN | responseFields } +{{ bookByISBN | response | table }} diff --git a/modules/jooby-openapi/src/test/resources/issues/i3820/schema.adoc b/modules/jooby-openapi/src/test/resources/issues/i3820/schema.adoc index 654694c382..db02dca25f 100644 --- a/modules/jooby-openapi/src/test/resources/issues/i3820/schema.adoc +++ b/modules/jooby-openapi/src/test/resources/issues/i3820/schema.adoc @@ -1 +1 @@ -${operation("POST", "/library/books").requestBody | schema} +{{operation("POST", "/library/books") | request | body | json }} From 97d4e29297bd750dda7951e8ab2a090fa78ffbc8 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sat, 13 Dec 2025 19:16:37 -0300 Subject: [PATCH 17/31] - cleanup, remove all implementation - cleanup classpath - ref #3820 --- modules/jooby-openapi/pom.xml | 5 - .../internal/openapi/asciidoc/Display.java | 14 +- .../internal/openapi/asciidoc/Filters.java | 129 --- .../internal/openapi/asciidoc/Functions.java | 188 ---- .../openapi/asciidoc/InternalContext.java | 45 - .../internal/openapi/asciidoc/Lookup.java | 6 +- .../internal/openapi/asciidoc/Mutator.java | 12 +- .../openapi/asciidoc/OperationFilters.java | 738 -------------- .../internal/openapi/asciidoc/SchemaData.java | 65 -- .../openapi/asciidoc/SnippetResolver.java | 57 -- .../internal/openapi/asciidoc/FilterTest.java | 917 ------------------ 11 files changed, 16 insertions(+), 2160 deletions(-) delete mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Filters.java delete mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java delete mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/InternalContext.java delete mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java delete mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/SchemaData.java delete mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/SnippetResolver.java delete mode 100644 modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java diff --git a/modules/jooby-openapi/pom.xml b/modules/jooby-openapi/pom.xml index 73b4905967..a4eeae25bc 100644 --- a/modules/jooby-openapi/pom.xml +++ b/modules/jooby-openapi/pom.xml @@ -56,11 +56,6 @@ asciidoctorj 3.0.1 - - org.asciidoctor - asciidoctorj-pdf - 2.3.23 - io.pebbletemplates pebble diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java index 8870875d43..c735f2e12b 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java @@ -31,7 +31,7 @@ public Object apply( EvaluationContext context, int lineNumber) throws PebbleException { - var asciidoc = InternalContext.asciidoc(context); + var asciidoc = AsciiDocContext.from(context); var pretty = args.getOrDefault("pretty", true) == Boolean.TRUE; return wrap( asciidoc.toJson(toJson(asciidoc, input), pretty), @@ -54,7 +54,7 @@ public Object apply( EvaluationContext context, int lineNumber) throws PebbleException { - var asciidoc = InternalContext.asciidoc(context); + var asciidoc = AsciiDocContext.from(context); return wrap( asciidoc.toYaml(toJson(asciidoc, input)), args.getOrDefault("wrap", Boolean.TRUE) == Boolean.TRUE, @@ -76,7 +76,7 @@ public Object apply( EvaluationContext context, int lineNumber) throws PebbleException { - var asciidoc = InternalContext.asciidoc(context); + var asciidoc = AsciiDocContext.from(context); return new SafeString(toAsciidoc(asciidoc, input).table(args)); } @@ -94,7 +94,7 @@ public Object apply( EvaluationContext context, int lineNumber) throws PebbleException { - var asciidoc = InternalContext.asciidoc(context); + var asciidoc = AsciiDocContext.from(context); return new SafeString(toAsciidoc(asciidoc, input).list(args)); } }, @@ -107,7 +107,7 @@ public Object apply( EvaluationContext context, int lineNumber) throws PebbleException { - var asciidoc = InternalContext.asciidoc(context); + var asciidoc = AsciiDocContext.from(context); var curl = switch (input) { case OperationExt op -> @@ -127,7 +127,7 @@ public Object apply( EvaluationContext context, int lineNumber) throws PebbleException { - var asciidoc = InternalContext.asciidoc(context); + var asciidoc = AsciiDocContext.from(context); var request = switch (input) { case OperationExt op -> new HttpRequest(asciidoc, op, args); @@ -157,7 +157,7 @@ public Object apply( EvaluationContext context, int lineNumber) throws PebbleException { - var asciidoc = InternalContext.asciidoc(context); + var asciidoc = AsciiDocContext.from(context); return toHttp(asciidoc, input, args).render(args); } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Filters.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Filters.java deleted file mode 100644 index 3708b02bef..0000000000 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Filters.java +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.openapi.asciidoc; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import com.fasterxml.jackson.core.JsonProcessingException; -import io.jooby.internal.openapi.EnumSchema; -import io.pebbletemplates.pebble.error.PebbleException; -import io.pebbletemplates.pebble.extension.Filter; -import io.pebbletemplates.pebble.template.EvaluationContext; -import io.pebbletemplates.pebble.template.PebbleTemplate; -import io.swagger.v3.oas.models.media.Schema; - -public enum Filters implements Filter { - display { - @Override - public List getArgumentNames() { - return null; - } - - @Override - public Object apply( - Object input, - Map args, - PebbleTemplate self, - EvaluationContext context, - int lineNumber) - throws PebbleException { - if (input instanceof Schema schema) { - return displaySchema(schema); - } else { - throw new IllegalArgumentException("Unsupported input type: " + input.getClass()); - } - } - - private Object displaySchema(Schema schema) { - if (schema instanceof EnumSchema enumSchema) { - var sb = new StringBuilder(); - sb.append( - """ - [cols="1,3"] - |=== - | Type | Description - - """); - for (var name : enumSchema.getEnum()) { - sb.append("\n") - .append("| *") - .append(name) - .append("*\n") - .append("| ") - .append(enumSchema.getDescription(name)) - .append("\n"); - } - return sb.append(" |===").toString(); - } - return null; - } - }, - - json { - @Override - public List getArgumentNames() { - return null; - } - - @Override - public Object apply( - Object input, - Map args, - PebbleTemplate self, - EvaluationContext context, - int lineNumber) - throws PebbleException { - try { - var json = InternalContext.json(context); - return "[source,json]\n----\n" - + json.writer().withDefaultPrettyPrinter().writeValueAsString(input) - + "\n----"; - } catch (JsonProcessingException e) { - throw new PebbleException( - e, "Could not convert to JSON: " + input, lineNumber, self.getName()); - } - } - }, - - yaml { - @Override - public List getArgumentNames() { - return null; - } - - @Override - public Object apply( - Object input, - Map args, - PebbleTemplate self, - EvaluationContext context, - int lineNumber) - throws PebbleException { - try { - var yaml = InternalContext.yaml(context); - return "[source,yaml]\n----\n" - + yaml.writer().withDefaultPrettyPrinter().writeValueAsString(input) - + "----"; - } catch (JsonProcessingException e) { - throw new PebbleException( - e, "Could not convert to YAML: " + input, lineNumber, self.getName()); - } - } - }; - - public static Map allFilters() { - Map functions = new HashMap<>(); - for (var value : values()) { - functions.put(value.name(), value); - } - for (var value : OperationFilters.values()) { - functions.put(value.name(), value); - } - return functions; - } -} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java deleted file mode 100644 index ef82c5e656..0000000000 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Functions.java +++ /dev/null @@ -1,188 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.openapi.asciidoc; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import io.pebbletemplates.pebble.error.PebbleException; -import io.pebbletemplates.pebble.extension.Function; -import io.pebbletemplates.pebble.template.EvaluationContext; -import io.pebbletemplates.pebble.template.PebbleTemplate; -import io.swagger.v3.oas.models.media.Schema; - -public enum Functions implements Function { - GET { - @Override - public List getArgumentNames() { - return List.of("pattern"); - } - - @Override - public Object execute( - Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { - args.put("identifier", name()); - return operation.execute(args, self, context, lineNumber); - } - }, - POST { - @Override - public List getArgumentNames() { - return List.of("pattern"); - } - - @Override - public Object execute( - Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { - args.put("identifier", name()); - return operation.execute(args, self, context, lineNumber); - } - }, - PUT { - @Override - public List getArgumentNames() { - return List.of("pattern"); - } - - @Override - public Object execute( - Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { - args.put("identifier", name()); - return operation.execute(args, self, context, lineNumber); - } - }, - PATCH { - @Override - public List getArgumentNames() { - return List.of("pattern"); - } - - @Override - public Object execute( - Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { - args.put("identifier", name()); - return operation.execute(args, self, context, lineNumber); - } - }, - DELETE { - @Override - public List getArgumentNames() { - return List.of("pattern"); - } - - @Override - public Object execute( - Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { - args.put("identifier", name()); - return operation.execute(args, self, context, lineNumber); - } - }, - tag { - @Override - public List getArgumentNames() { - return List.of("name"); - } - - @Override - public Object execute( - Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { - var openApi = InternalContext.openApi(context); - var name = args.get("name"); - return openApi.getTags().stream() - .filter(tag -> tag.getName().equals(name)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Tag not found: " + name)); - } - }, - schema { - @Override - public List getArgumentNames() { - return List.of("name"); - } - - @Override - public Object execute( - Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { - var openApi = InternalContext.openApi(context); - var name = args.get("name").toString(); - var path = name.split("\\."); - var schema = openApi.getComponents().getSchemas().get(path[0]); - if (schema == null) { - throw new IllegalArgumentException("Schema not found: " + name); - } - for (int i = 1; i < path.length; i++) { - Schema inner = (Schema) schema.getProperties().get(path[i]); - if (inner == null) { - throw new IllegalArgumentException( - "Property not found: " + Stream.of(path).limit(i).collect(Collectors.joining("."))); - } - schema = inner; - } - - return schema; - } - }, - operation { - @Override - public List getArgumentNames() { - return List.of("identifier", "pattern"); - } - - @Override - public Object execute( - Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { - try { - var namedArgs = new HashMap(); - var value = (String) args.get("identifier"); - if (isHTTPMethod(value)) { - namedArgs.put("method", value); - } else if (value.startsWith("/")) { - namedArgs.put("pattern", value); - } else { - namedArgs.put("id", value); - } - namedArgs.putIfAbsent("pattern", (String) args.get("pattern")); - var openApi = InternalContext.openApi(context); - var operationId = namedArgs.get("id"); - if (operationId == null) { - var method = namedArgs.get("method"); - var path = namedArgs.get("pattern"); - return openApi.findOperation(method, path); - } else { - return openApi.findOperationById(operationId); - } - } catch (Exception cause) { - throw new PebbleException( - cause, name() + " failed to generate output (?:?)", lineNumber, self.getName()); - } - } - - private boolean isHTTPMethod(String value) { - return switch (value.toUpperCase()) { - case "GET" -> true; - case "POST" -> true; - case "PUT" -> true; - case "DELETE" -> true; - case "HEAD" -> true; - case "OPTIONS" -> true; - case "TRACE" -> true; - case "PATCH" -> true; - default -> false; - }; - } - }; - - public static Map fn() { - Map functions = new HashMap<>(); - for (Functions value : values()) { - functions.put(value.name(), value); - } - return functions; - } -} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/InternalContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/InternalContext.java deleted file mode 100644 index f357669ae0..0000000000 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/InternalContext.java +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.openapi.asciidoc; - -import java.util.Map; - -import com.fasterxml.jackson.databind.ObjectMapper; -import io.jooby.internal.openapi.OpenAPIExt; -import io.pebbletemplates.pebble.template.EvaluationContext; - -public class InternalContext { - - @SuppressWarnings("unchecked") - private static T internal(EvaluationContext context, String name) { - var internal = (Map) context.getVariable("internal"); - return (T) internal.get(name); - } - - public static T variable(EvaluationContext context, String name) { - return internal(context, name); - } - - public static OpenAPIExt openApi(EvaluationContext context) { - return (OpenAPIExt) context.getVariable("openapi"); - } - - public static AsciiDocContext asciidoc(EvaluationContext context) { - return (AsciiDocContext) context.getVariable("_asciidocContext"); - } - - public static SnippetResolver resolver(EvaluationContext context) { - return internal(context, "resolver"); - } - - public static ObjectMapper json(EvaluationContext context) { - return internal(context, "json"); - } - - public static ObjectMapper yaml(EvaluationContext context) { - return internal(context, "yaml"); - } -} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Lookup.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Lookup.java index 6d4adb2dac..013819ddb8 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Lookup.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Lookup.java @@ -33,7 +33,7 @@ public Object execute( Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { var method = args.get("method").toString(); var path = args.get("path").toString(); - var asciidoc = InternalContext.asciidoc(context); + var asciidoc = AsciiDocContext.from(context); return asciidoc.getOpenApi().findOperation(method, path); } @@ -103,7 +103,7 @@ public List getArgumentNames() { public Object execute( Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { var path = args.get("path").toString(); - var asciidoc = InternalContext.asciidoc(context); + var asciidoc = AsciiDocContext.from(context); return asciidoc.resolveSchema(path); } @@ -128,7 +128,7 @@ public List getArgumentNames() { @Override public Object execute( Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { - var asciidoc = InternalContext.asciidoc(context); + var asciidoc = AsciiDocContext.from(context); var name = args.get("name").toString(); return asciidoc.getOpenApi().getTags().stream() .filter(tag -> tag.getName().equalsIgnoreCase(name)) diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Mutator.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Mutator.java index de89810225..18c1f24b4d 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Mutator.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Mutator.java @@ -29,7 +29,7 @@ public Object apply( int lineNumber) throws PebbleException { if (input instanceof Schema schema) { - var asciidoc = InternalContext.asciidoc(context); + var asciidoc = AsciiDocContext.from(context); return asciidoc.schemaExample(schema); } return input; @@ -45,7 +45,7 @@ public Object apply( int lineNumber) throws PebbleException { if (input instanceof Schema schema) { - var asciidoc = InternalContext.asciidoc(context); + var asciidoc = AsciiDocContext.from(context); return asciidoc.reduceSchema(schema); } return input; @@ -60,7 +60,7 @@ public Object apply( EvaluationContext context, int lineNumber) throws PebbleException { - return new HttpRequest(InternalContext.asciidoc(context), toOperation(input), args); + return new HttpRequest(AsciiDocContext.from(context), toOperation(input), args); } }, response { @@ -73,7 +73,7 @@ public Object apply( int lineNumber) throws PebbleException { return new HttpResponse( - InternalContext.asciidoc(context), + AsciiDocContext.from(context), toOperation(input), Optional.ofNullable(args.get("code")) .map(Number.class::cast) @@ -148,7 +148,7 @@ protected HttpMessage toHttpMessage( return switch (input) { case null -> throw new NullPointerException(name() + ": requires a request/response input"); // default to http request - case OperationExt op -> new HttpRequest(InternalContext.asciidoc(context), op, options); + case OperationExt op -> new HttpRequest(AsciiDocContext.from(context), op, options); case HttpMessage msg -> msg; default -> throw new ClassCastException( @@ -161,7 +161,7 @@ protected HttpRequest toHttpRequest( return switch (input) { case null -> throw new NullPointerException(name() + ": requires a request/response input"); // default to http request - case OperationExt op -> new HttpRequest(InternalContext.asciidoc(context), op, options); + case OperationExt op -> new HttpRequest(AsciiDocContext.from(context), op, options); case HttpRequest msg -> msg; default -> throw new ClassCastException( diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java deleted file mode 100644 index e881dce295..0000000000 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/OperationFilters.java +++ /dev/null @@ -1,738 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.openapi.asciidoc; - -import java.util.*; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Function; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import com.google.common.base.CaseFormat; -import com.google.common.base.Predicate; -import com.google.common.base.Splitter; -import com.google.common.collect.*; -import com.google.common.net.UrlEscapers; -import edu.umd.cs.findbugs.annotations.NonNull; -import edu.umd.cs.findbugs.annotations.Nullable; -import io.jooby.MediaType; -import io.jooby.StatusCode; -import io.jooby.internal.openapi.OperationExt; -import io.jooby.internal.openapi.RequestBodyExt; -import io.jooby.internal.openapi.ResponseExt; -import io.pebbletemplates.pebble.error.PebbleException; -import io.pebbletemplates.pebble.extension.Filter; -import io.pebbletemplates.pebble.template.EvaluationContext; -import io.pebbletemplates.pebble.template.PebbleTemplate; -import io.swagger.v3.oas.models.Operation; -import io.swagger.v3.oas.models.media.Schema; -import io.swagger.v3.oas.models.parameters.Parameter; - -public enum OperationFilters implements Filter { - curl { - private static final CharSequence Accept = new HeaderName("Accept"); - private static final CharSequence ContentType = new HeaderName("Content-Type"); - - @Override - protected String doApply( - SnippetResolver resolver, - OperationExt operation, - Map snippetContext, - Map args, - PebbleTemplate self, - EvaluationContext context, - int lineNumber) - throws Exception { - var options = args(args); - var method = removeOption(options, "-X", operation.getMethod()).toUpperCase(); - var language = removeOption(options, "language", ""); - /* Accept/Content-Type: */ - var addAccept = true; - var addContentType = true; - if (options.containsKey("-H")) { - var headers = parseHeaders(options.get("-H")); - addAccept = !headers.containsKey(Accept); - addContentType = !headers.containsKey(ContentType); - } - if (addAccept) { - operation.getProduces().forEach(value -> options.put("-H", "'Accept: " + value + "'")); - } - if (addContentType && !READ_METHODS.contains(method)) { - operation - .getConsumes() - .forEach(value -> options.put("-H", "'Content-Type: " + value + "'")); - } - var parameters = Optional.ofNullable(operation.getParameters()).orElse(List.of()); - /* Body */ - if (operation.getRequestBody() != null) { - var requestBody = operation.getRequestBody(); - var content = requestBody.getContent(); - if (content != null) { - var mediaType = - content.get(operation.getConsumes().stream().findFirst().orElse(MediaType.JSON)); - if (mediaType != null) { - var json = InternalContext.json(context); - options.put( - "-d", - "'" - + json.writeValueAsString( - SchemaData.from(mediaType.getSchema(), schemaRefResolver(context))) - + "'"); - } - } - } else { - // can be form - var form = parameters.stream().filter(it -> "form".equals(it.getIn())).toList(); - encodeUrlParameter(form) - .forEach( - it -> - options.put( - it.getKey(), - it.getKey().equals("-F") - ? "\"" + it.getValue() + "\"" - : "'" + it.getValue() + "'")); - } - /* Method */ - var url = snippetContext.get("url").toString(); - var query = parameters.stream().filter(it -> "query".equals(it.getIn())).toList(); - // query parameters - if (!query.isEmpty()) { - url += - encodeUrlParameter(query) - .map(Map.Entry::getValue) - .collect(Collectors.joining("&", "?", "")); - } - options.put("-X", method + " '" + url + "'"); - var optionsString = toString(options); - snippetContext.put("options", optionsString); - snippetContext.put("language", language); - return resolver.apply(id(), snippetContext); - } - - @NonNull private static String removeOption( - Multimap options, String name, String defaultValue) { - return Optional.of(options.removeAll(name)) - .map(Collection::iterator) - .filter(Iterator::hasNext) - .map(Iterator::next) - .orElse(defaultValue); - } - - @NonNull private static Stream> encodeUrlParameter(List query) { - return query.stream() - .flatMap( - it -> { - var names = List.of(it.getName()); - var schema = it.getSchema(); - var index = new AtomicInteger(0); - if (it.getSchema().getType().equals("array")) { - schema = it.getSchema().getItems(); - // shows 3 examples - names = List.of(it.getName(), it.getName(), it.getName()); - index.set(1); - } - var option = "binary".equals(schema.getFormat()) ? "-F" : "--data-urlencode"; - var value = - "binary".equals(schema.getFormat()) - ? "@/file%1$s.extension" - : SchemaData.shemaType(schema) + "%1$s"; - return names.stream() - .map( - name -> - Map.entry( - option, - name - + "=" - + String.format( - value, (index.get() == 0 ? "" : index.getAndIncrement())))); - }); - } - }, - request { - @Override - protected String doApply( - SnippetResolver resolver, - OperationExt operation, - Map snippetContext, - Map args, - PebbleTemplate self, - EvaluationContext context, - int lineNumber) - throws Exception { - /* Body */ - var requestBodyString = ""; - if (operation.getRequestBody() != null) { - var json = InternalContext.json(context); - var schema = schema(context, operation.getRequestBody()); - requestBodyString = - json.writeValueAsString(SchemaData.from(schema, schemaRefResolver(context))) + "\n"; - } - snippetContext.put("requestBody", requestBodyString); - snippetContext.put("headers", snippetContext.get("requestHeaders")); - return resolver.apply(id(), snippetContext); - } - }, - requestFields { - @Override - protected String doApply( - SnippetResolver resolver, - OperationExt operation, - Map snippetContext, - Map args, - PebbleTemplate self, - EvaluationContext context, - int lineNumber) - throws Exception { - /* Body */ - List> fields = List.of(); - if (operation.getRequestBody() != null) { - var schema = schema(context, operation.getRequestBody()); - fields = schemaToTable(schema, context); - } - snippetContext.put("fields", fields); - return resolver.apply(id(), snippetContext); - } - }, - response { - @Override - protected String doApply( - SnippetResolver resolver, - OperationExt operation, - Map snippetContext, - Map args, - PebbleTemplate self, - EvaluationContext context, - int lineNumber) - throws Exception { - /* Body */ - var requestBodyString = ""; - var statusCode = findStatusCode(args); - var response = responseByStatusCode(operation, statusCode); - if (statusCode == null) { - statusCode = StatusCode.valueOf(Integer.parseInt(response.getCode())); - } - var json = InternalContext.json(context); - var schema = schema(context, response); - if (schema != null) { - requestBodyString = - json.writeValueAsString(SchemaData.from(schema, schemaRefResolver(context))) + "\n"; - } - snippetContext.put("statusCode", statusCode.value()); - snippetContext.put("statusReason", statusCode.reason()); - snippetContext.put("responseBody", requestBodyString); - snippetContext.put("headers", snippetContext.get("responseHeaders")); - return resolver.apply(id(), snippetContext); - } - }, - responseFields { - @Override - protected String doApply( - SnippetResolver resolver, - OperationExt operation, - Map snippetContext, - Map args, - PebbleTemplate self, - EvaluationContext context, - int lineNumber) - throws Exception { - /* Body */ - var statusCode = findStatusCode(args); - var response = responseByStatusCode(operation, statusCode); - var schema = schema(context, response); - snippetContext.put("fields", schemaToTable(schema, context)); - return resolver.apply(id(), snippetContext); - } - }, - formParameters { - @Override - protected String doApply( - SnippetResolver resolver, - OperationExt operation, - Map snippetContext, - Map args, - PebbleTemplate self, - EvaluationContext context, - int lineNumber) - throws Exception { - snippetContext.put("parameters", parametersToTable(operation, p -> "form".equals(p.getIn()))); - return resolver.apply(id(), snippetContext); - } - }, - queryParameters { - @Override - protected String doApply( - SnippetResolver resolver, - OperationExt operation, - Map snippetContext, - Map args, - PebbleTemplate self, - EvaluationContext context, - int lineNumber) - throws Exception { - snippetContext.put( - "parameters", parametersToTable(operation, p -> "query".equals(p.getIn()))); - return resolver.apply(id(), snippetContext); - } - }, - path { - @Override - protected String doApply( - SnippetResolver resolver, - OperationExt operation, - Map snippetContext, - Map args, - PebbleTemplate self, - EvaluationContext context, - int lineNumber) { - var namedArgs = positionalArgs(args); - // Path - var path = parameters(operation, p -> "path".equals(p.getIn())); - var pathParams = new HashMap(); - path.forEach( - p -> { - pathParams.put( - p.getName(), namedArgs.getOrDefault(p.getName(), "{" + p.getName() + "}")); - }); - // QueryString - var query = - parameters( - operation, - p -> - "query".equals(p.getIn()) - && (namedArgs.isEmpty() || namedArgs.containsKey(p.getName()))); - var queryString = - query.isEmpty() - ? "" - : query.stream() - .map( - it -> { - var value = - namedArgs.getOrDefault( - it.getName(), SchemaData.shemaType(it.getSchema())); - return it.getName() - + "=" - + UrlEscapers.urlFragmentEscaper().escape(value.toString()); - }) - .collect(Collectors.joining("&", "?", "")); - return operation.getPath(pathParams) + queryString; - } - }, - pathParameters { - @Override - protected String doApply( - SnippetResolver resolver, - OperationExt operation, - Map snippetContext, - Map args, - PebbleTemplate self, - EvaluationContext context, - int lineNumber) - throws Exception { - snippetContext.put("parameters", parametersToTable(operation, p -> "path".equals(p.getIn()))); - return resolver.apply(id(), snippetContext); - } - }, - cookieParameters { - @Override - protected String doApply( - SnippetResolver resolver, - OperationExt operation, - Map snippetContext, - Map args, - PebbleTemplate self, - EvaluationContext context, - int lineNumber) - throws Exception { - snippetContext.put("cookies", parametersToTable(operation, p -> "cookie".equals(p.getIn()))); - return resolver.apply(id(), snippetContext); - } - }, - requestParameters { - @Override - protected String doApply( - SnippetResolver resolver, - OperationExt operation, - Map snippetContext, - Map args, - PebbleTemplate self, - EvaluationContext context, - int lineNumber) - throws Exception { - snippetContext.put("parameters", parametersToTable(operation, p -> true)); - return resolver.apply(id(), snippetContext); - } - }, - schema { - @Override - public String apply( - Object input, - Map args, - PebbleTemplate self, - EvaluationContext context, - int lineNumber) - throws PebbleException { - try { - var schema = schema(context, input); - var snippetResolver = InternalContext.resolver(context); - var json = InternalContext.json(context); - return snippetResolver.apply( - id(), - Map.of( - "schema", - json.writer() - .withDefaultPrettyPrinter() - .writeValueAsString(SchemaData.from(schema, schemaRefResolver(context))))); - } catch (PebbleException pebbleException) { - throw pebbleException; - } catch (Exception exception) { - throw new PebbleException( - exception, name() + " failed to generate output", lineNumber, self.getName()); - } - } - - @Override - protected String doApply( - SnippetResolver resolver, - OperationExt operation, - Map snippetContext, - Map args, - PebbleTemplate self, - EvaluationContext context, - int lineNumber) - throws Exception { - // NOOP - return null; - } - }, - statusCode { - @Override - protected String doApply( - SnippetResolver resolver, - OperationExt operation, - Map snippetContext, - Map args, - PebbleTemplate self, - EvaluationContext context, - int lineNumber) - throws Exception { - var statusCode = findStatusCode(args); - var response = responseByStatusCode(operation, statusCode, null); - if (response == null) { - throw new IllegalArgumentException("No response for: " + statusCode); - } - var json = InternalContext.json(context); - var schema = schema(context, response); - Map schemaData; - if (schema == null) { - if (statusCode.value() >= 400) { - schemaData = new LinkedHashMap<>(); - // follow default error handler - schemaData.put("message", "..."); - schemaData.put("statusCode", statusCode.value()); - schemaData.put("reason", statusCode.reason()); - } else { - throw new IllegalArgumentException("No schema response for: " + statusCode); - } - } else { - schemaData = SchemaData.from(schema, schemaRefResolver(context)); - } - var responseString = json.writer().withDefaultPrettyPrinter().writeValueAsString(schemaData); - snippetContext.put( - "statusReason", - Optional.ofNullable(response.getDescription()).orElse(statusCode.reason())); - snippetContext.put("response", responseString); - return resolver.apply(id(), snippetContext); - } - }; - - protected final String id() { - return CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_HYPHEN, name()); - } - - @Override - public List getArgumentNames() { - return null; - } - - protected abstract String doApply( - SnippetResolver resolver, - OperationExt operation, - Map snippetContext, - Map args, - PebbleTemplate self, - EvaluationContext context, - int lineNumber) - throws Exception; - - @Override - public String apply( - Object input, - Map args, - PebbleTemplate self, - EvaluationContext context, - int lineNumber) - throws PebbleException { - try { - if (!(input instanceof OperationExt operation)) { - throw new IllegalArgumentException( - "Argument must be " + Operation.class.getName() + ". Got: " + input); - } - var snippetResolver = InternalContext.resolver(context); - var snippetContext = - newSnippetContext(InternalContext.variable(context, "serverUrl"), operation); - return doApply(snippetResolver, operation, snippetContext, args, self, context, lineNumber); - } catch (PebbleException pebbleException) { - throw pebbleException; - } catch (Exception exception) { - throw new PebbleException( - exception, name() + " failed to generate output", lineNumber, self.getName()); - } - } - - protected ResponseExt responseByStatusCode( - OperationExt operation, @Nullable StatusCode statusCode) { - return responseByStatusCode(operation, statusCode, operation.getDefaultResponse()); - } - - protected ResponseExt responseByStatusCode( - OperationExt operation, @Nullable StatusCode statusCode, ResponseExt defaultResponse) { - ResponseExt response; - if (statusCode != null) { - response = (ResponseExt) operation.getResponses().get(Integer.toString(statusCode.value())); - if (response == null) { - throw new IllegalArgumentException("No response: " + statusCode.value()); - } - } else { - response = defaultResponse; - } - return response; - } - - protected Map newSnippetContext(String serverUrl, OperationExt operation) { - Map map = new HashMap<>(); - map.put("pattern", operation.getPath()); - map.put("path", operation.getPath()); - map.put("method", operation.getMethod()); - map.put("url", serverUrl + operation.getPath()); - var requestHeaders = ArrayListMultimap.create(); - var responseHeaders = ArrayListMultimap.create(); - var parameters = Optional.ofNullable(operation.getParameters()).orElse(List.of()); - var headerParams = - parameters.stream().filter(it -> "header".equalsIgnoreCase(it.getIn())).toList(); - operation - .getProduces() - .forEach( - value -> { - requestHeaders.put("Accept", value); - responseHeaders.put("Content-Type", value); - }); - headerParams.forEach(it -> requestHeaders.put(it.getName(), "{{" + it.getName() + "}}")); - if (!READ_METHODS.contains(operation.getMethod())) { - operation.getConsumes().forEach(value -> requestHeaders.put("Content-Type", value)); - } - - Function, Map> mapper = - e -> Map.of("name", e.getKey(), "value", e.getValue()); - map.put("requestHeaders", requestHeaders.entries().stream().map(mapper).toList()); - map.put("responseHeaders", responseHeaders.entries().stream().map(mapper).toList()); - return map; - } - - protected StatusCode findStatusCode(Map args) { - for (var value : args.values()) { - try { - var code = Integer.parseInt(String.valueOf(value)); - if (code >= 100 && code <= 600) { - return StatusCode.valueOf(code); - } - } catch (NumberFormatException ignored) { - } - } - return null; - } - - protected List> schemaToTable(Schema schema, EvaluationContext context) { - List> fields = new ArrayList<>(); - SchemaData.from(schema, schemaRefResolver(context)) - .forEach( - (name, type) -> { - var field = new LinkedHashMap(); - field.put("name", name); - field.put("type", type.toString()); - var property = schema.getProperties().get(name); - if (property != null) { - field.put("description", property.getDescription()); - } - fields.add(field); - }); - return fields; - } - - protected List parameters(OperationExt operation, Predicate predicate) { - return Optional.ofNullable(operation.getParameters()).orElse(List.of()).stream() - .filter(predicate) - .sorted(Comparator.comparing(Parameter::getName)) - .toList(); - } - - protected List> parametersToTable( - OperationExt operation, Predicate predicate) { - List> fields = new ArrayList<>(); - var parameters = parameters(operation, predicate); - parameters.forEach( - it -> { - var schema = it.getSchema(); - var field = new LinkedHashMap(); - field.put("name", it.getName()); - field.put("type", SchemaData.shemaType(schema)); - field.put("description", it.getDescription()); - field.put("in", it.getIn()); - fields.add(field); - }); - return fields; - } - - protected Schema schema(EvaluationContext context, Object input) { - var schema = - switch (input) { - case Schema s -> s; - case RequestBodyExt requestBody -> - Optional.ofNullable(requestBody.getContent()) - .flatMap(content -> content.values().stream().findFirst()) - .map(io.swagger.v3.oas.models.media.MediaType::getSchema) - .orElse(null); - case ResponseExt response -> - Optional.ofNullable(response.getContent()) - .flatMap(content -> content.values().stream().findFirst()) - .map(io.swagger.v3.oas.models.media.MediaType::getSchema) - .orElse(null); - case null -> throw new NullPointerException("Unable to get schema from null"); - default -> - throw new IllegalArgumentException( - "Unable to get schema from " + input.getClass().getName()); - }; - if (schema != null && schema.get$ref() != null) { - return schemaRefResolver(context).apply(schema.get$ref()).orElse(schema); - } - return schema; - } - - @SuppressWarnings("unchecked") - protected Function>> schemaRefResolver(EvaluationContext context) { - var openapi = InternalContext.openApi(context); - return ref -> { - var name = ref.substring("#/components/schemas/".length()); - var components = openapi.getComponents(); - if (components != null) { - return Optional.ofNullable(components.getSchemas().get(name)); - } - return Optional.empty(); - }; - } - - protected Multimap parseHeaders(Collection headers) { - Multimap result = LinkedHashMultimap.create(); - for (var line : headers) { - if (line.startsWith("'") && line.endsWith("'")) { - line = line.substring(1, line.length() - 1); - } - var header = Splitter.on(':').trimResults().omitEmptyStrings().splitToList(line); - if (header.size() != 2) { - throw new IllegalArgumentException("Invalid header: " + line); - } - result.put(new HeaderName(header.get(0)), header.get(1)); - } - return result; - } - - protected static final Set READ_METHODS = Set.of("GET", "HEAD"); - - protected String toString(Multimap options) { - var sb = new StringBuilder(); - var separator = "\\\n"; - var tabSize = id().length() + 1; - for (Map.Entry entry : options.entries()) { - var k = entry.getKey(); - var v = entry.getValue(); - if (!sb.isEmpty()) { - sb.append(" ".repeat(tabSize)); - } - sb.append(k); - if (v != null && !v.isEmpty()) { - sb.append(" ").append(v); - } - sb.append(separator); - } - sb.setLength(sb.length() - separator.length()); - return sb.toString(); - } - - protected Map positionalArgs(Map args) { - var optionList = new ArrayList<>(args.values()); - Map result = new LinkedHashMap<>(); - for (int i = 0; i < optionList.size(); i += 2) { - var key = optionList.get(i).toString(); - var value = optionList.get(i + 1); - result.put(key, value); - } - return result; - } - - protected Multimap args(Map args) { - Multimap result = LinkedHashMultimap.create(); - var optionList = new ArrayList<>(args.values()); - for (int i = 0; i < optionList.size(); ) { - var key = optionList.get(i).toString(); - String value = null; - if (i + 1 < optionList.size()) { - var next = optionList.get(i + 1); - if (next.toString().startsWith("-")) { - i += 1; - } else { - value = next.toString(); - i += 2; - } - } else { - i += 1; - } - result.put(key, value == null ? "" : value); - } - return result; - } - - protected record HeaderName(String value) implements CharSequence { - - @Override - public int length() { - return value.length(); - } - - @Override - public boolean equals(Object obj) { - return value.equalsIgnoreCase(obj.toString()); - } - - @Override - public int hashCode() { - return value.toLowerCase().hashCode(); - } - - @Override - public char charAt(int index) { - return value.charAt(index); - } - - @NonNull @Override - public CharSequence subSequence(int start, int end) { - return value.subSequence(start, end); - } - - @Override - @NonNull public String toString() { - return value; - } - } -} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/SchemaData.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/SchemaData.java deleted file mode 100644 index 7672007545..0000000000 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/SchemaData.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.openapi.asciidoc; - -import static java.util.Optional.ofNullable; - -import java.util.*; -import java.util.function.Function; - -import io.swagger.v3.oas.models.media.Schema; - -public class SchemaData { - public static Map from( - Schema schema, Function>> resolver) { - return ofNullable(traverse(schema.getProperties(), resolver)).orElse(Map.of()); - } - - @SuppressWarnings("rawtypes") - private static Map traverse( - Map properties, Function>> resolver) { - if (properties != null) { - Map result = new LinkedHashMap<>(); - properties.forEach( - (name, value) -> { - if (value.getType() == null) { - // must be a reference - var ref = value.get$ref(); - if (ref != null) { - var refSchema = resolver.apply(ref); - if (refSchema.isPresent()) { - result.put(name, from(refSchema.get(), resolver)); - } else { - // resolve as empty/missing - result.put(name, Map.of()); - } - } else { - // resolve as empty/missing - result.put(name, Map.of()); - } - } else if (value.getType().equals("object")) { - result.put(name, from(value, resolver)); - } else if (value.getType().equals("array")) { - var array = - ofNullable(value.getItems()) - .map(Schema::getProperties) - .map(it -> traverse(it, resolver)) - .map(List::of) - .orElse(List.of()); - result.put(name, array); - } else { - result.put(name, shemaType(value)); - } - }); - return result; - } - return null; - } - - public static String shemaType(Schema schema) { - return Optional.ofNullable(schema.getFormat()).orElse(schema.getType()); - } -} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/SnippetResolver.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/SnippetResolver.java deleted file mode 100644 index 5efe206cd4..0000000000 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/SnippetResolver.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.openapi.asciidoc; - -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.StringWriter; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Map; - -import io.jooby.SneakyThrows; -import io.pebbletemplates.pebble.PebbleEngine; - -public class SnippetResolver { - private final Path baseDir; - private PebbleEngine engine; - - public SnippetResolver(Path baseDir) { - this.baseDir = baseDir; - } - - public String apply(String snippet, Map context) throws IOException { - var writer = new StringWriter(); - var snippetContent = resolve(baseDir, snippet).trim().replaceAll("\r\n", "\n"); - var template = engine.getLiteralTemplate(snippetContent); - template.evaluate(writer, context); - return writer.toString(); - } - - private String resolve(Path snippetDir, String name) { - try { - var templatePath = snippetDir.resolve(name + ".snippet"); - if (Files.exists(templatePath)) { - return Files.readString(templatePath); - } else { - var path = "/io/jooby/openapi/templates/asciidoc/default-" + name + ".snippet"; - try (var in = getClass().getResourceAsStream(path)) { - if (in == null) { - throw new FileNotFoundException("classpath:" + path); - } - return new String(in.readAllBytes(), StandardCharsets.UTF_8); - } - } - } catch (IOException x) { - throw SneakyThrows.propagate(x); - } - } - - public void setEngine(PebbleEngine engine) { - this.engine = engine; - } -} diff --git a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java deleted file mode 100644 index 01cf9f902c..0000000000 --- a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/FilterTest.java +++ /dev/null @@ -1,917 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.openapi.asciidoc; - -import static io.jooby.internal.openapi.asciidoc.OperationFilters.*; -import static io.jooby.openapi.CurrentDir.basedir; -import static io.jooby.openapi.OperationBuilder.operation; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.util.LinkedHashMap; -import java.util.Map; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import io.jooby.StatusCode; -import io.jooby.internal.openapi.OpenAPIExt; -import io.pebbletemplates.pebble.PebbleEngine; -import io.pebbletemplates.pebble.extension.AbstractExtension; -import io.pebbletemplates.pebble.lexer.Syntax; -import io.pebbletemplates.pebble.template.EvaluationContext; -import io.pebbletemplates.pebble.template.PebbleTemplate; -import io.swagger.v3.core.util.Json; -import io.swagger.v3.core.util.Yaml; -import issues.i3729.api.Book; -import issues.i3729.api.BookError; - -public class FilterTest { - private static final String SERVER_URL = "https://api.libray.com"; - SnippetResolver resolver; - private Map internalContext; - - @BeforeEach - public void setup() { - var openapi = new OpenAPIExt(); - resolver = new SnippetResolver(basedir("src", "test", "resources", "adoc")); - internalContext = - Map.of( - "openapi", - openapi, - "serverUrl", - SERVER_URL, - "json", - Json.mapper(), - "yaml", - Yaml.mapper(), - "resolver", - resolver); - resolver.setEngine( - new PebbleEngine.Builder() - .extension( - new AbstractExtension() { - @Override - @SuppressWarnings("unchecked") - public Map getGlobalVariables() { - var openApiRoot = Json.mapper().convertValue(openapi, Map.class); - openApiRoot.put("internal", internalContext); - return openApiRoot; - } - }) - .autoEscaping(false) - .syntax( - new Syntax.Builder() - .setPrintOpenDelimiter("${") - .setPrintCloseDelimiter("}") - .setEnableNewLineTrimming(false) - .build()) - .build()); - } - - @Test - public void path() { - // Query parameter filtering by name - assertThat( - path.apply( - operation("GET", "/api/library/search").query("title", "isbn").build(), - args("title", "Some..."), - template(), - evaluationContext(), - 1)) - .isEqualTo("/api/library/search?title=Some..."); - // All - assertThat( - path.apply( - operation("GET", "/api/library/search").query("title", "isbn").build(), - args(), - template(), - evaluationContext(), - 1)) - .isEqualTo("/api/library/search?isbn=string&title=string"); - - // Path+Query - assertThat( - path.apply( - operation("GET", "/api/library/book/{isbn}") - .parameter( - Map.of("query", mapOf("title", "string"), "path", mapOf("isbn", "string"))) - .build(), - args("title", "Some...", "isbn", "12340"), - template(), - evaluationContext(), - 1)) - .isEqualTo("/api/library/book/12340?title=Some..."); - // Default Path - assertThat( - path.apply( - operation("GET", "/api/library/book/{isbn}") - .parameter( - Map.of("query", mapOf("title", "string"), "path", mapOf("isbn", "string"))) - .build(), - args("title", "Some..."), - template(), - evaluationContext(), - 1)) - .isEqualTo("/api/library/book/{isbn}?title=Some..."); - - // Only Path - assertThat( - path.apply( - operation("GET", "/api/library/book/{isbn}").path("isbn").build(), - args(), - template(), - evaluationContext(), - 1)) - .isEqualTo("/api/library/book/{isbn}"); - - // Defaults - assertThat( - path.apply( - operation("GET", "/api/library/book/{isbn}") - .parameter( - Map.of("query", mapOf("title", "string"), "path", mapOf("isbn", "string"))) - .build(), - args(), - template(), - evaluationContext(), - 1)) - .isEqualTo("/api/library/book/{isbn}?title=string"); - } - - @Test - public void requestParams() { - // Query parameter - assertThat( - queryParameters.apply( - operation("GET", "/api/library/{isbn}").query("foo", "bar").build(), - args(), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [cols="1,1,3"] - |=== - |Parameter|Type|Description - - |`+bar+` - |`+string+` - | - - |`+foo+` - |`+string+` - | - - |===\ - """); - // Path parameter - assertThat( - pathParameters.apply( - operation("GET", "/api/library/{isbn}").path("isbn").build(), - args(), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [cols="1,1,3"] - |=== - |Parameter|Type|Description - - |`+isbn+` - |`+string+` - | - - |===\ - """); - - // Cookie parameter - assertThat( - cookieParameters.apply( - operation("GET", "/api/library/{isbn}").cookie("single-sign-on").build(), - args(), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [cols="1,3"] - |=== - |Parameter|Description - - |`+single-sign-on+` - | - - |===\ - """); - - // Form - assertThat( - formParameters.apply( - operation("POST", "/api/library") - .parameter(Map.of("form", mapOf("name", "string", "file", "binary"))) - .consumes("multipart/form-data") - .build(), - args(), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [cols="1,1,3"] - |=== - |Parameter|Type|Description - - |`+file+` - |`+binary+` - | - - |`+name+` - |`+string+` - | - - |===\ - """); - - // All them - assertThat( - requestParameters.apply( - operation("POST", "/api/library") - .parameter( - Map.of( - "form", - mapOf("name", "string", "file", "binary"), - "path", - Map.of("isbn", "string"), - "query", - Map.of("active", "true"))) - .consumes("multipart/form-data") - .build(), - args(), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [cols="1,1,1,3"] - |=== - |Parameter|In|Type|Description - - |`+active+` - |`+query+` - |`+true+` - | - - |`+file+` - |`+form+` - |`+binary+` - | - - |`+isbn+` - |`+path+` - |`+string+` - | - - |`+name+` - |`+form+` - |`+string+` - | - - |===\ - """); - } - - @Test - public void curl() { - assertThat( - curl.apply( - operation("GET", "/api/library/{isbn}").build(), - args(), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [source] - ---- - curl -X GET 'https://api.libray.com/api/library/{isbn}' - ----\ - """); - - // Query parameter - assertThat( - curl.apply( - operation("GET", "/api/library/{isbn}").query("foo", "bar").build(), - args("language", "bash"), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [source, bash] - ---- - curl -X GET 'https://api.libray.com/api/library/{isbn}?foo=string&bar=string' - ----\ - """); - - // Form parameter - assertThat( - curl.apply( - operation("POST", "/api/library/{isbn}").form("foo", "bar").build(), - args(), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [source] - ---- - curl --data-urlencode 'foo=string'\\ - --data-urlencode 'bar=string'\\ - -X POST 'https://api.libray.com/api/library/{isbn}' - ----\ - """); - - // Query+Form parameter - assertThat( - curl.apply( - operation("POST", "/api/library/{isbn}") - .parameter( - Map.of( - "query", - mapOf("active", "boolean"), - "form", - mapOf("foo", "string", "bar", "string"))) - .build(), - args(), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [source] - ---- - curl --data-urlencode 'foo=string'\\ - --data-urlencode 'bar=string'\\ - -X POST 'https://api.libray.com/api/library/{isbn}?active=boolean' - ----\ - """); - - // Passing arguments - assertThat( - curl.apply( - operation("GET", "/api/library/{isbn}").build(), - args("-i"), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [source] - ---- - curl -i\\ - -X GET 'https://api.libray.com/api/library/{isbn}' - ----\ - """); - - // Override method - assertThat( - curl.apply( - operation("GET", "/api/library/{isbn}").build(), - args("-i", "-X", "POST"), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [source] - ---- - curl -i\\ - -X POST 'https://api.libray.com/api/library/{isbn}' - ----\ - """); - - // With Accept Header - assertThat( - curl.apply( - operation("GET", "/api/library/{isbn}").produces("application/json").build(), - args(), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [source] - ---- - curl -H 'Accept: application/json'\\ - -X GET 'https://api.libray.com/api/library/{isbn}' - ----\ - """); - - // With Override Accept Header - assertThat( - curl.apply( - operation("GET", "/api/library/{isbn}").produces("application/json").build(), - args("-H", "'Accept: application/xml'"), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [source] - ---- - curl -H 'Accept: application/xml'\\ - -X GET 'https://api.libray.com/api/library/{isbn}' - ----\ - """); - - assertThat( - curl.apply( - operation("POST", "/api/library").body(new Book(), "application/json").build(), - args(), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [source] - ---- - curl -H 'Content-Type: application/json'\\ - -d '{"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"}'\\ - -X POST 'https://api.libray.com/api/library' - ----\ - """); - - assertThat( - curl.apply( - operation("POST", "/api/library") - .parameter(Map.of("form", mapOf("name", "string", "file", "binary"))) - .consumes("multipart/form-data") - .build(), - args(), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [source] - ---- - curl -H 'Content-Type: multipart/form-data'\\ - --data-urlencode 'name=string'\\ - -F "file=@/file.extension"\\ - -X POST 'https://api.libray.com/api/library' - ----\ - """); - } - - @Test - public void httpRequest() { - assertThat( - request.apply( - operation("GET", "/api/library/{isbn}").build(), - args(), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [source,http,options="nowrap"] - ---- - GET /api/library/{isbn} HTTP/1.1 - ----\ - """); - - assertThat( - request.apply( - operation("GET", "/api/library/{isbn}").produces("application/json").build(), - args(), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [source,http,options="nowrap"] - ---- - GET /api/library/{isbn} HTTP/1.1 - Accept: application/json - ----\ - """); - - assertThat( - request.apply( - operation("POST", "/api/library").body(new Book(), "application/json").build(), - args(), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [source,http,options="nowrap"] - ---- - POST /api/library HTTP/1.1 - Content-Type: application/json - {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} - ----\ - """); - } - - @Test - public void httpResponse() { - assertThat( - response.apply( - operation("GET", "/api/library/{isbn}").defaultResponse().build(), - args(), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [source,http,options="nowrap"] - ---- - HTTP/1.1 200 Success - ----\ - """); - - assertThat( - response.apply( - operation("GET", "/api/library/{isbn}") - .defaultResponse() - .produces("application/json") - .build(), - args(), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [source,http,options="nowrap"] - ---- - HTTP/1.1 200 Success - Content-Type: application/json - ----\ - """); - - assertThat( - response.apply( - operation("POST", "/api/library") - .produces("application/json") - .response(new Book(), StatusCode.CREATED, "application/json") - .build(), - args(), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [source,http,options="nowrap"] - ---- - HTTP/1.1 201 Created - Content-Type: application/json - {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} - ----\ - """); - - assertThat( - response.apply( - operation("POST", "/api/library") - .produces("application/json") - .response(new Book(), StatusCode.CREATED, "application/json") - .build(), - args(), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [source,http,options="nowrap"] - ---- - HTTP/1.1 201 Created - Content-Type: application/json - {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","authors":[],"image":"binary"} - ----\ - """); - - assertThat( - response.apply( - operation("POST", "/api/library") - .produces("application/json") - .response(new Book(), StatusCode.CREATED, "application/json") - .response(new BookError(), StatusCode.BAD_REQUEST, "application/json") - .build(), - args("400"), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [source,http,options="nowrap"] - ---- - HTTP/1.1 400 Bad Request - Content-Type: application/json - {"path":"string","message":"string","code":"int32"} - ----\ - """); - } - - @Test - public void responseFields() { - assertThat( - responseFields.apply( - operation("POST", "/api/library") - .produces("application/json") - .response(new Book(), StatusCode.CREATED, "application/json") - .build(), - args(), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [cols="1,1,3"] - |=== - |Path|Type|Description - - |`+isbn+` - |`+string+` - | - - |`+title+` - |`+string+` - | - - |`+publicationDate+` - |`+date+` - | - - |`+text+` - |`+string+` - | - - |`+type+` - |`+string+` - | - - |`+authors+` - |`+[]+` - | - - |`+image+` - |`+binary+` - | - - |===\ - """); - - assertEquals( - """ - [cols="1,1,3"] - |=== - |Path|Type|Description - - |`+isbn+` - |`+string+` - | - - |`+title+` - |`+string+` - | - - |`+publicationDate+` - |`+date+` - | - - |`+text+` - |`+string+` - | - - |`+type+` - |`+string+` - | - - |`+authors+` - |`+[]+` - | - - |`+image+` - |`+binary+` - | - - |===\ - """, - responseFields.apply( - operation("POST", "/api/library") - .produces("application/json") - .response(new Book(), StatusCode.CREATED, "application/json") - .build(), - args(), - template(), - evaluationContext(), - 1)); - - assertThat( - responseFields.apply( - operation("POST", "/api/library") - .produces("application/json") - .response(new Book(), StatusCode.CREATED, "application/json") - .response(new BookError(), StatusCode.BAD_REQUEST, "application/json") - .build(), - args("400"), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [cols="1,1,3"] - |=== - |Path|Type|Description - - |`+path+` - |`+string+` - | - - |`+message+` - |`+string+` - | - - |`+code+` - |`+int32+` - | - - |===\ - """); - } - - @Test - public void statusCode() { - assertThat( - statusCode.apply( - operation("POST", "/api/library") - .produces("application/json") - .response(new Book(), StatusCode.CREATED, "application/json") - .response(new BookError(), StatusCode.BAD_REQUEST, "application/json") - .build(), - args("201"), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - .Created - [source,json] - ---- - { - "isbn" : "string", - "title" : "string", - "publicationDate" : "date", - "text" : "string", - "type" : "string", - "authors" : [ ], - "image" : "binary" - } - ----\ - """); - - assertThat( - statusCode.apply( - operation("POST", "/api/library") - .produces("application/json") - .response(new Book(), StatusCode.CREATED, "application/json") - .response(new BookError(), StatusCode.BAD_REQUEST, "application/json") - .build(), - args("400"), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - .Bad Request - [source,json] - ---- - { - "path" : "string", - "message" : "string", - "code" : "int32" - } - ----\ - """); - } - - @Test - public void schema() { - // Request Body - assertThat( - schema.apply( - operation("POST", "/api/library") - .body(new Book(), "application/json") - .build() - .getRequestBody(), - args(), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [source,json] - ---- - { - "isbn" : "string", - "title" : "string", - "publicationDate" : "date", - "text" : "string", - "type" : "string", - "authors" : [ ], - "image" : "binary" - } - ----\ - """); - - // Response - assertThat( - schema.apply( - operation("POST", "/api/library") - .response(new Book(), StatusCode.OK, "application/json") - .build() - .getDefaultResponse(), - args(), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [source,json] - ---- - { - "isbn" : "string", - "title" : "string", - "publicationDate" : "date", - "text" : "string", - "type" : "string", - "authors" : [ ], - "image" : "binary" - } - ----\ - """); - - // Schema - assertThat( - schema.apply( - operation("POST", "/api/library") - .response(new Book(), StatusCode.OK, "application/json") - .build() - .getDefaultResponse() - .getContent() - .get("application/json") - .getSchema(), - args(), - template(), - evaluationContext(), - 1)) - .isEqualToNormalizingNewlines( - """ - [source,json] - ---- - { - "isbn" : "string", - "title" : "string", - "publicationDate" : "date", - "text" : "string", - "type" : "string", - "authors" : [ ], - "image" : "binary" - } - ----\ - """); - } - - private Map args(Object... args) { - Map result = new LinkedHashMap<>(); - for (int i = 0; i < args.length; i++) { - result.put(Integer.toString(i), args[i]); - } - return result; - } - - private static Map mapOf(String... values) { - Map map = new LinkedHashMap<>(); - for (int i = 0; i < values.length; i += 2) { - map.put(values[i], values[i + 1]); - } - return map; - } - - private EvaluationContext evaluationContext() { - var context = mock(EvaluationContext.class); - when(context.getVariable("internal")).thenReturn(internalContext); - return context; - } - - private PebbleTemplate template() { - return mock(PebbleTemplate.class); - } -} From 9ba72dc595aa9f711c16255df268a566222e644e Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sat, 13 Dec 2025 19:18:19 -0300 Subject: [PATCH 18/31] - remove dead template files - ref #3820 --- .../asciidoc/default-cookie-parameters.snippet | 8 -------- .../openapi/templates/asciidoc/default-curl.snippet | 4 ---- .../templates/asciidoc/default-form-parameters.snippet | 9 --------- .../templates/asciidoc/default-path-parameters.snippet | 9 --------- .../asciidoc/default-query-parameters.snippet | 9 --------- .../templates/asciidoc/default-request-fields.snippet | 9 --------- .../templates/asciidoc/default-request-headers.snippet | 8 -------- .../asciidoc/default-request-parameters.snippet | 10 ---------- .../openapi/templates/asciidoc/default-request.snippet | 8 -------- .../templates/asciidoc/default-response-fields.snippet | 9 --------- .../templates/asciidoc/default-response.snippet | 8 -------- .../openapi/templates/asciidoc/default-schema.snippet | 4 ---- .../templates/asciidoc/default-status-code.snippet | 5 ----- .../io/jooby/openapi/templates/asciidoc/error.peb | 5 ----- modules/jooby-openapi/src/main/resources/source.peb | 9 --------- 15 files changed, 114 deletions(-) delete mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-cookie-parameters.snippet delete mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-curl.snippet delete mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-form-parameters.snippet delete mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-path-parameters.snippet delete mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-query-parameters.snippet delete mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-fields.snippet delete mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-headers.snippet delete mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-parameters.snippet delete mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request.snippet delete mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-fields.snippet delete mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response.snippet delete mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-schema.snippet delete mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-status-code.snippet delete mode 100644 modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/error.peb delete mode 100644 modules/jooby-openapi/src/main/resources/source.peb diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-cookie-parameters.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-cookie-parameters.snippet deleted file mode 100644 index 7e520402a5..0000000000 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-cookie-parameters.snippet +++ /dev/null @@ -1,8 +0,0 @@ -[cols="1,3"] -|=== -|Parameter|Description -{% for cookie in cookies %} -|`+${cookie.name}+` -|${cookie.description} -{% endfor %} -|=== diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-curl.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-curl.snippet deleted file mode 100644 index c266e29d39..0000000000 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-curl.snippet +++ /dev/null @@ -1,4 +0,0 @@ -[source{% if language %}, ${language}{%endif%}] ----- -curl ${options} ----- diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-form-parameters.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-form-parameters.snippet deleted file mode 100644 index 3b1625d618..0000000000 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-form-parameters.snippet +++ /dev/null @@ -1,9 +0,0 @@ -[cols="1,1,3"] -|=== -|Parameter|Type|Description -{% for parameter in parameters %} -|`+${parameter.name}+` -|`+${parameter.type}+` -|${parameter.description} -{% endfor %} -|=== diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-path-parameters.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-path-parameters.snippet deleted file mode 100644 index 7c0260a0b4..0000000000 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-path-parameters.snippet +++ /dev/null @@ -1,9 +0,0 @@ - [cols="1,1,3"] -|=== -|Parameter|Type|Description -{% for parameter in parameters %} -|`+${parameter.name}+` -|`+${parameter.type}+` -|${parameter.description} -{% endfor %} -|=== diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-query-parameters.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-query-parameters.snippet deleted file mode 100644 index 3b1625d618..0000000000 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-query-parameters.snippet +++ /dev/null @@ -1,9 +0,0 @@ -[cols="1,1,3"] -|=== -|Parameter|Type|Description -{% for parameter in parameters %} -|`+${parameter.name}+` -|`+${parameter.type}+` -|${parameter.description} -{% endfor %} -|=== diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-fields.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-fields.snippet deleted file mode 100644 index 3515a8eaf4..0000000000 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-fields.snippet +++ /dev/null @@ -1,9 +0,0 @@ -[cols="1,1,3"] -|=== -|Path|Type|Description -{% for field in fields %} -|`+${field.name}+` -|`+${field.type}+` -|${field.description} -{% endfor %} -|=== diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-headers.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-headers.snippet deleted file mode 100644 index cc0100c98f..0000000000 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-headers.snippet +++ /dev/null @@ -1,8 +0,0 @@ -[cols="1,3"] -|=== -|Parameter|Description -{% for header in headers %} -|`+${header.name}+` -|${header.description} -{% endfor %} -|=== diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-parameters.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-parameters.snippet deleted file mode 100644 index c41ced0b95..0000000000 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request-parameters.snippet +++ /dev/null @@ -1,10 +0,0 @@ -[cols="1,1,1,3"] -|=== -|Parameter|In|Type|Description -{% for parameter in parameters %} -|`+${parameter.name}+` -|`+${parameter.in}+` -|`+${parameter.type}+` -|${parameter.description} -{% endfor %} -|=== diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request.snippet deleted file mode 100644 index ba4c36ac21..0000000000 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-request.snippet +++ /dev/null @@ -1,8 +0,0 @@ -[source,http,options="nowrap"] ----- -${method} ${path} HTTP/1.1 -{% for h in headers -%} -${h.name}: ${h.value} -{% endfor -%} -${requestBody -} ----- diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-fields.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-fields.snippet deleted file mode 100644 index 3515a8eaf4..0000000000 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response-fields.snippet +++ /dev/null @@ -1,9 +0,0 @@ -[cols="1,1,3"] -|=== -|Path|Type|Description -{% for field in fields %} -|`+${field.name}+` -|`+${field.type}+` -|${field.description} -{% endfor %} -|=== diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response.snippet deleted file mode 100644 index 15791b78a0..0000000000 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-response.snippet +++ /dev/null @@ -1,8 +0,0 @@ -[source,http,options="nowrap"] ----- -HTTP/1.1 ${statusCode} ${statusReason} -{% for h in headers -%} -${h.name}: ${h.value} -{% endfor -%} -${responseBody -} ----- diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-schema.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-schema.snippet deleted file mode 100644 index 7cd869e93b..0000000000 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-schema.snippet +++ /dev/null @@ -1,4 +0,0 @@ -[source,json] ----- -${schema} ----- diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-status-code.snippet b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-status-code.snippet deleted file mode 100644 index 203f7a07fb..0000000000 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/default-status-code.snippet +++ /dev/null @@ -1,5 +0,0 @@ -.${statusReason} -[source,json] ----- -${response} ----- diff --git a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/error.peb b/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/error.peb deleted file mode 100644 index 7f609728a5..0000000000 --- a/modules/jooby-openapi/src/main/resources/io/jooby/openapi/templates/asciidoc/error.peb +++ /dev/null @@ -1,5 +0,0 @@ -{ - "message": "{{ message }}", - "statusCode": "{{ statusCode }}", - "reason": "{{ statusCodeReason }}" -} diff --git a/modules/jooby-openapi/src/main/resources/source.peb b/modules/jooby-openapi/src/main/resources/source.peb deleted file mode 100644 index d64546434a..0000000000 --- a/modules/jooby-openapi/src/main/resources/source.peb +++ /dev/null @@ -1,9 +0,0 @@ -{%- if display == "inline" -%} -`{%- block code -%}{%- endblock -%}` -{%- else -%} -[source{% if language %}, ${language}{%endif%}] ----- -{% block code %} -{% endblock %} ----- -{%- endif -%} From 49088f1711a3ad089b42213384eee41b5d69fc6e Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sat, 13 Dec 2025 21:42:58 -0300 Subject: [PATCH 19/31] - fix build #3820 --- .../jooby-openapi/src/test/java/issues/i3820/Issue3820.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/Issue3820.java b/modules/jooby-openapi/src/test/java/issues/i3820/Issue3820.java index bdebb90c98..7c7a21d0fc 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3820/Issue3820.java +++ b/modules/jooby-openapi/src/test/java/issues/i3820/Issue3820.java @@ -17,6 +17,8 @@ public void shouldGenerateRequestBodySchema(OpenAPIResult result) { assertThat(result.toAsciiDoc(CurrentDir.testClass(getClass(), "schema.adoc"))) .isEqualToIgnoringNewLines( """ + [source, json] + ---- { "isbn" : "string", "title" : "string", @@ -36,7 +38,8 @@ public void shouldGenerateRequestBodySchema(OpenAPIResult result) { "zip" : "string" } } ] - }\ + } + ----\ """); } } From b3635f3bf839a23d6bb129c63c2eca1a3b33c454 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sat, 13 Dec 2025 21:53:50 -0300 Subject: [PATCH 20/31] - build: fix new line on windows --- .../java/issues/i3820/PebbleSupportTest.java | 60 +++++++++---------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java b/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java index b1bb08d56d..35d68addc3 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java +++ b/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java @@ -113,7 +113,7 @@ public void tags(OpenAPIExt openapi) throws IOException { var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); templates .evaluateThat("{{tag(\"Library\").description }}") - .isEqualTo( + .isEqualToIgnoringNewLines( "Outlines the available actions in the Library System API. The system is designed to" + " allow users to search for books, view details, and manage the library" + " inventory."); @@ -125,7 +125,7 @@ public void schema(OpenAPIExt openapi) throws IOException { templates .evaluateThat("{{schema(\"Book\") | truncate | json}}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ [source, json] ---- @@ -143,7 +143,7 @@ public void schema(OpenAPIExt openapi) throws IOException { templates .evaluateThat("{{schema(\"Book\") | truncate | yaml(false) }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ isbn: string title: string @@ -267,7 +267,7 @@ public void curl(OpenAPIExt openapi) throws IOException { templates .evaluateThat("{{POST(\"/library/authors\") | curl }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ [source] ---- @@ -282,7 +282,7 @@ public void curl(OpenAPIExt openapi) throws IOException { templates .evaluateThat("{{POST(\"/library/books\") | request | curl }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ [source] ---- @@ -295,7 +295,7 @@ public void curl(OpenAPIExt openapi) throws IOException { templates .evaluateThat("{{GET(\"/library/books\") | request | curl }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ [source] ---- @@ -306,7 +306,7 @@ public void curl(OpenAPIExt openapi) throws IOException { templates .evaluateThat("{{GET(\"/library/books/{isbn}\") | request | curl }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ [source] ---- @@ -316,7 +316,7 @@ public void curl(OpenAPIExt openapi) throws IOException { templates .evaluateThat("{{GET(\"/library/books/{isbn}\") | request | curl(\"-i\") }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ [source] ---- @@ -328,7 +328,7 @@ public void curl(OpenAPIExt openapi) throws IOException { templates .evaluateThat( "{{GET(\"/library/books/{isbn}\") | request | curl(\"-i\", \"-X\", \"POST\") }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ [source] ---- @@ -340,7 +340,7 @@ public void curl(OpenAPIExt openapi) throws IOException { templates .evaluateThat( "{{GET(\"/library/books/{isbn}\") | request | curl(\"-i\", \"-X\", \"POST\") }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ [source] ---- @@ -352,7 +352,7 @@ public void curl(OpenAPIExt openapi) throws IOException { templates .evaluateThat( "{{GET(\"/library/books\") | request | curl(\"-H\", \"'Accept: application/xml'\") }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ [source] ---- @@ -369,7 +369,7 @@ public void response(OpenAPIExt openapi) throws IOException { /* Error response code: */ templates .evaluateThat("{{GET(\"/library/books/{isbn}\") | response(code=404) | http }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ [source,http,options="nowrap"] ---- @@ -380,7 +380,7 @@ public void response(OpenAPIExt openapi) throws IOException { /* Override default response code: */ templates .evaluateThat("{{POST(\"/library/books\") | response(code=201) | http }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ [source,http,options="nowrap"] ---- @@ -393,7 +393,7 @@ public void response(OpenAPIExt openapi) throws IOException { /* Default response */ templates .evaluateThat("{{POST(\"/library/books\") | response | http }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ [source,http,options="nowrap"] ---- @@ -405,7 +405,7 @@ public void response(OpenAPIExt openapi) throws IOException { templates .evaluateThat("{{POST(\"/library/books\") | response | list }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ isbn:: * type: `+string+` @@ -437,7 +437,7 @@ public void response(OpenAPIExt openapi) throws IOException { templates .evaluateThat("{{POST(\"/library/books\") | response | table }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ [cols="1,1,3a", options="header"] |=== @@ -510,7 +510,7 @@ public void request(OpenAPIExt openapi) throws IOException { templates .evaluateThat("{{POST(\"/library/books\") | request | list }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ Accept:: * type: `+string+` @@ -555,7 +555,7 @@ public void request(OpenAPIExt openapi) throws IOException { templates .evaluateThat("{{POST(\"/library/books\") | request | table }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ [cols="1,1,1,3a", options="header"] |=== @@ -616,7 +616,7 @@ public void request(OpenAPIExt openapi) throws IOException { templates .evaluateThat("{{GET(\"/library/books\") | request | table }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ [cols="1,1,1,3", options="header"] |=== @@ -646,7 +646,7 @@ public void request(OpenAPIExt openapi) throws IOException { templates .evaluateThat("{{GET(\"/library/books\") | request | parameters(query) | table }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ [cols="1,1,3", options="header"] |=== @@ -669,7 +669,7 @@ public void request(OpenAPIExt openapi) throws IOException { templates .evaluateThat( "{{GET(\"/library/books\") | request | parameters(query, ['title']) | table }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ [cols="1,1,3", options="header"] |=== @@ -683,7 +683,7 @@ public void request(OpenAPIExt openapi) throws IOException { templates .evaluateThat("{{GET(\"/library/books\") | request | parameters('path') | table }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ [cols="1,1,3", options="header"] |=== @@ -693,7 +693,7 @@ public void request(OpenAPIExt openapi) throws IOException { templates .evaluateThat("{{GET(\"/library/books\") | parameters('path') | table }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ [cols="1,1,3", options="header"] |=== @@ -703,7 +703,7 @@ public void request(OpenAPIExt openapi) throws IOException { templates .evaluateThat("{{GET(\"/library/books\") | parameters(cookie) | table }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ [cols="1,3", options="header"] |=== @@ -713,7 +713,7 @@ public void request(OpenAPIExt openapi) throws IOException { templates .evaluateThat("{{POST(\"/library/books\") | request(body=\"none\") | http }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ [source,http,options="nowrap"] ---- @@ -732,7 +732,7 @@ public void request(OpenAPIExt openapi) throws IOException { // example on same schema must generate same output templates .evaluateThat("{{GET(\"/library/books\") | request | http }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ [source,http,options="nowrap"] ---- @@ -743,7 +743,7 @@ public void request(OpenAPIExt openapi) throws IOException { templates .evaluateThat("{{POST(\"/library/books\") | request | http }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ [source,http,options="nowrap"] ---- @@ -756,7 +756,7 @@ public void request(OpenAPIExt openapi) throws IOException { templates .evaluateThat("{{POST(\"/library/books\") | request | parameters(header) | table }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ [cols="1,3", options="header"] |=== @@ -773,7 +773,7 @@ public void request(OpenAPIExt openapi) throws IOException { templates .evaluateThat( "{{POST(\"/library/books\") | request | parameters(header) | table(['name']) }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ [cols="1", options="header"] |=== @@ -787,7 +787,7 @@ public void request(OpenAPIExt openapi) throws IOException { templates .evaluateThat("{{POST(\"/library/books\") | request | body | table }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ [cols="1,1,3a", options="header"] |=== From 21d70bd54d76afd9fbe5425a43080a373d474d18 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sun, 14 Dec 2025 11:00:51 -0300 Subject: [PATCH 21/31] set default error response: - allow to define custom error - add a function error - allow to resolve simple variables from error template - ref #3820 --- .../openapi/asciidoc/AsciiDocContext.java | 89 +++++++++++++-- .../internal/openapi/asciidoc/Display.java | 8 -- .../internal/openapi/asciidoc/Lookup.java | 102 ++++++++++++++++-- .../internal/openapi/asciidoc/Mutator.java | 9 -- .../asciidoc/PebbleTemplateSupport.java | 4 + .../java/issues/i3820/PebbleSupportTest.java | 52 +++++++++ .../test/java/issues/i3820/app/AppLib.java | 1 + 7 files changed, 230 insertions(+), 35 deletions(-) diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java index 33588ec16b..b458cfc767 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java @@ -10,6 +10,8 @@ import java.io.IOException; import java.io.StringWriter; import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Instant; import java.util.*; import java.util.function.BiConsumer; import java.util.stream.Collectors; @@ -26,6 +28,7 @@ import io.jooby.SneakyThrows; import io.jooby.internal.openapi.OpenAPIExt; import io.pebbletemplates.pebble.PebbleEngine; +import io.pebbletemplates.pebble.error.PebbleException; import io.pebbletemplates.pebble.extension.AbstractExtension; import io.pebbletemplates.pebble.extension.Filter; import io.pebbletemplates.pebble.extension.Function; @@ -35,7 +38,7 @@ import io.pebbletemplates.pebble.loader.FileLoader; import io.pebbletemplates.pebble.loader.Loader; import io.pebbletemplates.pebble.template.EvaluationContext; -import io.swagger.v3.oas.models.OpenAPI; +import io.pebbletemplates.pebble.template.PebbleTemplate; import io.swagger.v3.oas.models.media.Schema; public class AsciiDocContext { @@ -56,12 +59,14 @@ public class AsciiDocContext { private final Map, Map> examples = new HashMap<>(); + private final Instant now = Instant.now(); + public AsciiDocContext(Path baseDir, ObjectMapper json, ObjectMapper yaml, OpenAPIExt openapi) { this.json = json; this.yamlOpenApi = yaml; this.yamlOutput = newYamlOutput(); - this.engine = createEngine(baseDir, json, openapi, this); this.openapi = openapi; + this.engine = createEngine(baseDir, json, this); } public String generate(Path index) throws IOException { @@ -89,6 +94,10 @@ public void export(Path input, Path outputDir) { } } + public Instant getNow() { + return now; + } + private ObjectMapper newYamlOutput() { var factory = new YAMLFactory(); factory.enable(YAMLGenerator.Feature.MINIMIZE_QUOTES); @@ -97,7 +106,7 @@ private ObjectMapper newYamlOutput() { } private static PebbleEngine createEngine( - Path baseDir, ObjectMapper json, OpenAPI openapi, AsciiDocContext context) { + Path baseDir, ObjectMapper json, AsciiDocContext context) { List> loaders = List.of(new FileLoader(baseDir.toAbsolutePath().toString()), new ClasspathLoader()); return new PebbleEngine.Builder() @@ -107,8 +116,9 @@ private static PebbleEngine createEngine( new AbstractExtension() { @Override public Map getGlobalVariables() { - Map openapiRoot = json.convertValue(openapi, Map.class); - openapiRoot.put("openapi", openapi); + Map openapiRoot = json.convertValue(context.openapi, Map.class); + openapiRoot.put("openapi", context.openapi); + openapiRoot.put("now", context.now); // make in to work without literal openapiRoot.put("query", "query"); @@ -122,15 +132,74 @@ public Map getGlobalVariables() { @Override public Map getFunctions() { - return Lookup.lookup(); + return Stream.of(Lookup.values()) + .collect(Collectors.toMap(Enum::name, it -> wrapFn(it))); + } + + private static Function wrapFn(Lookup lookup) { + return new Function() { + @Override + public List getArgumentNames() { + return lookup.getArgumentNames(); + } + + @Override + public Object execute( + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) { + try { + return lookup.execute(args, self, context, lineNumber); + } catch (PebbleException rethrow) { + throw rethrow; + } catch (Throwable cause) { + var path = Paths.get(self.getName()); + throw new PebbleException( + cause, + "execution of `" + lookup.name() + "()` resulted in exception:", + lineNumber, + path.getFileName().toString().trim()); + } + } + }; + } + + private static Filter wrapFilter(String filterName, Filter filter) { + return new Filter() { + @Override + public List getArgumentNames() { + return filter.getArgumentNames(); + } + + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + try { + return filter.apply(input, args, self, context, lineNumber); + } catch (PebbleException rethrow) { + throw rethrow; + } catch (Throwable cause) { + var path = Paths.get(self.getName()); + throw new PebbleException( + cause, + "execution of `" + filterName + "()` resulted in exception:", + lineNumber, + path.getFileName().toString().trim()); + } + } + }; } @Override public Map getFilters() { - Map filters = new HashMap<>(); - filters.putAll(Mutator.seek()); - filters.putAll(Display.display()); - return filters; + return Stream.concat(Stream.of(Mutator.values()), Stream.of(Display.values())) + .collect(Collectors.toMap(Enum::name, it -> wrapFilter(it.name(), it))); } }) .syntax(new Syntax.Builder().setEnableNewLineTrimming(false).build()) diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java index c735f2e12b..a7e5df285c 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java @@ -196,12 +196,4 @@ protected SafeString wrap(String content, boolean wrap, String prefix, String su public List getArgumentNames() { return List.of(); } - - public static Map display() { - Map result = new HashMap<>(); - for (var value : values()) { - result.put(value.name(), value); - } - return result; - } } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Lookup.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Lookup.java index 013819ddb8..2f4504fc24 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Lookup.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Lookup.java @@ -7,6 +7,7 @@ import java.util.*; +import io.jooby.StatusCode; import io.pebbletemplates.pebble.extension.Function; import io.pebbletemplates.pebble.template.EvaluationContext; import io.pebbletemplates.pebble.template.PebbleTemplate; @@ -136,6 +137,99 @@ public Object execute( .orElseThrow(() -> new NoSuchElementException("Tag not found: " + name)); } + @Override + public List getArgumentNames() { + return List.of("name"); + } + }, + error { + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + var error = context.getVariable("error"); + if (error == null) { + // mimic default error handler + Map defaultError = new TreeMap<>(); + var statusCode = + StatusCode.valueOf( + ((Number) + args.getOrDefault( + "code", + args.getOrDefault("statusCode", StatusCode.SERVER_ERROR.value()))) + .intValue()); + defaultError.put("statusCode", statusCode.value()); + defaultError.put( + "reason", + args.getOrDefault( + "reason", args.getOrDefault("statusCodeReason", statusCode.reason()))); + defaultError.put("message", args.getOrDefault("message", "...")); + return defaultError; + } else if (error instanceof Map errorMap) { + var mutableMap = new TreeMap(); + mutableMap.putAll((Map) errorMap); + mutableMap.putAll(args); + for (var entry : errorMap.entrySet()) { + var value = entry.getValue(); + var template = String.valueOf(value); + if (template.startsWith("{{") && template.endsWith("}}")) { + var variable = template.substring(2, template.length() - 2).trim(); + value = + switch (variable) { + case "status.reason", + "statusCodeReason", + "code.reason", + "codeReason", + "reason" -> { + var statusCode = + StatusCode.valueOf( + ((Number) + args.getOrDefault( + "code", + args.getOrDefault( + "statusCode", StatusCode.SERVER_ERROR.value()))) + .intValue()); + yield statusCode.reason(); + } + default -> Optional.ofNullable(context.getVariable(variable)).orElse(template); + }; + mutableMap.put((String) entry.getKey(), value); + } + } + return mutableMap; + } + throw new ClassCastException("Global error must be a map: " + error); + } + + @Override + public List getArgumentNames() { + return List.of(); + } + }, + server { + @Override + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + var asciidoc = AsciiDocContext.from(context); + var servers = asciidoc.getOpenApi().getServers(); + if (servers == null || servers.isEmpty()) { + throw new NoSuchElementException("No servers"); + } + var nameOrIndex = args.get("name"); + if (nameOrIndex instanceof Number index) { + if (index.intValue() >= 0 && index.intValue() < servers.size()) { + return servers.get(index.intValue()); + } else { + throw new NoSuchElementException("Server not found: [" + nameOrIndex + "]"); + } + } else { + return servers.stream() + .filter(it -> nameOrIndex.equals(it.getDescription())) + .findFirst() + .orElseThrow(() -> new NoSuchElementException("Server not found: " + nameOrIndex)); + } + } + @Override public List getArgumentNames() { return List.of("name"); @@ -147,12 +241,4 @@ protected Map appendMethod(Map args) { result.put("method", name()); return result; } - - public static Map lookup() { - Map result = new HashMap<>(); - for (var value : values()) { - result.put(value.name(), value); - } - return result; - } } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Mutator.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Mutator.java index 18c1f24b4d..8007981519 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Mutator.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Mutator.java @@ -5,7 +5,6 @@ */ package io.jooby.internal.openapi.asciidoc; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -173,12 +172,4 @@ protected HttpRequest toHttpRequest( public List getArgumentNames() { return List.of(); } - - public static Map seek() { - Map result = new HashMap<>(); - for (var value : values()) { - result.put(value.name(), value); - } - return result; - } } diff --git a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/PebbleTemplateSupport.java b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/PebbleTemplateSupport.java index 9355840288..116320106b 100644 --- a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/PebbleTemplateSupport.java +++ b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/PebbleTemplateSupport.java @@ -34,6 +34,10 @@ public void evaluate(String input, SneakyThrows.Consumer consumer) throw consumer.accept(evaluate(input)); } + public AsciiDocContext getContext() { + return context; + } + public String evaluate(String input) throws IOException { var template = context.getEngine().getLiteralTemplate(input); var writer = new StringWriter(); diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java b/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java index 35d68addc3..e352644d86 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java +++ b/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java @@ -80,6 +80,48 @@ public void shouldSupportJsonSchemaInV31(OpenAPIExt openapi) throws IOException """); } + @OpenAPITest(value = AppLib.class) + public void errorMap(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + // default error map + templates + .evaluateThat( + """ + {{ error(code=400) | json }} + """) + .isEqualToIgnoringNewLines( + """ + [source, json] + ---- + { + "message" : "...", + "reason" : "Bad Request", + "statusCode" : 400 + } + ----\ + """); + + templates + .evaluateThat( + """ + {%- set error = {"code": 500, "message": "{{code.reason}}", "time": now } -%} + {{ error(code=402) | json }} + """) + .isEqualToIgnoringNewLines( + String.format( + """ + [source, json] + ---- + { + "code" : 402, + "message" : "Payment Required", + "time" : "%s" + } + ----\ + """, + templates.getContext().getNow())); + } + @OpenAPITest(value = AppLib.class) public void openApi(OpenAPIExt openapi) throws IOException { var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); @@ -119,6 +161,16 @@ public void tags(OpenAPIExt openapi) throws IOException { + " inventory."); } + @OpenAPITest(value = AppLib.class) + public void server(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + templates.evaluateThat("{{ server(0).url }}").isEqualTo("https://library.jooby.io"); + + templates + .evaluateThat("{{ server(\"Production\").url }}") + .isEqualTo("https://library.jooby.io"); + } + @OpenAPITest(value = AppLib.class) public void schema(OpenAPIExt openapi) throws IOException { var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/app/AppLib.java b/modules/jooby-openapi/src/test/java/issues/i3820/app/AppLib.java index b725aae6e3..6317c73625 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3820/app/AppLib.java +++ b/modules/jooby-openapi/src/test/java/issues/i3820/app/AppLib.java @@ -22,6 +22,7 @@ * @contact.url https://jooby.io * @contact.email support@jooby.io * @server.url https://library.jooby.io + * @server.description Production * @x-logo.url https://redoredocly.github.io/redoc/museum-logo.png * @tag Library. Outlines the available actions in the Library System API. The system is designed to * allow users to search for books, view details, and manage the library inventory. From 567e7b4decc17f706a7fbd29c2acd01131b5b49d Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sun, 14 Dec 2025 12:51:29 -0300 Subject: [PATCH 22/31] statusCode: add way to display status code - it can be override, supported format: json, yaml, table, list --- .../openapi/asciidoc/AsciiDocContext.java | 53 +++++++++++ .../internal/openapi/asciidoc/Display.java | 16 ++-- .../internal/openapi/asciidoc/Lookup.java | 93 +++++++++---------- .../openapi/asciidoc/StatusCodeList.java | 47 ++++++++++ .../internal/openapi/asciidoc/ToAsciiDoc.java | 14 +++ .../asciidoc/display/MapToAsciiDoc.java | 48 ++++++++++ .../OpenApiToAsciiDoc.java} | 18 ++-- .../{http => display}/RequestToCurl.java | 2 +- .../{http => display}/RequestToHttp.java | 2 +- .../{http => display}/ResponseToHttp.java | 2 +- .../java/issues/i3820/PebbleSupportTest.java | 88 ++++++++++++++++++ 11 files changed, 313 insertions(+), 70 deletions(-) create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/StatusCodeList.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/ToAsciiDoc.java create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/MapToAsciiDoc.java rename modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/{table/ToAsciiDoc.java => display/OpenApiToAsciiDoc.java} (91%) rename modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/{http => display}/RequestToCurl.java (99%) rename modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/{http => display}/RequestToHttp.java (96%) rename modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/{http => display}/ResponseToHttp.java (96%) diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java index b458cfc767..301fca6304 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java @@ -26,6 +26,7 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; import io.jooby.SneakyThrows; +import io.jooby.StatusCode; import io.jooby.internal.openapi.OpenAPIExt; import io.pebbletemplates.pebble.PebbleEngine; import io.pebbletemplates.pebble.error.PebbleException; @@ -120,6 +121,16 @@ public Map getGlobalVariables() { openapiRoot.put("openapi", context.openapi); openapiRoot.put("now", context.now); + openapiRoot.put( + "error", + Map.of( + "statusCode", + "{{statusCode.code}}", + "reason", + "{{statusCode.reason}}", + "message", + "...")); + // make in to work without literal openapiRoot.put("query", "query"); openapiRoot.put("path", "path"); @@ -206,6 +217,48 @@ public Map getFilters() { .build(); } + @SuppressWarnings("unchecked") + public Map error(EvaluationContext context, Map args) { + var error = context.getVariable("error"); + if (error instanceof Map errorMap) { + var mutableMap = new TreeMap((Map) errorMap); + args.forEach( + (key, value) -> { + if (mutableMap.containsKey(key)) { + mutableMap.put(key, value); + } + }); + var statusCode = + StatusCode.valueOf( + ((Number) + args.getOrDefault( + "code", args.getOrDefault("statusCode", StatusCode.SERVER_ERROR.value()))) + .intValue()); + for (var entry : errorMap.entrySet()) { + var value = entry.getValue(); + var template = String.valueOf(value); + if (template.startsWith("{{") && template.endsWith("}}")) { + var variable = template.substring(2, template.length() - 2).trim(); + value = + switch (variable) { + case "status.reason", + "statusCodeReason", + "statusCode.reason", + "code.reason", + "codeReason", + "reason" -> + statusCode.reason(); + case "status.code", "statusCode.code", "statusCode", "code" -> statusCode.value(); + default -> Optional.ofNullable(context.getVariable(variable)).orElse(template); + }; + mutableMap.put((String) entry.getKey(), value); + } + } + return mutableMap; + } + throw new ClassCastException("Global error must be a map: " + error); + } + public String schemaType(Schema schema) { var resolved = resolveSchema(schema); return Optional.ofNullable(resolved.getFormat()).orElse(resolveType(resolved)); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java index a7e5df285c..24cc4035ac 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java @@ -10,10 +10,7 @@ import java.util.Map; import io.jooby.internal.openapi.OperationExt; -import io.jooby.internal.openapi.asciidoc.http.RequestToCurl; -import io.jooby.internal.openapi.asciidoc.http.RequestToHttp; -import io.jooby.internal.openapi.asciidoc.http.ResponseToHttp; -import io.jooby.internal.openapi.asciidoc.table.ToAsciiDoc; +import io.jooby.internal.openapi.asciidoc.display.*; import io.pebbletemplates.pebble.error.PebbleException; import io.pebbletemplates.pebble.extension.Filter; import io.pebbletemplates.pebble.extension.escaper.SafeString; @@ -173,10 +170,12 @@ private ToSnippet toHttp(AsciiDocContext context, Object input, Map ToAsciiDoc.parameters(context, req.getAllParameters()); - case HttpResponse rsp -> ToAsciiDoc.schema(context, rsp.getBody()); - case Schema schema -> ToAsciiDoc.schema(context, schema); - case ParameterList paramList -> ToAsciiDoc.parameters(context, paramList); + case HttpRequest req -> OpenApiToAsciiDoc.parameters(context, req.getAllParameters()); + case HttpResponse rsp -> OpenApiToAsciiDoc.schema(context, rsp.getBody()); + case Schema schema -> OpenApiToAsciiDoc.schema(context, schema); + case ParameterList paramList -> OpenApiToAsciiDoc.parameters(context, paramList); + case ToAsciiDoc asciiDoc -> asciiDoc; + case Map map -> new MapToAsciiDoc(List.of(map)); default -> throw new IllegalArgumentException("Can't render: " + input); }; } @@ -184,6 +183,7 @@ protected ToAsciiDoc toAsciidoc(AsciiDocContext context, Object input) { protected Object toJson(AsciiDocContext context, Object input) { return switch (input) { case Schema schema -> context.schemaProperties(schema); + case StatusCodeList codeList -> codeList.codes(); default -> input; }; } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Lookup.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Lookup.java index 2f4504fc24..dcad7d444c 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Lookup.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Lookup.java @@ -6,7 +6,9 @@ package io.jooby.internal.openapi.asciidoc; import java.util.*; +import java.util.stream.Stream; +import edu.umd.cs.findbugs.annotations.NonNull; import io.jooby.StatusCode; import io.pebbletemplates.pebble.extension.Function; import io.pebbletemplates.pebble.template.EvaluationContext; @@ -143,67 +145,56 @@ public List getArgumentNames() { } }, error { - @SuppressWarnings({"unchecked", "rawtypes"}) @Override public Object execute( Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { - var error = context.getVariable("error"); - if (error == null) { - // mimic default error handler - Map defaultError = new TreeMap<>(); - var statusCode = - StatusCode.valueOf( - ((Number) - args.getOrDefault( - "code", - args.getOrDefault("statusCode", StatusCode.SERVER_ERROR.value()))) - .intValue()); - defaultError.put("statusCode", statusCode.value()); - defaultError.put( - "reason", - args.getOrDefault( - "reason", args.getOrDefault("statusCodeReason", statusCode.reason()))); - defaultError.put("message", args.getOrDefault("message", "...")); - return defaultError; - } else if (error instanceof Map errorMap) { - var mutableMap = new TreeMap(); - mutableMap.putAll((Map) errorMap); - mutableMap.putAll(args); - for (var entry : errorMap.entrySet()) { - var value = entry.getValue(); - var template = String.valueOf(value); - if (template.startsWith("{{") && template.endsWith("}}")) { - var variable = template.substring(2, template.length() - 2).trim(); - value = - switch (variable) { - case "status.reason", - "statusCodeReason", - "code.reason", - "codeReason", - "reason" -> { - var statusCode = - StatusCode.valueOf( - ((Number) - args.getOrDefault( - "code", - args.getOrDefault( - "statusCode", StatusCode.SERVER_ERROR.value()))) - .intValue()); - yield statusCode.reason(); - } - default -> Optional.ofNullable(context.getVariable(variable)).orElse(template); - }; - mutableMap.put((String) entry.getKey(), value); + var asciidoc = AsciiDocContext.from(context); + return asciidoc.error(context, args); + } + + @Override + public List getArgumentNames() { + return List.of(); + } + }, + statusCode { + @Override + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + var code = args.get("code"); + if (code instanceof List codes) { + return new StatusCodeList(codes.stream().flatMap(this::toMap).toList()); + } + return new StatusCodeList(toMap(code).toList()); + } + + @NonNull private Stream> toMap(Object candidate) { + if (candidate instanceof Number code) { + Map map = new LinkedHashMap<>(); + map.put("code", code.intValue()); + map.put("reason", StatusCode.valueOf(code.intValue()).reason()); + return Stream.of(map); + } else if (candidate instanceof Map codeMap) { + var codes = new ArrayList>(); + for (var entry : codeMap.entrySet()) { + var value = entry.getKey(); + if (value instanceof Number code) { + Map map = new LinkedHashMap<>(); + map.put("code", code.intValue()); + map.put("reason", entry.getValue()); + codes.add(map); + } else { + throw new ClassCastException("Must be Map: " + candidate); } } - return mutableMap; + return codes.stream(); } - throw new ClassCastException("Global error must be a map: " + error); + throw new ClassCastException("Not a number: " + candidate); } @Override public List getArgumentNames() { - return List.of(); + return List.of("code"); } }, server { diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/StatusCodeList.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/StatusCodeList.java new file mode 100644 index 0000000000..d70457488d --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/StatusCodeList.java @@ -0,0 +1,47 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.internal.openapi.asciidoc.display.MapToAsciiDoc; + +public record StatusCodeList(List> codes) + implements Iterable>, ToAsciiDoc { + @NonNull @Override + public String toString() { + return codes.toString(); + } + + @NonNull @Override + public Iterator> iterator() { + return codes.iterator(); + } + + @Override + public String list(Map options) { + var sb = new StringBuilder(); + codes.forEach( + (row) -> + sb.append("* `+") + .append(row.get("code")) + .append("+`: ") + .append(row.get("reason")) + .append('\n')); + if (!sb.isEmpty()) { + sb.setLength(sb.length() - 1); + } + return sb.toString(); + } + + @Override + public String table(Map options) { + return new MapToAsciiDoc(codes).table(options); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/ToAsciiDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/ToAsciiDoc.java new file mode 100644 index 0000000000..435ead5f3e --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/ToAsciiDoc.java @@ -0,0 +1,14 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.Map; + +public interface ToAsciiDoc { + String list(Map options); + + String table(Map options); +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/MapToAsciiDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/MapToAsciiDoc.java new file mode 100644 index 0000000000..0d12448436 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/MapToAsciiDoc.java @@ -0,0 +1,48 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc.display; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import io.jooby.internal.openapi.asciidoc.ToAsciiDoc; + +public record MapToAsciiDoc(List> rows) implements ToAsciiDoc { + + public String list(Map options) { + var sb = new StringBuilder(); + rows.forEach( + (row) -> { + row.forEach( + (name, value) -> { + sb.append("* ").append(name).append(": ").append(value).append('\n'); + }); + }); + if (!sb.isEmpty()) { + sb.setLength(sb.length() - 1); + } + return sb.toString(); + } + + public String table(Map options) { + var sb = new StringBuilder(); + sb.append("|===").append('\n'); + if (!rows.isEmpty()) { + sb.append(rows.getFirst().keySet().stream().collect(Collectors.joining("|", "|", ""))) + .append("\n\n"); + rows.forEach( + row -> { + row.values().forEach(value -> sb.append("|").append(value).append("\n")); + sb.append("\n"); + }); + sb.append("\n"); + } + sb.setLength(sb.length() - 1); + sb.append("|==="); + return sb.toString(); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/table/ToAsciiDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/OpenApiToAsciiDoc.java similarity index 91% rename from modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/table/ToAsciiDoc.java rename to modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/OpenApiToAsciiDoc.java index a03fe74b07..8e854ce1ac 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/table/ToAsciiDoc.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/OpenApiToAsciiDoc.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.internal.openapi.asciidoc.table; +package io.jooby.internal.openapi.asciidoc.display; import java.util.LinkedHashMap; import java.util.List; @@ -14,27 +14,29 @@ import io.jooby.internal.openapi.EnumSchema; import io.jooby.internal.openapi.asciidoc.AsciiDocContext; import io.jooby.internal.openapi.asciidoc.ParameterList; +import io.jooby.internal.openapi.asciidoc.ToAsciiDoc; import io.swagger.v3.oas.models.media.Schema; -public record ToAsciiDoc( +public record OpenApiToAsciiDoc( AsciiDocContext context, Map> properties, List columns, - Map additionalProperties) { + Map additionalProperties) + implements ToAsciiDoc { private static final String ROOT = "___root__"; - public static ToAsciiDoc schema(AsciiDocContext context, Schema schema) { + public static OpenApiToAsciiDoc schema(AsciiDocContext context, Schema schema) { var columns = schema instanceof EnumSchema ? List.of("name", "description") : List.of("name", "type", "description"); var properties = new LinkedHashMap>(); - properties.put(ToAsciiDoc.ROOT, schema); + properties.put(OpenApiToAsciiDoc.ROOT, schema); context.traverseSchema(schema, properties::put); - return new ToAsciiDoc(context, properties, columns, Map.of()); + return new OpenApiToAsciiDoc(context, properties, columns, Map.of()); } - public static ToAsciiDoc parameters(AsciiDocContext context, ParameterList parameters) { + public static OpenApiToAsciiDoc parameters(AsciiDocContext context, ParameterList parameters) { var properties = new LinkedHashMap>(); parameters.forEach(p -> properties.put(p.getName(), p.getSchema())); Map additionalProperties = new LinkedHashMap<>(); @@ -43,7 +45,7 @@ public static ToAsciiDoc parameters(AsciiDocContext context, ParameterList param additionalProperties.put(p.getName() + ".in", p.getIn()); additionalProperties.put(p.getName() + ".description", p.getDescription()); }); - return new ToAsciiDoc(context, properties, parameters.includes(), additionalProperties); + return new OpenApiToAsciiDoc(context, properties, parameters.includes(), additionalProperties); } public String list(Map options) { diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/RequestToCurl.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/RequestToCurl.java similarity index 99% rename from modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/RequestToCurl.java rename to modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/RequestToCurl.java index f4b6cf9589..457c71f249 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/RequestToCurl.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/RequestToCurl.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.internal.openapi.asciidoc.http; +package io.jooby.internal.openapi.asciidoc.display; import java.util.*; diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/RequestToHttp.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/RequestToHttp.java similarity index 96% rename from modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/RequestToHttp.java rename to modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/RequestToHttp.java index b30f1381a6..bd97c2dad4 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/RequestToHttp.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/RequestToHttp.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.internal.openapi.asciidoc.http; +package io.jooby.internal.openapi.asciidoc.display; import java.util.Map; diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/ResponseToHttp.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/ResponseToHttp.java similarity index 96% rename from modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/ResponseToHttp.java rename to modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/ResponseToHttp.java index 69b404f0c5..07983b5f0b 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/http/ResponseToHttp.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/ResponseToHttp.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.internal.openapi.asciidoc.http; +package io.jooby.internal.openapi.asciidoc.display; import java.util.Map; diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java b/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java index e352644d86..989b40508d 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java +++ b/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java @@ -21,6 +21,65 @@ public class PebbleSupportTest { + @OpenAPITest(value = AppLib.class) + public void statusCode(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + // default error map + templates.evaluateThat("{{ statusCode(200) }}").isEqualTo("[{code=200, reason=Success}]"); + + templates + .evaluateThat("{{ statusCode(200) | list }}") + .isEqualTo( + """ + * `+200+`: Success\ + """); + + templates + .evaluateThat("{{ statusCode(200) | table }}") + .isEqualTo( + """ + |=== + |code|reason + + |200 + |Success + + |===\ + """); + + templates + .evaluateThat("{{ statusCode([200, 201]) | table }}") + .isEqualTo( + """ + |=== + |code|reason + + |200 + |Success + + |201 + |Created + + |===\ + """); + + templates + .evaluateThat("{{ statusCode([200, 201]) | list }}") + .isEqualTo( + """ + * `+200+`: Success + * `+201+`: Created\ + """); + + templates + .evaluateThat("{{ statusCode({200: \"OK\", 500: \"Internal Server Error\"}) | list }}") + .isEqualTo( + """ + * `+200+`: OK + * `+500+`: Internal Server Error\ + """); + } + @OpenAPITest(value = AppLibrary.class) public void bodyBug(OpenAPIExt openapi) throws IOException { var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); @@ -101,6 +160,35 @@ public void errorMap(OpenAPIExt openapi) throws IOException { ----\ """); + templates + .evaluateThat( + """ + {{ error(code=400) | list }} + """) + .isEqualToIgnoringNewLines( + """ + * message: ... + * reason: Bad Request + * statusCode: 400\ + """); + + templates + .evaluateThat( + """ + {{ error(code=400) | table }} + """) + .isEqualToIgnoringNewLines( + """ + |=== + |message|reason|statusCode + + |... + |Bad Request + |400 + + |===\ + """); + templates .evaluateThat( """ From 2e0e9823ab5129c9a5a56c5e0aab61fab4c56ed9 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sun, 14 Dec 2025 14:40:02 -0300 Subject: [PATCH 23/31] routes: add route summary - routes/operations - add support for fn alias - fix error lookup to support expression like `error(400)` --- .../openapi/asciidoc/AsciiDocContext.java | 12 ++- .../internal/openapi/asciidoc/Display.java | 3 +- .../openapi/asciidoc/HttpRequestList.java | 70 +++++++++++++++ .../internal/openapi/asciidoc/Lookup.java | 47 +++++++--- .../openapi/asciidoc/StatusCodeList.java | 2 + .../java/issues/i3820/PebbleSupportTest.java | 86 +++++++++++++++++-- 6 files changed, 202 insertions(+), 18 deletions(-) create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequestList.java diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java index 301fca6304..76901f1b12 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java @@ -121,6 +121,7 @@ public Map getGlobalVariables() { openapiRoot.put("openapi", context.openapi); openapiRoot.put("now", context.now); + // Global/Default values: openapiRoot.put( "error", Map.of( @@ -130,6 +131,14 @@ public Map getGlobalVariables() { "{{statusCode.reason}}", "message", "...")); + // Routes + var operations = + context.openapi.getOperations().stream() + .map(op -> new HttpRequest(context, op, Map.of())) + .toList(); + // so we can print routes without calling function: routes() vs routes + openapiRoot.put("routes", operations); + openapiRoot.put("operations", operations); // make in to work without literal openapiRoot.put("query", "query"); @@ -144,7 +153,8 @@ public Map getGlobalVariables() { @Override public Map getFunctions() { return Stream.of(Lookup.values()) - .collect(Collectors.toMap(Enum::name, it -> wrapFn(it))); + .flatMap(it -> it.alias().stream().map(name -> Map.entry(name, it))) + .collect(Collectors.toMap(Map.Entry::getKey, it -> wrapFn(it.getValue()))); } private static Function wrapFn(Lookup lookup) { diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java index 24cc4035ac..0284f9523d 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java @@ -183,7 +183,8 @@ protected ToAsciiDoc toAsciidoc(AsciiDocContext context, Object input) { protected Object toJson(AsciiDocContext context, Object input) { return switch (input) { case Schema schema -> context.schemaProperties(schema); - case StatusCodeList codeList -> codeList.codes(); + case StatusCodeList codeList -> + codeList.codes().size() == 1 ? codeList.codes().getFirst() : codeList.codes(); default -> input; }; } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequestList.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequestList.java new file mode 100644 index 0000000000..dc73576f47 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequestList.java @@ -0,0 +1,70 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import com.fasterxml.jackson.annotation.JsonIncludeProperties; +import edu.umd.cs.findbugs.annotations.NonNull; + +@JsonIncludeProperties({"operations"}) +public record HttpRequestList(AsciiDocContext context, List operations) + implements Iterable, ToAsciiDoc { + @NonNull @Override + public Iterator iterator() { + return operations.iterator(); + } + + @NonNull @Override + public String toString() { + return operations.toString(); + } + + @Override + public String list(Map options) { + var sb = new StringBuilder(); + operations.forEach( + op -> + sb.append("* `+") + .append(op) + .append("+`") + .append( + Optional.ofNullable(op.getSummary()).map(summary -> ": " + summary).orElse("")) + .append('\n')); + if (!sb.isEmpty()) { + sb.setLength(sb.length() - 1); + } + return sb.toString(); + } + + @Override + public String table(Map options) { + var sb = new StringBuilder(); + sb.append("[cols=\"1,1,3a\", options=\"header\"]").append('\n'); + sb.append("|===").append('\n'); + sb.append("|").append("Method|Path|Summary").append("\n\n"); + operations.forEach( + op -> + sb.append("|`+") + .append(op.getMethod()) + .append("+`\n") + .append("|`+") + .append(op.getPath()) + .append("+`\n") + .append("|") + .append(Optional.ofNullable(op.operation().getSummary()).orElse("")) + .append("\n\n")); + if (!sb.isEmpty()) { + sb.append("\n"); + sb.setLength(sb.length() - 1); + } + sb.append("|==="); + return sb.toString(); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Lookup.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Lookup.java index dcad7d444c..fd4d6a725f 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Lookup.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Lookup.java @@ -114,17 +114,10 @@ public Object execute( public List getArgumentNames() { return List.of("path"); } - }, - model { - @Override - public Object execute( - Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { - return schema.execute(args, self, context, lineNumber); - } @Override - public List getArgumentNames() { - return schema.getArgumentNames(); + public List alias() { + return List.of("schema", "model"); } }, tag { @@ -154,7 +147,35 @@ public Object execute( @Override public List getArgumentNames() { - return List.of(); + return List.of("code"); + } + }, + routes { + @Override + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + var asciidoc = AsciiDocContext.from(context); + var operations = asciidoc.getOpenApi().getOperations(); + var list = + operations.stream() + .filter( + it -> { + var includes = (String) args.get("includes"); + return includes == null || it.getPath().matches(includes); + }) + .map(it -> new HttpRequest(asciidoc, it, args)) + .toList(); + return new HttpRequestList(asciidoc, list); + } + + @Override + public List getArgumentNames() { + return List.of("includes"); + } + + @Override + public List alias() { + return List.of("routes", "operations"); } }, statusCode { @@ -176,7 +197,7 @@ public Object execute( return Stream.of(map); } else if (candidate instanceof Map codeMap) { var codes = new ArrayList>(); - for (var entry : codeMap.entrySet()) { + for (var entry : new TreeMap<>(codeMap).entrySet()) { var value = entry.getKey(); if (value instanceof Number code) { Map map = new LinkedHashMap<>(); @@ -227,6 +248,10 @@ public List getArgumentNames() { } }; + public List alias() { + return List.of(name()); + } + protected Map appendMethod(Map args) { Map result = new LinkedHashMap<>(args); result.put("method", name()); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/StatusCodeList.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/StatusCodeList.java index d70457488d..6b4adebeb8 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/StatusCodeList.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/StatusCodeList.java @@ -9,9 +9,11 @@ import java.util.List; import java.util.Map; +import com.fasterxml.jackson.annotation.JsonIncludeProperties; import edu.umd.cs.findbugs.annotations.NonNull; import io.jooby.internal.openapi.asciidoc.display.MapToAsciiDoc; +@JsonIncludeProperties({"codes"}) public record StatusCodeList(List> codes) implements Iterable>, ToAsciiDoc { @NonNull @Override diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java b/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java index 989b40508d..3917a0ef23 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java +++ b/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java @@ -21,22 +21,82 @@ public class PebbleSupportTest { + @OpenAPITest(value = AppLib.class) + public void routes(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + // default error map + templates + .evaluateThat("{{ routes }}") + .isEqualTo( + "[GET /library/books/{isbn}, GET /library/search, GET /library/books, POST" + + " /library/books, POST /library/authors]"); + templates + .evaluateThat("{{ routes(\"/library/books/?.*\") }}") + .isEqualTo("[GET /library/books/{isbn}, GET /library/books, POST /library/books]"); + templates.evaluate("{{ routes | json(false) }}", output -> Json31.mapper().readTree(output)); + + templates + .evaluateThat("{{ routes(\"/library/books/?.*\") | table }}") + .isEqualToIgnoringNewLines( + """ + [cols="1,1,3a", options="header"] + |=== + |Method|Path|Summary + + |`+GET+` + |`+/library/books/{isbn}+` + |Get Specific Book Details + + |`+GET+` + |`+/library/books+` + |Browse Books (Paginated) + + |`+POST+` + |`+/library/books+` + |Add New Book + + |===\ + """); + + templates + .evaluateThat("{{ routes(\"/library/books/?.*\") | list }}") + .isEqualToIgnoringNewLines( + """ + * `+GET /library/books/{isbn}+`: Get Specific Book Details + * `+GET /library/books+`: Browse Books (Paginated) + * `+POST /library/books+`: Add New Book\ + """); + } + @OpenAPITest(value = AppLib.class) public void statusCode(OpenAPIExt openapi) throws IOException { var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); // default error map templates.evaluateThat("{{ statusCode(200) }}").isEqualTo("[{code=200, reason=Success}]"); + templates + .evaluateThat("{{ statusCode(200) | json }}") + .isEqualToIgnoringNewLines( + """ + [source, json] + ---- + { + "code" : 200, + "reason" : "Success" + } + ----\ + """); + templates .evaluateThat("{{ statusCode(200) | list }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ * `+200+`: Success\ """); templates .evaluateThat("{{ statusCode(200) | table }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ |=== |code|reason @@ -49,7 +109,7 @@ public void statusCode(OpenAPIExt openapi) throws IOException { templates .evaluateThat("{{ statusCode([200, 201]) | table }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ |=== |code|reason @@ -65,7 +125,7 @@ public void statusCode(OpenAPIExt openapi) throws IOException { templates .evaluateThat("{{ statusCode([200, 201]) | list }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ * `+200+`: Success * `+201+`: Created\ @@ -73,7 +133,7 @@ public void statusCode(OpenAPIExt openapi) throws IOException { templates .evaluateThat("{{ statusCode({200: \"OK\", 500: \"Internal Server Error\"}) | list }}") - .isEqualTo( + .isEqualToIgnoringNewLines( """ * `+200+`: OK * `+500+`: Internal Server Error\ @@ -142,6 +202,22 @@ public void shouldSupportJsonSchemaInV31(OpenAPIExt openapi) throws IOException @OpenAPITest(value = AppLib.class) public void errorMap(OpenAPIExt openapi) throws IOException { var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + templates + .evaluateThat( + """ + {{ error(400) | json }} + """) + .isEqualToIgnoringNewLines( + """ + [source, json] + ---- + { + "message" : "...", + "reason" : "Bad Request", + "statusCode" : 400 + } + ----\ + """); // default error map templates .evaluateThat( From ebd88ef3efafd1f7d0cc40cd6c03ee4e44636f3e Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sun, 14 Dec 2025 18:28:17 -0300 Subject: [PATCH 24/31] - code cleanup - dynamic doc generation --- .../io/jooby/internal/openapi/OpenAPIExt.java | 11 +-- .../jooby/internal/openapi/OperationExt.java | 4 + .../openapi/asciidoc/AsciiDocContext.java | 21 +++++- .../internal/openapi/asciidoc/Display.java | 11 +-- .../openapi/asciidoc/HttpRequest.java | 12 ++- .../openapi/asciidoc/HttpRequestList.java | 11 ++- .../openapi/asciidoc/HttpResponse.java | 51 +++++++++++-- .../internal/openapi/asciidoc/Lookup.java | 7 ++ .../internal/openapi/asciidoc/Mutator.java | 33 ++++++--- .../openapi/asciidoc/ParameterList.java | 31 ++++++-- .../internal/openapi/asciidoc/TagExt.java | 31 ++++++++ .../asciidoc/display/MapToAsciiDoc.java | 7 ++ .../asciidoc/display/OpenApiToAsciiDoc.java | 13 +++- .../java/issues/i3729/api/ApiDocTest.java | 40 ++-------- .../java/issues/i3820/PebbleSupportTest.java | 73 ++++++++++++++++++- 15 files changed, 273 insertions(+), 83 deletions(-) create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/TagExt.java diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIExt.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIExt.java index 520abecb23..0004a40ab1 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIExt.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIExt.java @@ -235,13 +235,6 @@ private void setProperty(S src, Function getter, S target, BiConsum } } - public OperationExt findOperationById(String operationId) { - return getOperations().stream() - .filter(it -> it.getOperationId().equals(operationId)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Operation not found: " + operationId)); - } - public OperationExt findOperation(String method, String pattern) { Predicate filter = op -> op.getPath().equals(pattern); filter = filter.and(op -> op.getMethod().equals(method)); @@ -251,4 +244,8 @@ public OperationExt findOperation(String method, String pattern) { .orElseThrow( () -> new IllegalArgumentException("Operation not found: " + method + " " + pattern)); } + + public List findOperationByTag(String tag) { + return getOperations().stream().filter(it -> it.isOnTag(tag)).toList(); + } } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OperationExt.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OperationExt.java index 3f3806b08e..f04c8d3ab2 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OperationExt.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OperationExt.java @@ -181,6 +181,10 @@ public void addTag(Tag tag) { addTagsItem(tag.getName()); } + public boolean isOnTag(String tag) { + return globalTags.stream().map(Tag::getName).anyMatch(tag::equals); + } + public List getGlobalTags() { return globalTags; } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java index 76901f1b12..eca5f863e8 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java @@ -58,7 +58,7 @@ public class AsciiDocContext { private final AutoDataFakerMapper faker = new AutoDataFakerMapper(); - private final Map, Map> examples = new HashMap<>(); + private final Map> examples = new HashMap<>(); private final Instant now = Instant.now(); @@ -140,6 +140,19 @@ public Map getGlobalVariables() { openapiRoot.put("routes", operations); openapiRoot.put("operations", operations); + // Tags + var tags = + context.openapi.getTags().stream() + .map( + tag -> + new TagExt( + tag, + context.openapi.findOperationByTag(tag.getName()).stream() + .map(op -> new HttpRequest(context, op, Map.of())) + .toList())) + .toList(); + openapiRoot.put("tags", tags); + // make in to work without literal openapiRoot.put("query", "query"); openapiRoot.put("path", "path"); @@ -259,7 +272,9 @@ public Map error(EvaluationContext context, Map "reason" -> statusCode.reason(); case "status.code", "statusCode.code", "statusCode", "code" -> statusCode.value(); - default -> Optional.ofNullable(context.getVariable(variable)).orElse(template); + default -> + Optional.ofNullable(args.getOrDefault(variable, context.getVariable(variable))) + .orElse(template); }; mutableMap.put((String) entry.getKey(), value); } @@ -332,7 +347,7 @@ public Map schemaExample(Schema schema) { s -> traverse( new HashSet<>(), - s, + schema, (parent, property) -> { var enumItems = property.getEnum(); if (enumItems == null || enumItems.isEmpty()) { diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java index 0284f9523d..3577a3ef4d 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java @@ -8,6 +8,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.TreeMap; import io.jooby.internal.openapi.OperationExt; import io.jooby.internal.openapi.asciidoc.display.*; @@ -74,12 +75,7 @@ public Object apply( int lineNumber) throws PebbleException { var asciidoc = AsciiDocContext.from(context); - return new SafeString(toAsciidoc(asciidoc, input).table(args)); - } - - @Override - public List getArgumentNames() { - return List.of("columns"); + return new SafeString(toAsciidoc(asciidoc, input).table(new TreeMap<>(args))); } }, list { @@ -92,7 +88,7 @@ public Object apply( int lineNumber) throws PebbleException { var asciidoc = AsciiDocContext.from(context); - return new SafeString(toAsciidoc(asciidoc, input).list(args)); + return new SafeString(toAsciidoc(asciidoc, input).list(new TreeMap<>(args))); } }, curl { @@ -183,6 +179,7 @@ protected ToAsciiDoc toAsciidoc(AsciiDocContext context, Object input) { protected Object toJson(AsciiDocContext context, Object input) { return switch (input) { case Schema schema -> context.schemaProperties(schema); + case HttpResponse rsp -> toJson(context, rsp.getSucessOrError()); case StatusCodeList codeList -> codeList.codes().size() == 1 ? codeList.codes().getFirst() : codeList.codes(); default -> input; diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequest.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequest.java index 5926d30d70..6466139ef9 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequest.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequest.java @@ -57,6 +57,14 @@ public String getPath() { return operation.getPath(); } + public String getDescription() { + return operation.getDescription(); + } + + public String getSummary() { + return operation.getSummary(); + } + public List getProduces() { return operation.getProduces(); } @@ -262,8 +270,4 @@ private static Predicate inFilter(String in) { public String toString() { return getMethod() + " " + getPath(); } - - public String getSummary() { - return operation.getSummary(); - } } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequestList.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequestList.java index dc73576f47..565e82a634 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequestList.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequestList.java @@ -9,6 +9,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; import com.fasterxml.jackson.annotation.JsonIncludeProperties; import edu.umd.cs.findbugs.annotations.NonNull; @@ -46,7 +47,15 @@ public String list(Map options) { @Override public String table(Map options) { var sb = new StringBuilder(); - sb.append("[cols=\"1,1,3a\", options=\"header\"]").append('\n'); + if (options.isEmpty()) { + options.put("options", "header"); + } + options.putIfAbsent("cols", "1,1,3a"); + sb.append( + options.entrySet().stream() + .map(it -> it.getKey() + "=\"" + it.getValue() + "\"") + .collect(Collectors.joining(", ", "[", "]"))) + .append('\n'); sb.append("|===").append('\n'); sb.append("|").append("Method|Path|Summary").append("\n\n"); operations.forEach( diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpResponse.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpResponse.java index 0dbf6dbf4c..9525637e8d 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpResponse.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpResponse.java @@ -5,21 +5,23 @@ */ package io.jooby.internal.openapi.asciidoc; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonIncludeProperties; import edu.umd.cs.findbugs.annotations.NonNull; import io.jooby.StatusCode; import io.jooby.internal.openapi.OperationExt; import io.jooby.internal.openapi.ParameterExt; import io.jooby.internal.openapi.ResponseExt; +import io.pebbletemplates.pebble.template.EvaluationContext; import io.swagger.v3.oas.models.media.Schema; -@JsonIgnoreProperties({"context", "operation", "options"}) +@JsonIncludeProperties({"method", "path"}) public record HttpResponse( - AsciiDocContext context, + EvaluationContext evaluationContext, OperationExt operation, Integer statusCode, Map options) @@ -38,12 +40,47 @@ public ParameterList getCookies() { return new ParameterList(List.of(), ParameterList.NAME_DESC); } + @Override + public AsciiDocContext context() { + return AsciiDocContext.from(evaluationContext); + } + + public String getMethod() { + return operation.getMethod(); + } + + public String getPath() { + return operation.getPath(); + } + @Override public Schema getBody() { - return selectBody(getBody(getResponse()), options.getOrDefault("body", "full").toString()); + return selectBody(getBody(response()), options.getOrDefault("body", "full").toString()); + } + + public boolean isSuccess() { + return statusCode != null && statusCode >= 200 && statusCode < 300; + } + + public Object getSucessOrError() { + var response = response(); + if (response == operation.getDefaultResponse()) { + return getBody(); + } + // massage error apply global error format + var rsp = operation.getResponses().get(Integer.toString(statusCode)); + + if (rsp == null) { + // default output + return context().error(evaluationContext, Map.of("code", statusCode)); + } + var errorContext = new LinkedHashMap(); + errorContext.put("code", statusCode); + errorContext.put("message", rsp.getDescription()); + return context().error(evaluationContext, errorContext); } - private ResponseExt getResponse() { + private ResponseExt response() { if (statusCode == null) { return operation.getDefaultResponse(); } else { @@ -60,7 +97,7 @@ private ResponseExt getResponse() { public StatusCode getStatusCode() { if (statusCode == null) { - return Optional.ofNullable(getResponse()) + return Optional.ofNullable(response()) .map(it -> StatusCode.valueOf(Integer.parseInt(it.getCode()))) .orElse(StatusCode.OK); } @@ -71,7 +108,7 @@ public StatusCode getStatusCode() { private Schema getBody(ResponseExt response) { return Optional.ofNullable(response) .map(it -> toSchema(it.getContent(), List.of())) - .map(context::resolveSchema) + .map(context()::resolveSchema) .orElse(AsciiDocContext.EMPTY_SCHEMA); } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Lookup.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Lookup.java index fd4d6a725f..46feb364f6 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Lookup.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Lookup.java @@ -129,6 +129,13 @@ public Object execute( return asciidoc.getOpenApi().getTags().stream() .filter(tag -> tag.getName().equalsIgnoreCase(name)) .findFirst() + .map( + it -> + new TagExt( + it, + asciidoc.getOpenApi().findOperationByTag(it.getName()).stream() + .map(op -> new HttpRequest(asciidoc, op, Map.of())) + .toList())) .orElseThrow(() -> new NoSuchElementException("Tag not found: " + name)); } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Mutator.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Mutator.java index 8007981519..475615ae41 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Mutator.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Mutator.java @@ -14,7 +14,6 @@ import io.pebbletemplates.pebble.extension.Filter; import io.pebbletemplates.pebble.template.EvaluationContext; import io.pebbletemplates.pebble.template.PebbleTemplate; -import io.swagger.v3.oas.models.Operation; import io.swagger.v3.oas.models.media.Schema; public enum Mutator implements Filter { @@ -30,6 +29,8 @@ public Object apply( if (input instanceof Schema schema) { var asciidoc = AsciiDocContext.from(context); return asciidoc.schemaExample(schema); + } else if (input instanceof Map mapLike) { + } return input; } @@ -72,7 +73,7 @@ public Object apply( int lineNumber) throws PebbleException { return new HttpResponse( - AsciiDocContext.from(context), + context, toOperation(input), Optional.ofNullable(args.get("code")) .map(Number.class::cast) @@ -80,6 +81,11 @@ public Object apply( .orElse(null), args); } + + @Override + public List getArgumentNames() { + return List.of("code"); + } }, parameters { @Override @@ -118,6 +124,11 @@ public Object apply( int lineNumber) throws PebbleException { var bodyType = args.getOrDefault("type", "full"); + // Handle response a bit different + if (input instanceof HttpResponse rsp) { + // success or error + return rsp.getSucessOrError(); + } return toHttpMessage(context, input, Map.of("body", bodyType)).getBody(); } }, @@ -135,20 +146,24 @@ public Object apply( }; protected OperationExt toOperation(Object input) { - if (!(input instanceof OperationExt)) { - throw new IllegalArgumentException( - "Not an operation: " + input.getClass() + ", expecting: " + Operation.class); - } - return (OperationExt) input; + return switch (input) { + case OperationExt op -> op; + case HttpRequest req -> req.operation(); + case HttpResponse rsp -> rsp.operation(); + case null -> throw new NullPointerException(name() + ": requires a request/response input"); + default -> + throw new ClassCastException( + name() + ": requires a request/response input: " + input.getClass()); + }; } protected HttpMessage toHttpMessage( EvaluationContext context, Object input, Map options) { return switch (input) { - case null -> throw new NullPointerException(name() + ": requires a request/response input"); // default to http request case OperationExt op -> new HttpRequest(AsciiDocContext.from(context), op, options); case HttpMessage msg -> msg; + case null -> throw new NullPointerException(name() + ": requires a request/response input"); default -> throw new ClassCastException( name() + ": requires a request/response input: " + input.getClass()); @@ -158,10 +173,10 @@ protected HttpMessage toHttpMessage( protected HttpRequest toHttpRequest( EvaluationContext context, Object input, Map options) { return switch (input) { - case null -> throw new NullPointerException(name() + ": requires a request/response input"); // default to http request case OperationExt op -> new HttpRequest(AsciiDocContext.from(context), op, options); case HttpRequest msg -> msg; + case null -> throw new NullPointerException(name() + ": requires a request/response input"); default -> throw new ClassCastException( name() + ": requires a request/response input: " + input.getClass()); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/ParameterList.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/ParameterList.java index 8410697540..9cf432b560 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/ParameterList.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/ParameterList.java @@ -5,7 +5,7 @@ */ package io.jooby.internal.openapi.asciidoc; -import java.util.Iterator; +import java.util.AbstractList; import java.util.List; import java.util.stream.Collectors; @@ -14,23 +14,38 @@ import io.swagger.v3.oas.models.parameters.Parameter; @JsonIgnoreProperties({"includes"}) -public record ParameterList(List parameters, List includes) - implements Iterable { +public class ParameterList extends AbstractList { public static final List NAME_DESC = List.of("name", "description"); public static final List NAME_TYPE_DESC = List.of("name", "type", "description"); public static final List PARAM = List.of("name", "type", "in", "description"); + private final List parameters; + private final List includes; - @NonNull @Override - public Iterator iterator() { - return parameters.iterator(); + public ParameterList(List parameters, List includes) { + this.parameters = parameters; + this.includes = includes; + } + + public List parameters() { + return parameters; } - public boolean isEmpty() { - return parameters.isEmpty(); + public List includes() { + return includes; + } + + @Override + public int size() { + return parameters.size(); } @NonNull @Override public String toString() { return parameters.stream().map(Parameter::getName).collect(Collectors.joining(", ")); } + + @Override + public Parameter get(int index) { + return parameters.get(index); + } } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/TagExt.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/TagExt.java new file mode 100644 index 0000000000..7655775d00 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/TagExt.java @@ -0,0 +1,31 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.List; + +import io.swagger.v3.oas.models.tags.Tag; + +public class TagExt extends Tag { + + private final List operations; + + public TagExt(Tag tag, List operations) { + setDescription(tag.getDescription()); + setName(tag.getName()); + setExternalDocs(tag.getExternalDocs()); + setExtensions(tag.getExtensions()); + this.operations = operations; + } + + public List getOperations() { + return operations; + } + + public List getRoutes() { + return operations; + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/MapToAsciiDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/MapToAsciiDoc.java index 0d12448436..2f1511fd84 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/MapToAsciiDoc.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/MapToAsciiDoc.java @@ -30,6 +30,13 @@ public String list(Map options) { public String table(Map options) { var sb = new StringBuilder(); + if (!options.isEmpty()) { + sb.append( + options.entrySet().stream() + .map(it -> it.getKey() + "=\"" + it.getValue() + "\"") + .collect(Collectors.joining(", ", "[", "]"))) + .append('\n'); + } sb.append("|===").append('\n'); if (!rows.isEmpty()) { sb.append(rows.getFirst().keySet().stream().collect(Collectors.joining("|", "|", ""))) diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/OpenApiToAsciiDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/OpenApiToAsciiDoc.java index 8e854ce1ac..22185430cc 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/OpenApiToAsciiDoc.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/OpenApiToAsciiDoc.java @@ -114,6 +114,7 @@ public String list(Map options) { public String table(Map options) { var isEnum = properties.get(ROOT) instanceof EnumSchema; var columns = (List) options.getOrDefault("columns", this.columns); + options.remove("columns"); var colList = colList(columns); var sb = new StringBuilder(); sb.append("|===").append('\n'); @@ -153,11 +154,19 @@ public String table(Map options) { }); } sb.append("|==="); - return colsToString(colList) + "\n" + sb; + options.putIfAbsent("cols", colsToString(colList)); + if (options.size() == 1) { + options.put("options", "header"); + } + return options.entrySet().stream() + .map(e -> e.getKey() + "=\"" + e.getValue() + "\"") + .collect(Collectors.joining(", ", "[", "]")) + + "\n" + + sb; } private String colsToString(List cols) { - return cols.stream().collect(Collectors.joining(",", "[cols=\"", "\", options=\"header\"]")); + return String.join(",", cols); } private List colList(List names) { diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java index 380065b552..9fab258dda 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java @@ -330,23 +330,9 @@ public void shouldGenerateAdoc(OpenAPIResult result) { [source, json] ---- { - "isbn" : "string", - "title" : "string", - "publicationDate" : "date", - "text" : "string", - "type" : "string", - "authors" : [ { - "ssn" : "string", - "name" : "string", - "address" : { - "street" : "string", - "city" : "string", - "state" : "string", - "country" : "string" - }, - "books" : [ { } ] - } ], - "image" : "binary" + "message" : "Bad Request: For bad ISBN code.", + "reason" : "Bad Request", + "statusCode" : 400 } ---- @@ -354,23 +340,9 @@ public void shouldGenerateAdoc(OpenAPIResult result) { [source, json] ---- { - "isbn" : "string", - "title" : "string", - "publicationDate" : "date", - "text" : "string", - "type" : "string", - "authors" : [ { - "ssn" : "string", - "name" : "string", - "address" : { - "street" : "string", - "city" : "string", - "state" : "string", - "country" : "string" - }, - "books" : [ { } ] - } ], - "image" : "binary" + "message" : "Not Found: If a book doesn't exist.", + "reason" : "Not Found", + "statusCode" : 404 } ---- diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java b/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java index 3917a0ef23..d191366fcf 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java +++ b/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java @@ -24,6 +24,30 @@ public class PebbleSupportTest { @OpenAPITest(value = AppLib.class) public void routes(OpenAPIExt openapi) throws IOException { var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + + templates + .evaluateThat("{{ routes(\"/library/books/?.*\") | table(grid=\"rows\") }}") + .isEqualToIgnoringNewLines( + """ + [cols="1,1,3a", grid="rows"] + |=== + |Method|Path|Summary + + |`+GET+` + |`+/library/books/{isbn}+` + |Get Specific Book Details + + |`+GET+` + |`+/library/books+` + |Browse Books (Paginated) + + |`+POST+` + |`+/library/books+` + |Add New Book + + |===\ + """); + // default error map templates .evaluateThat("{{ routes }}") @@ -323,6 +347,38 @@ public void tags(OpenAPIExt openapi) throws IOException { "Outlines the available actions in the Library System API. The system is designed to" + " allow users to search for books, view details, and manage the library" + " inventory."); + + templates + .evaluateThat( + """ + {% for tag in tags %} + == {{ tag.name }} + {{ tag.description }} + + // 2. Loop through all routes associated with this tag + {% for route in tag.routes %} + === {{ route.summary }} + {{ route.description }} + + *URL:* `{{ route.path }}` ({{ route.method }}) + {% if route.parameters is not empty %} + *Parameters:* + {{ route | parameters | table }} + {% endif %} + // Only show Request Body if it exists (e.g. for POST/PUT) + {% if route.body %} + *Data Payload:* + {{ route | request | body | example | json }} + {% endif %} + // Example response for success + .Response + {{ route | response(200) | json }} + {% endfor %} + {% endfor %} + """) + .isEqualToIgnoringNewLines( + """ + """); } @OpenAPITest(value = AppLib.class) @@ -583,6 +639,20 @@ public void response(OpenAPIExt openapi) throws IOException { var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); /* Error response code: */ + templates + .evaluateThat("{{GET(\"/library/books/{isbn}\") | response(code=404) | json }}") + .isEqualToIgnoringNewLines( + """ + [source, json] + ---- + { + "message" : "Not Found: error if it doesn't exist.", + "reason" : "Not Found", + "statusCode" : 404 + } + ----\ + """); + templates .evaluateThat("{{GET(\"/library/books/{isbn}\") | response(code=404) | http }}") .isEqualToIgnoringNewLines( @@ -988,7 +1058,8 @@ public void request(OpenAPIExt openapi) throws IOException { templates .evaluateThat( - "{{POST(\"/library/books\") | request | parameters(header) | table(['name']) }}") + "{{POST(\"/library/books\") | request | parameters(header) | table(columns=['name'])" + + " }}") .isEqualToIgnoringNewLines( """ [cols="1", options="header"] From bbcf676ece599ca6ecf5a47ede5f670f0f8a2041 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sun, 14 Dec 2025 18:42:25 -0300 Subject: [PATCH 25/31] - implement dyamic display based on tags - ref #3820 --- .../openapi/asciidoc/AsciiDocContext.java | 9 +- .../openapi/asciidoc/HttpMessage.java | 2 +- .../openapi/asciidoc/HttpRequest.java | 29 +- .../openapi/asciidoc/HttpResponse.java | 3 +- .../asciidoc/display/RequestToCurl.java | 2 +- .../asciidoc/display/RequestToHttp.java | 2 +- .../asciidoc/display/ResponseToHttp.java | 2 +- .../java/issues/i3820/PebbleSupportTest.java | 267 +++++++++++++++++- 8 files changed, 286 insertions(+), 30 deletions(-) diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java index eca5f863e8..faa48eb3ec 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java @@ -33,7 +33,6 @@ import io.pebbletemplates.pebble.extension.AbstractExtension; import io.pebbletemplates.pebble.extension.Filter; import io.pebbletemplates.pebble.extension.Function; -import io.pebbletemplates.pebble.lexer.Syntax; import io.pebbletemplates.pebble.loader.ClasspathLoader; import io.pebbletemplates.pebble.loader.DelegatingLoader; import io.pebbletemplates.pebble.loader.FileLoader; @@ -44,7 +43,6 @@ public class AsciiDocContext { public static final BiConsumer> NOOP = (name, schema) -> {}; - public static final Schema EMPTY_SCHEMA = new Schema<>(); private ObjectMapper json; @@ -133,7 +131,7 @@ public Map getGlobalVariables() { "...")); // Routes var operations = - context.openapi.getOperations().stream() + Optional.of(context.openapi.getOperations()).orElse(List.of()).stream() .map(op -> new HttpRequest(context, op, Map.of())) .toList(); // so we can print routes without calling function: routes() vs routes @@ -142,7 +140,7 @@ public Map getGlobalVariables() { // Tags var tags = - context.openapi.getTags().stream() + Optional.ofNullable(context.openapi.getTags()).orElse(List.of()).stream() .map( tag -> new TagExt( @@ -236,7 +234,6 @@ public Map getFilters() { .collect(Collectors.toMap(Enum::name, it -> wrapFilter(it.name(), it))); } }) - .syntax(new Syntax.Builder().setEnableNewLineTrimming(false).build()) .build(); } @@ -384,7 +381,7 @@ private Map traverse( SneakyThrows.Function2, Schema, String> valueMapper, BiConsumer> consumer, BiConsumer> inner) { - if (schema == EMPTY_SCHEMA) { + if (schema == null) { return Map.of(); } var resolved = resolveSchema(schema); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpMessage.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpMessage.java index 85a5a01cbc..3fa39d3c08 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpMessage.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpMessage.java @@ -21,7 +21,7 @@ public interface HttpMessage { AsciiDocContext context(); default Schema selectBody(Schema body, String modifier) { - if (body != AsciiDocContext.EMPTY_SCHEMA) { + if (body != null) { return switch (modifier) { case "full" -> body; case "simple" -> context().reduceSchema(body); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequest.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequest.java index 6466139ef9..c37dddd7d0 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequest.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequest.java @@ -151,13 +151,12 @@ public String getQueryString(Map filter) { return ""; } - @SuppressWarnings("unchecked") private Schema getBody(List contentType) { var body = Optional.ofNullable(operation.getRequestBody()) .map(it -> toSchema(it.getContent(), contentType)) .map(context::resolveSchema) - .orElse(AsciiDocContext.EMPTY_SCHEMA); + .orElse(null); return selectBody(body, options.getOrDefault("body", "full").toString()); } @@ -170,7 +169,7 @@ public Schema getForm() { BiFunction, Map.Entry, Map.Entry> formatter) { var output = ArrayListMultimap.create(); var form = getForm(); - if (form != AsciiDocContext.EMPTY_SCHEMA) { + if (form != null) { traverseSchema(null, form, formatter, output::put); } return output; @@ -232,23 +231,21 @@ public ParameterList getAllParameters() { var parameters = allParameters(); var body = getForm(); var bodyType = "form"; - if (body == AsciiDocContext.EMPTY_SCHEMA) { + if (body == null) { body = getBody(); bodyType = "body"; } var paramType = bodyType; - if (body != AsciiDocContext.EMPTY_SCHEMA) { - context.traverseSchema( - body, - (propertyName, schema) -> { - var p = new Parameter(); - p.setName(propertyName); - p.setSchema(schema); - p.setIn(paramType); - p.setDescription(schema.getDescription()); - parameters.add(p); - }); - } + context.traverseSchema( + body, + (propertyName, schema) -> { + var p = new Parameter(); + p.setName(propertyName); + p.setSchema(schema); + p.setIn(paramType); + p.setDescription(schema.getDescription()); + parameters.add(p); + }); return new ParameterList(parameters, ParameterList.PARAM); } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpResponse.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpResponse.java index 9525637e8d..097cd2fb9b 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpResponse.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpResponse.java @@ -104,12 +104,11 @@ public StatusCode getStatusCode() { return StatusCode.valueOf(statusCode); } - @SuppressWarnings("unchecked") private Schema getBody(ResponseExt response) { return Optional.ofNullable(response) .map(it -> toSchema(it.getContent(), List.of())) .map(context()::resolveSchema) - .orElse(AsciiDocContext.EMPTY_SCHEMA); + .orElse(null); } @NonNull @Override diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/RequestToCurl.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/RequestToCurl.java index 457c71f249..e3937c9a40 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/RequestToCurl.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/RequestToCurl.java @@ -60,7 +60,7 @@ public String render(Map args) { }); if (formUrlEncoded.isEmpty()) { var body = request.getBody(); - if (body != AsciiDocContext.EMPTY_SCHEMA) { + if (body != null) { options.put("-d", "'" + context.toJson(context.schemaProperties(body), false) + "'"); } } else { diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/RequestToHttp.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/RequestToHttp.java index bd97c2dad4..387e11de7c 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/RequestToHttp.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/RequestToHttp.java @@ -39,7 +39,7 @@ public String render(Map options) { .append('\n'); } var schema = request.getBody(); - if (schema != AsciiDocContext.EMPTY_SCHEMA) { + if (schema != null) { sb.append(context.toJson(context.schemaProperties(schema), false)).append('\n'); } return sb.append("----").toString(); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/ResponseToHttp.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/ResponseToHttp.java index 07983b5f0b..313055d1d0 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/ResponseToHttp.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/ResponseToHttp.java @@ -28,7 +28,7 @@ public String render(Map options) { sb.append(header.getName()).append(": ").append(value).append('\n'); } var schema = response.getBody(); - if (schema != AsciiDocContext.EMPTY_SCHEMA) { + if (schema != null) { sb.append(context.getJson().writeValueAsString(context.schemaProperties(schema))) .append('\n'); } diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java b/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java index d191366fcf..4f9654a020 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java +++ b/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java @@ -361,15 +361,18 @@ public void tags(OpenAPIExt openapi) throws IOException { {{ route.description }} *URL:* `{{ route.path }}` ({{ route.method }}) + {% if route.parameters is not empty %} *Parameters:* {{ route | parameters | table }} {% endif %} + // Only show Request Body if it exists (e.g. for POST/PUT) - {% if route.body %} + {% if route.body is not null %} *Data Payload:* - {{ route | request | body | example | json }} + {{ route | request | body | json }} {% endif %} + // Example response for success .Response {{ route | response(200) | json }} @@ -378,6 +381,266 @@ public void tags(OpenAPIExt openapi) throws IOException { """) .isEqualToIgnoringNewLines( """ + == Library + Outlines the available actions in the Library System API. The system is designed to allow users to search for books, view details, and manage the library inventory. + + // 2. Loop through all routes associated with this tag + + === Get Specific Book Details + View the full information for a single specific book using its unique ISBN. + + *URL:* `/library/books/{isbn}` (GET) + + *Parameters:* + [cols="1,1,1,3", options="header"] + |=== + |Name|Type|In|Description + |`+isbn+` + |`+string+` + |`+path+` + |The unique ID from the URL (e.g., /books/978-3-16-148410-0) + + |=== + + // Only show Request Body if it exists (e.g. for POST/PUT) + + // Example response for success + .Response + [source, json] + ---- + { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "publisher" : { + "id" : "int64", + "name" : "string" + }, + "authors" : [ { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "zip" : "string" + } + } ] + } + ---- + + === Quick Search + Find books by a partial title (e.g., searching "Harry" finds "Harry Potter"). + + *URL:* `/library/search` (GET) + + *Parameters:* + [cols="1,1,1,3", options="header"] + |=== + |Name|Type|In|Description + |`+q+` + |`+string+` + |`+query+` + |The word or phrase to search for. + + |=== + + // Only show Request Body if it exists (e.g. for POST/PUT) + + // Example response for success + .Response + [source, json] + ---- + { } + ---- + + === Browse Books (Paginated) + Look up a specific book title where there might be many editions or copies, splitting the results into manageable pages. + + *URL:* `/library/books` (GET) + + *Parameters:* + [cols="1,1,1,3", options="header"] + |=== + |Name|Type|In|Description + |`+Accept+` + |`+string+` + |`+header+` + | + + |`+title+` + |`+string+` + |`+query+` + |The exact book title to filter by. + + |`+page+` + |`+int32+` + |`+query+` + |Which page number to load (defaults to 1). + + |`+size+` + |`+int32+` + |`+query+` + |How many books to show per page (defaults to 20). + + |=== + + // Only show Request Body if it exists (e.g. for POST/PUT) + + // Example response for success + .Response + [source, json] + ---- + { + "content" : [ { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "publisher" : { + "id" : "int64", + "name" : "string" + }, + "authors" : [ { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "zip" : "string" + } + } ] + } ], + "numberOfElements" : "int32", + "totalElements" : "int64", + "totalPages" : "int64", + "pageRequest" : { + "page" : "int64", + "size" : "int32" + }, + "nextPageRequest" : { }, + "previousPageRequest" : { } + } + ---- + + + == Inventory + Managing Inventory + + // 2. Loop through all routes associated with this tag + + === Add New Book + Register a new book in the system. + + *URL:* `/library/books` (POST) + + *Parameters:* + [cols="1,1,1,3", options="header"] + |=== + |Name|Type|In|Description + |`+Accept+` + |`+string+` + |`+header+` + | + + |`+Content-Type+` + |`+string+` + |`+header+` + | + + |=== + + // Only show Request Body if it exists (e.g. for POST/PUT) + + *Data Payload:* + [source, json] + ---- + { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "publisher" : { + "id" : "int64", + "name" : "string" + }, + "authors" : [ { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "zip" : "string" + } + } ] + } + ---- + + // Example response for success + .Response + [source, json] + ---- + { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "publisher" : { + "id" : "int64", + "name" : "string" + }, + "authors" : [ { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "zip" : "string" + } + } ] + } + ---- + + === Add New Author + + + *URL:* `/library/authors` (POST) + + // Only show Request Body if it exists (e.g. for POST/PUT) + + *Data Payload:* + [source, json] + ---- + { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "zip" : "string" + } + } + ---- + + // Example response for success + .Response + [source, json] + ---- + { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "zip" : "string" + } + } + ---- + \ """); } From daf60906da1c7701771bcc36d134def3ff904237 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 15 Dec 2025 10:13:44 -0300 Subject: [PATCH 26/31] link: add link to schema filter - bug fixing - make jakarata Page fully embedded --- .../internal/openapi/ArrayLikeSchema.java | 23 +++ .../jooby/internal/openapi/ParserContext.java | 16 +- .../openapi/asciidoc/AsciiDocContext.java | 97 ++++++---- .../internal/openapi/asciidoc/Display.java | 37 +++- .../openapi/asciidoc/HttpRequest.java | 9 + .../internal/openapi/asciidoc/Mutator.java | 2 - .../asciidoc/display/ResponseToHttp.java | 3 +- .../asciidoc/PebbleTemplateSupport.java | 2 +- .../java/issues/i3729/api/ApiDocTest.java | 38 ++-- .../src/test/java/issues/i3820/App3820b.java | 22 +++ .../java/issues/i3820/PebbleSupportTest.java | 179 ++++++++++++++++-- .../test/java/issues/i3820/app/AppLib.java | 7 + .../test/java/issues/i3820/app/LibApi.java | 5 + 13 files changed, 353 insertions(+), 87 deletions(-) create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ArrayLikeSchema.java create mode 100644 modules/jooby-openapi/src/test/java/issues/i3820/App3820b.java diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ArrayLikeSchema.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ArrayLikeSchema.java new file mode 100644 index 0000000000..e853acf5b3 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ArrayLikeSchema.java @@ -0,0 +1,23 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.models.media.Schema; + +@JsonIgnoreProperties({"items"}) +public class ArrayLikeSchema extends Schema { + + public static ArrayLikeSchema create(Schema schema, Schema items) { + var arrayLikeSchema = new ArrayLikeSchema<>(); + arrayLikeSchema.setItems(items); + arrayLikeSchema.setProperties(schema.getProperties()); + arrayLikeSchema.setType(schema.getType()); + arrayLikeSchema.setTypes(schema.getTypes()); + arrayLikeSchema.setName(schema.getName()); + return arrayLikeSchema; + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java index 9acac6cd72..ecaa165cbb 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java @@ -450,15 +450,23 @@ private Schema schema(JavaType type) { // must be embedded it mimics a List. This is bc it might have a different item type // per operation. var pageSchema = converters.read(type.getRawClass()).get("Page"); - // force loading of PageRequest - schema(PageRequest.class); + + var pageRequestSchema = converters.read(PageRequest.class).get("PageRequest"); + pageSchema.getProperties().put("pageRequest", pageRequestSchema); + pageSchema.getProperties().put("nextPageRequest", pageRequestSchema); + pageSchema.getProperties().put("previousPageRequest", pageRequestSchema); var params = type.getBindings().getTypeParameters(); + Schema element; if (params != null && !params.isEmpty()) { + element = schema(params.getFirst()); Schema contentSchema = (Schema) pageSchema.getProperties().get("content"); - contentSchema.setItems(schema(params.getFirst())); + contentSchema.setItems(element); + } else { + element = new Schema<>(); + element.setType("object"); } - return pageSchema; + return ArrayLikeSchema.create(pageSchema, element); } return schema(type.getRawClass()); } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java index faa48eb3ec..b2b20e50a7 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java @@ -5,6 +5,7 @@ */ package io.jooby.internal.openapi.asciidoc; +import static io.swagger.v3.oas.models.Components.COMPONENTS_SCHEMAS_REF; import static java.util.Optional.ofNullable; import java.io.IOException; @@ -33,6 +34,7 @@ import io.pebbletemplates.pebble.extension.AbstractExtension; import io.pebbletemplates.pebble.extension.Filter; import io.pebbletemplates.pebble.extension.Function; +import io.pebbletemplates.pebble.lexer.Syntax; import io.pebbletemplates.pebble.loader.ClasspathLoader; import io.pebbletemplates.pebble.loader.DelegatingLoader; import io.pebbletemplates.pebble.loader.FileLoader; @@ -56,7 +58,7 @@ public class AsciiDocContext { private final AutoDataFakerMapper faker = new AutoDataFakerMapper(); - private final Map> examples = new HashMap<>(); + private final Map, Map> examples = new HashMap<>(); private final Instant now = Instant.now(); @@ -111,6 +113,7 @@ private static PebbleEngine createEngine( return new PebbleEngine.Builder() .autoEscaping(false) .loader(new DelegatingLoader(loaders)) + .syntax(new Syntax.Builder().setEnableNewLineTrimming(false).build()) .extension( new AbstractExtension() { @Override @@ -131,9 +134,11 @@ public Map getGlobalVariables() { "...")); // Routes var operations = - Optional.of(context.openapi.getOperations()).orElse(List.of()).stream() - .map(op -> new HttpRequest(context, op, Map.of())) - .toList(); + new HttpRequestList( + context, + Optional.of(context.openapi.getOperations()).orElse(List.of()).stream() + .map(op -> new HttpRequest(context, op, Map.of())) + .toList()); // so we can print routes without calling function: routes() vs routes openapiRoot.put("routes", operations); openapiRoot.put("operations", operations); @@ -150,6 +155,12 @@ public Map getGlobalVariables() { .toList())) .toList(); openapiRoot.put("tags", tags); + // Schemas + var components = context.openapi.getComponents(); + if (components != null && components.getSchemas() != null) { + var schemas = components.getSchemas(); + openapiRoot.put("schemas", schemas); + } // make in to work without literal openapiRoot.put("query", "query"); @@ -286,7 +297,7 @@ public String schemaType(Schema schema) { return Optional.ofNullable(resolved.getFormat()).orElse(resolveType(resolved)); } - private String resolveType(Schema schema) { + public String resolveType(Schema schema) { var resolved = resolveSchema(schema); if (resolved.getType() == null) { return resolved.getTypes().iterator().next(); @@ -302,7 +313,12 @@ public Schema resolveSchema(Schema schema) { return schema; } - public Map schemaProperties(Schema schema) { + public Object schemaProperties(Schema schema) { + var resolved = resolveSchema(schema); + var resolvedType = resolveType(resolved); + if ("array".equals(resolvedType)) { + return List.of(traverse(resolved.getItems(), NOOP)); + } return traverse(schema, NOOP); } @@ -338,25 +354,33 @@ public Schema emptySchema(Schema schema) { return empty; } - public Map schemaExample(Schema schema) { - return examples.computeIfAbsent( - schema, - s -> - traverse( - new HashSet<>(), - schema, - (parent, property) -> { - var enumItems = property.getEnum(); - if (enumItems == null || enumItems.isEmpty()) { - var type = schemaType(property); - var gen = faker.getGenerator(parent.getName(), property.getName(), type, type); - return gen.get(); - } else { - return enumItems.get(new Random().nextInt(enumItems.size())).toString(); - } - }, - NOOP, - NOOP)); + public Object schemaExample(Schema schema) { + var resolved = resolveSchema(schema); + var resolvedType = resolveType(resolved); + var target = resolved; + if ("array".equals(resolvedType)) { + target = resolveSchema(resolved.getItems()); + } + var result = + examples.computeIfAbsent( + target, + key -> + traverse( + new HashSet<>(), + key, + (parent, property) -> { + var enumItems = property.getEnum(); + if (enumItems == null || enumItems.isEmpty()) { + var type = schemaType(property); + var gen = + faker.getGenerator(parent.getName(), property.getName(), type, type); + return gen.get(); + } else { + return enumItems.get(new Random().nextInt(enumItems.size())).toString(); + } + }, + NOOP)); + return "array".equals(resolvedType) ? List.of(result) : result; } public void traverseSchema(Schema schema, BiConsumer> consumer) { @@ -364,23 +388,14 @@ public void traverseSchema(Schema schema, BiConsumer> consu } private Map traverse(Schema schema, BiConsumer> consumer) { - return traverse(schema, consumer, NOOP); - } - - private Map traverse( - Schema schema, - BiConsumer> consumer, - BiConsumer> inner) { - return traverse( - new HashSet<>(), schema, (parent, property) -> schemaType(property), consumer, inner); + return traverse(new HashSet<>(), schema, (parent, property) -> schemaType(property), consumer); } private Map traverse( Set visited, Schema schema, SneakyThrows.Function2, Schema, String> valueMapper, - BiConsumer> consumer, - BiConsumer> inner) { + BiConsumer> consumer) { if (schema == null) { return Map.of(); } @@ -395,13 +410,11 @@ private Map traverse( var valueType = resolveType(resolvedValue); consumer.accept(name, resolvedValue); if ("object".equals(valueType)) { - result.put(name, traverse(visited, resolvedValue, valueMapper, inner, inner)); + result.put(name, traverse(visited, resolvedValue, valueMapper, NOOP)); } else if ("array".equals(valueType)) { var array = ofNullable(resolvedValue.getItems()) - .map( - items -> - traverse(visited, resolveSchema(items), valueMapper, inner, inner)) + .map(items -> traverse(visited, resolveSchema(items), valueMapper, NOOP)) .map(List::of) .orElse(List.of()); result.put(name, array); @@ -442,8 +455,8 @@ private Optional> resolveSchemaInternal(String name) { if (components == null || components.getSchemas() == null) { throw new NoSuchElementException("No schema found"); } - if (name.startsWith("#/components/schemas/")) { - name = name.substring("#/components/schemas/".length()); + if (name.startsWith(COMPONENTS_SCHEMAS_REF)) { + name = name.substring(COMPONENTS_SCHEMAS_REF.length()); } return Optional.ofNullable((Schema) components.getSchemas().get(name)); } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java index 3577a3ef4d..1fb4ee3744 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java @@ -5,10 +5,7 @@ */ package io.jooby.internal.openapi.asciidoc; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.TreeMap; +import java.util.*; import io.jooby.internal.openapi.OperationExt; import io.jooby.internal.openapi.asciidoc.display.*; @@ -91,6 +88,38 @@ public Object apply( return new SafeString(toAsciidoc(asciidoc, input).list(new TreeMap<>(args))); } }, + link { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + var schema = + switch (input) { + case Schema s -> s; + case HttpMessage msg -> msg.getBody(); + default -> throw new IllegalArgumentException("Can't render: " + input); + }; + var asciidoc = AsciiDocContext.from(context); + var resolved = asciidoc.resolveSchema(schema); + var target = resolved; + var prefix = ""; + var suffix = ""; + if (resolved.getItems() != null) { + target = asciidoc.resolveSchema(resolved.getItems()); + prefix = Optional.ofNullable(resolved.getName()).orElse("") + "["; + suffix = "]"; + } + if ("object".equals(asciidoc.resolveType(target))) { + return new SafeString(prefix + "<<" + target.getName() + ">>" + suffix); + } + // no link for basic types + return prefix + target.getName() + suffix; + } + }, curl { @Override public Object apply( diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequest.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequest.java index c37dddd7d0..121eddddfc 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequest.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequest.java @@ -21,6 +21,7 @@ import io.jooby.internal.openapi.ParameterExt; import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.parameters.Parameter; +import io.swagger.v3.oas.models.security.SecurityRequirement; @JsonIncludeProperties({"path", "method"}) public record HttpRequest( @@ -222,6 +223,14 @@ private void encode( } } + public boolean isDeprecated() { + return operation.getDeprecated() == Boolean.TRUE; + } + + public List getSecurity() { + return operation.getSecurity(); + } + @Override public Schema getBody() { return getBody(List.of()); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Mutator.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Mutator.java index 475615ae41..3d85d4b458 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Mutator.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Mutator.java @@ -29,8 +29,6 @@ public Object apply( if (input instanceof Schema schema) { var asciidoc = AsciiDocContext.from(context); return asciidoc.schemaExample(schema); - } else if (input instanceof Map mapLike) { - } return input; } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/ResponseToHttp.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/ResponseToHttp.java index 313055d1d0..545572c473 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/ResponseToHttp.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/ResponseToHttp.java @@ -29,8 +29,7 @@ public String render(Map options) { } var schema = response.getBody(); if (schema != null) { - sb.append(context.getJson().writeValueAsString(context.schemaProperties(schema))) - .append('\n'); + sb.append(context.toJson(context.schemaProperties(schema), false)).append('\n'); } return sb.append("----").toString(); } catch (Exception x) { diff --git a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/PebbleTemplateSupport.java b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/PebbleTemplateSupport.java index 116320106b..a1decd839a 100644 --- a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/PebbleTemplateSupport.java +++ b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/PebbleTemplateSupport.java @@ -42,6 +42,6 @@ public String evaluate(String input) throws IOException { var template = context.getEngine().getLiteralTemplate(input); var writer = new StringWriter(); template.evaluate(writer); - return writer.toString(); + return writer.toString().trim(); } } diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java index 9fab258dda..d8bc609cc7 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java @@ -135,11 +135,32 @@ public void shouldGenerateGoodDoc(OpenAPIResult result) { type: integer format: int64 pageRequest: - $ref: "#/components/schemas/PageRequest" + type: object + properties: + page: + type: integer + format: int64 + size: + type: integer + format: int32 nextPageRequest: - $ref: "#/components/schemas/PageRequest" + type: object + properties: + page: + type: integer + format: int64 + size: + type: integer + format: int32 previousPageRequest: - $ref: "#/components/schemas/PageRequest" + type: object + properties: + page: + type: integer + format: int64 + size: + type: integer + format: int32 post: tags: - Inventory @@ -178,15 +199,6 @@ public void shouldGenerateGoodDoc(OpenAPIResult result) { type: string description: Two digit country code. description: Author address. - PageRequest: - type: object - properties: - page: - type: integer - format: int64 - size: - type: integer - format: int32 Book: type: object properties: @@ -237,7 +249,7 @@ public void shouldGenerateGoodDoc(OpenAPIResult result) { type: array description: Published books. items: - $ref: "#/components/schemas/Book" + $ref: "#/components/schemas/Book"\ """); } diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/App3820b.java b/modules/jooby-openapi/src/test/java/issues/i3820/App3820b.java new file mode 100644 index 0000000000..a6dc1281a7 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3820/App3820b.java @@ -0,0 +1,22 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3820; + +import java.util.ArrayList; +import java.util.List; + +import io.jooby.Jooby; + +public class App3820b extends Jooby { + { + get( + "/strings", + ctx -> { + List strings = new ArrayList<>(); + return strings; + }); + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java b/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java index 4f9654a020..7e82952192 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java +++ b/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java @@ -167,6 +167,8 @@ public void statusCode(OpenAPIExt openapi) throws IOException { @OpenAPITest(value = AppLibrary.class) public void bodyBug(OpenAPIExt openapi) throws IOException { var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + + templates.evaluateThat("{{ GET(\"/api/library/{isbn}\") | response | body | json(false) }}"); templates .evaluateThat("{{ GET(\"/api/library/{isbn}\") | response | body | json(false) }}") .isEqualToIgnoringNewLines( @@ -353,11 +355,13 @@ public void tags(OpenAPIExt openapi) throws IOException { """ {% for tag in tags %} == {{ tag.name }} + {{ tag.description }} // 2. Loop through all routes associated with this tag {% for route in tag.routes %} === {{ route.summary }} + {{ route.description }} *URL:* `{{ route.path }}` ({{ route.method }}) @@ -376,6 +380,22 @@ public void tags(OpenAPIExt openapi) throws IOException { // Example response for success .Response {{ route | response(200) | json }} + + {% if route.security is not empty %} + .Required Permissions + [cols="1,3"] + |=== + |Type | Scopes + + {# Iterate through security schemes #} + {% for scheme in route.security %} + {% for req in scheme %} + | *{{ req.key }}* | {{ req.value | join(", ") }} + {% endfor %} + {% endfor %} + |=== + + {% endif %} {% endfor %} {% endfor %} """) @@ -383,12 +403,9 @@ public void tags(OpenAPIExt openapi) throws IOException { """ == Library Outlines the available actions in the Library System API. The system is designed to allow users to search for books, view details, and manage the library inventory. - // 2. Loop through all routes associated with this tag - === Get Specific Book Details View the full information for a single specific book using its unique ISBN. - *URL:* `/library/books/{isbn}` (GET) *Parameters:* @@ -401,7 +418,6 @@ public void tags(OpenAPIExt openapi) throws IOException { |The unique ID from the URL (e.g., /books/978-3-16-148410-0) |=== - // Only show Request Body if it exists (e.g. for POST/PUT) // Example response for success @@ -429,10 +445,15 @@ public void tags(OpenAPIExt openapi) throws IOException { } ] } ---- + .Required Permissions + [cols="1,3"] + |=== + |Type | Scopes + + | *librarySecurity* | read:books|=== === Quick Search Find books by a partial title (e.g., searching "Harry" finds "Harry Potter"). - *URL:* `/library/search` (GET) *Parameters:* @@ -445,19 +466,42 @@ Find books by a partial title (e.g., searching "Harry" finds "Harry Potter"). |The word or phrase to search for. |=== - // Only show Request Body if it exists (e.g. for POST/PUT) // Example response for success .Response [source, json] ---- - { } + [ { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "publisher" : { + "id" : "int64", + "name" : "string" + }, + "authors" : [ { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "zip" : "string" + } + } ] + } ] ---- + .Required Permissions + [cols="1,3"] + |=== + |Type | Scopes + + | *librarySecurity* | read:books|=== === Browse Books (Paginated) Look up a specific book title where there might be many editions or copies, splitting the results into manageable pages. - *URL:* `/library/books` (GET) *Parameters:* @@ -485,7 +529,6 @@ Find books by a partial title (e.g., searching "Harry" finds "Harry Potter"). |How many books to show per page (defaults to 20). |=== - // Only show Request Body if it exists (e.g. for POST/PUT) // Example response for success @@ -524,16 +567,18 @@ Find books by a partial title (e.g., searching "Harry" finds "Harry Potter"). "previousPageRequest" : { } } ---- + .Required Permissions + [cols="1,3"] + |=== + |Type | Scopes + | *librarySecurity* | read:books|=== == Inventory Managing Inventory - // 2. Loop through all routes associated with this tag - === Add New Book Register a new book in the system. - *URL:* `/library/books` (POST) *Parameters:* @@ -551,9 +596,7 @@ Find books by a partial title (e.g., searching "Harry" finds "Harry Potter"). | |=== - // Only show Request Body if it exists (e.g. for POST/PUT) - *Data Payload:* [source, json] ---- @@ -578,7 +621,6 @@ Find books by a partial title (e.g., searching "Harry" finds "Harry Potter"). } ] } ---- - // Example response for success .Response [source, json] @@ -604,14 +646,19 @@ Find books by a partial title (e.g., searching "Harry" finds "Harry Potter"). } ] } ---- + .Required Permissions + [cols="1,3"] + |=== + |Type | Scopes - === Add New Author + | *librarySecurity* | write:books|=== + === Add New Author *URL:* `/library/authors` (POST) - // Only show Request Body if it exists (e.g. for POST/PUT) + // Only show Request Body if it exists (e.g. for POST/PUT) *Data Payload:* [source, json] ---- @@ -625,7 +672,6 @@ Find books by a partial title (e.g., searching "Harry" finds "Harry Potter"). } } ---- - // Example response for success .Response [source, json] @@ -640,7 +686,13 @@ Find books by a partial title (e.g., searching "Harry" finds "Harry Potter"). } } ---- - \ + .Required Permissions + [cols="1,3"] + |=== + |Type | Scopes + + | *librarySecurity* | write:author + |===\ """); } @@ -897,10 +949,99 @@ public void curl(OpenAPIExt openapi) throws IOException { """); } + @OpenAPITest(value = AppLib.class) + public void link(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + + templates + .evaluateThat("{{GET(\"/library/books\") | response | link }}") + .isEqualTo("Page[<>]"); + + templates + .evaluateThat("{{GET(\"/library/search\") | response | link }}") + .isEqualTo("[<>]"); + } + + @OpenAPITest(value = App3820b.class) + public void linkPrimitives(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + + templates.evaluateThat("{{GET(\"/strings\") | response | link }}").isEqualTo("Page[<>]"); + } + @OpenAPITest(value = AppLib.class) public void response(OpenAPIExt openapi) throws IOException { var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + templates + .evaluateThat("{{GET(\"/library/books\") | response | json }}") + .isEqualToIgnoringNewLines( + """ + [source, json] + ---- + { + "content" : [ { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "publisher" : { + "id" : "int64", + "name" : "string" + }, + "authors" : [ { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "zip" : "string" + } + } ] + } ], + "numberOfElements" : "int32", + "totalElements" : "int64", + "totalPages" : "int64", + "pageRequest" : { + "page" : "int64", + "size" : "int32" + }, + "nextPageRequest" : { }, + "previousPageRequest" : { } + } + ----\ + """); + + templates + .evaluateThat("{{GET(\"/library/search\") | response | json }}") + .isEqualToIgnoringNewLines( + """ + [source, json] + ---- + [ { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "publisher" : { + "id" : "int64", + "name" : "string" + }, + "authors" : [ { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "zip" : "string" + } + } ] + } ] + ----\ + """); + /* Error response code: */ templates .evaluateThat("{{GET(\"/library/books/{isbn}\") | response(code=404) | json }}") diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/app/AppLib.java b/modules/jooby-openapi/src/test/java/issues/i3820/app/AppLib.java index 6317c73625..309b32a6ce 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3820/app/AppLib.java +++ b/modules/jooby-openapi/src/test/java/issues/i3820/app/AppLib.java @@ -23,6 +23,13 @@ * @contact.email support@jooby.io * @server.url https://library.jooby.io * @server.description Production + * @securityScheme.name librarySecurity + * @securityScheme.type apiKey + * @securityScheme.in header + * @securityScheme.paramName X-Auth + * @securityScheme.flows.implicit.authorizationUrl https://library.jooby.io/auth + * @securityScheme.flows.implicit.scopes.name [write:books, read:books, write:author] + * @securityScheme.flows.implicit.scopes.description [modify books in your account, read books] * @x-logo.url https://redoredocly.github.io/redoc/museum-logo.png * @tag Library. Outlines the available actions in the Library System API. The system is designed to * allow users to search for books, view details, and manage the library inventory. diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/app/LibApi.java b/modules/jooby-openapi/src/test/java/issues/i3820/app/LibApi.java index dd3711008d..d0ca32dd39 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3820/app/LibApi.java +++ b/modules/jooby-openapi/src/test/java/issues/i3820/app/LibApi.java @@ -35,6 +35,7 @@ public LibApi(Library library) { * @return The book data * @throws NotFoundException 404 error if it doesn't exist. * @tag Library + * @securityRequirement librarySecurity read:books */ @GET @Path("/books/{isbn}") @@ -51,6 +52,7 @@ public Book getBook(@PathParam String isbn) { * @return A list of books matching that term. * @x-badges [{name:Beta, position:before, color:purple}] * @tag Library + * @securityRequirement librarySecurity read:books */ @GET @Path("/search") @@ -71,6 +73,7 @@ public List searchBooks(@QueryParam String q) { * @param size How many books to show per page (defaults to 20). * @return A "Page" object containing the books and info like "Total Pages: 5". * @tag Library + * @securityRequirement librarySecurity read:books */ @GET @Path("/books") @@ -93,6 +96,7 @@ public Page getBooksByTitle( * @param book New book to add. * @return A text message confirming success. * @tag Inventory + * @securityRequirement librarySecurity write:books */ @POST @Path("/books") @@ -109,6 +113,7 @@ public Book addBook(Book book) { * @param author New author to add. * @return Created author. * @tag Inventory + * @securityRequirement librarySecurity write:author */ @POST @Path("/authors") From 3daa4755f01c806f5fd7374a92628aa2ba681e83 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 15 Dec 2025 10:25:29 -0300 Subject: [PATCH 27/31] open api: fix `type` vs `types` difference over `v30` vs `v31` - remove useless code around it --- .../openapi/asciidoc/AsciiDocContext.java | 32 ++++++++----------- .../internal/openapi/asciidoc/Display.java | 2 +- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java index b2b20e50a7..dc2ecf3d93 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java @@ -62,6 +62,11 @@ public class AsciiDocContext { private final Instant now = Instant.now(); + static { + // type vs types difference in v30 vs v31 + System.setProperty(Schema.BIND_TYPE_AND_TYPES, Boolean.TRUE.toString()); + } + public AsciiDocContext(Path baseDir, ObjectMapper json, ObjectMapper yaml, OpenAPIExt openapi) { this.json = json; this.yamlOpenApi = yaml; @@ -294,15 +299,7 @@ public Map error(EvaluationContext context, Map public String schemaType(Schema schema) { var resolved = resolveSchema(schema); - return Optional.ofNullable(resolved.getFormat()).orElse(resolveType(resolved)); - } - - public String resolveType(Schema schema) { - var resolved = resolveSchema(schema); - if (resolved.getType() == null) { - return resolved.getTypes().iterator().next(); - } - return resolved.getType(); + return Optional.ofNullable(resolved.getFormat()).orElse(resolved.getType()); } public Schema resolveSchema(Schema schema) { @@ -315,8 +312,7 @@ public Schema resolveSchema(Schema schema) { public Object schemaProperties(Schema schema) { var resolved = resolveSchema(schema); - var resolvedType = resolveType(resolved); - if ("array".equals(resolvedType)) { + if ("array".equals(resolved.getType())) { return List.of(traverse(resolved.getItems(), NOOP)); } return traverse(schema, NOOP); @@ -329,7 +325,7 @@ public Schema reduceSchema(Schema schema) { traverse( schema, (name, value) -> { - var type = resolveType(value); + var type = value.getType(); if ("object".equals(type)) { var object = new Schema<>(); object.setType(type); @@ -348,17 +344,17 @@ public Schema reduceSchema(Schema schema) { } public Schema emptySchema(Schema schema) { + var resolved = resolveSchema(schema); var empty = new Schema<>(); - empty.setType(resolveType(schema)); - empty.setName(schema.getName()); + empty.setType(resolved.getType()); + empty.setName(resolved.getName()); return empty; } public Object schemaExample(Schema schema) { var resolved = resolveSchema(schema); - var resolvedType = resolveType(resolved); var target = resolved; - if ("array".equals(resolvedType)) { + if ("array".equals(resolved.getType())) { target = resolveSchema(resolved.getItems()); } var result = @@ -380,7 +376,7 @@ public Object schemaExample(Schema schema) { } }, NOOP)); - return "array".equals(resolvedType) ? List.of(result) : result; + return "array".equals(resolved.getType()) ? List.of(result) : result; } public void traverseSchema(Schema schema, BiConsumer> consumer) { @@ -407,7 +403,7 @@ private Map traverse( properties.forEach( (name, value) -> { var resolvedValue = resolveSchema(value); - var valueType = resolveType(resolvedValue); + var valueType = resolvedValue.getType(); consumer.accept(name, resolvedValue); if ("object".equals(valueType)) { result.put(name, traverse(visited, resolvedValue, valueMapper, NOOP)); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java index 1fb4ee3744..457d2d81cb 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java @@ -113,7 +113,7 @@ public Object apply( prefix = Optional.ofNullable(resolved.getName()).orElse("") + "["; suffix = "]"; } - if ("object".equals(asciidoc.resolveType(target))) { + if ("object".equals(target.getType())) { return new SafeString(prefix + "<<" + target.getName() + ">>" + suffix); } // no link for basic types From afcdad35772e9d27802b8317277a6acae5a0c03f Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 15 Dec 2025 11:44:21 -0300 Subject: [PATCH 28/31] - fix sample data/schema properties for array of basic types/as well as basic types it self - fix links for array, arraylike models --- .../openapi/asciidoc/AsciiDocContext.java | 18 ++++++++++++ .../internal/openapi/asciidoc/Display.java | 28 +++++++++++-------- .../src/test/java/issues/i3820/App3820b.java | 7 +++++ .../java/issues/i3820/PebbleSupportTest.java | 26 +++++++++++++++-- 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java index dc2ecf3d93..69c7927b30 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java @@ -41,6 +41,8 @@ import io.pebbletemplates.pebble.loader.Loader; import io.pebbletemplates.pebble.template.EvaluationContext; import io.pebbletemplates.pebble.template.PebbleTemplate; +import io.swagger.v3.oas.models.media.BooleanSchema; +import io.swagger.v3.oas.models.media.NumberSchema; import io.swagger.v3.oas.models.media.Schema; public class AsciiDocContext { @@ -313,11 +315,26 @@ public Schema resolveSchema(Schema schema) { public Object schemaProperties(Schema schema) { var resolved = resolveSchema(schema); if ("array".equals(resolved.getType())) { + var items = resolveSchema(resolved.getItems()); + if (items.getName() == null) { + return List.of(basicTypeSample(items)); + } return List.of(traverse(resolved.getItems(), NOOP)); } + if (resolved.getName() == null) { + return basicTypeSample(resolved); + } return traverse(schema, NOOP); } + private Object basicTypeSample(Schema items) { + return switch (items) { + case NumberSchema s -> 0; + case BooleanSchema s -> true; + default -> schemaType(items); + }; + } + @SuppressWarnings("rawtypes") public Schema reduceSchema(Schema schema) { var truncated = emptySchema(schema); @@ -348,6 +365,7 @@ public Schema emptySchema(Schema schema) { var empty = new Schema<>(); empty.setType(resolved.getType()); empty.setName(resolved.getName()); + empty.setTypes(resolved.getTypes()); return empty; } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java index 457d2d81cb..d47f7c0e7e 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java @@ -105,19 +105,23 @@ public Object apply( }; var asciidoc = AsciiDocContext.from(context); var resolved = asciidoc.resolveSchema(schema); - var target = resolved; - var prefix = ""; - var suffix = ""; - if (resolved.getItems() != null) { - target = asciidoc.resolveSchema(resolved.getItems()); - prefix = Optional.ofNullable(resolved.getName()).orElse("") + "["; - suffix = "]"; + if (resolved.getItems() == null) { + if (resolved.getName() == null) { + return resolved.getType(); + } + return new SafeString("<<" + resolved.getName() + ">>"); + } else { + var item = asciidoc.resolveSchema(resolved.getItems()); + if (item.getName() == null) { + // primitives + return new SafeString(item.getType() + "[]"); + } else { + if ("array".equals(resolved.getType())) { + return new SafeString("<<" + item.getName() + ">>[]"); + } + return new SafeString(resolved.getName() + "[<<" + item.getName() + ">>]"); + } } - if ("object".equals(target.getType())) { - return new SafeString(prefix + "<<" + target.getName() + ">>" + suffix); - } - // no link for basic types - return prefix + target.getName() + suffix; } }, curl { diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/App3820b.java b/modules/jooby-openapi/src/test/java/issues/i3820/App3820b.java index a6dc1281a7..6d13fc14fe 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3820/App3820b.java +++ b/modules/jooby-openapi/src/test/java/issues/i3820/App3820b.java @@ -18,5 +18,12 @@ public class App3820b extends Jooby { List strings = new ArrayList<>(); return strings; }); + + get( + "/string", + ctx -> { + String value = ""; + return value; + }); } } diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java b/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java index 7e82952192..d46431d195 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java +++ b/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java @@ -959,14 +959,34 @@ public void link(OpenAPIExt openapi) throws IOException { templates .evaluateThat("{{GET(\"/library/search\") | response | link }}") - .isEqualTo("[<>]"); + .isEqualTo("<>[]"); } @OpenAPITest(value = App3820b.class) - public void linkPrimitives(OpenAPIExt openapi) throws IOException { + public void checkPrimitives(OpenAPIExt openapi) throws IOException { var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); - templates.evaluateThat("{{GET(\"/strings\") | response | link }}").isEqualTo("Page[<>]"); + templates.evaluateThat("{{GET(\"/strings\") | response | link }}").isEqualTo("string[]"); + + templates + .evaluateThat("{{GET(\"/strings\") | response | json }}") + .isEqualToIgnoringNewLines( + """ + [source, json] + ---- + [ "string" ] + ----\ + """); + + templates + .evaluateThat("{{GET(\"/string\") | response | json }}") + .isEqualToIgnoringNewLines( + """ + [source, json] + ---- + "string" + ----\ + """); } @OpenAPITest(value = AppLib.class) From 21711e4f051235f1e360a228996b660b2d848463 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 15 Dec 2025 14:58:10 -0300 Subject: [PATCH 29/31] - make schemas easier to navigate/iterate and display --- .../io/jooby/internal/openapi/asciidoc/AsciiDocContext.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java index 69c7927b30..c599382a7d 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java @@ -166,7 +166,7 @@ public Map getGlobalVariables() { var components = context.openapi.getComponents(); if (components != null && components.getSchemas() != null) { var schemas = components.getSchemas(); - openapiRoot.put("schemas", schemas); + openapiRoot.put("schemas", new ArrayList<>(schemas.values())); } // make in to work without literal From b50689581527fb76a7f34148fedf81de24304753 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 15 Dec 2025 14:59:28 -0300 Subject: [PATCH 30/31] - add extensions to http request --- .../java/io/jooby/internal/openapi/asciidoc/HttpRequest.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequest.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequest.java index 121eddddfc..ac2ce2b8fd 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequest.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequest.java @@ -74,6 +74,10 @@ public List getConsumes() { return operation.getConsumes(); } + public Map getExtensions() { + return operation.getExtensions(); + } + @Override public ParameterList getHeaders() { return new ParameterList( From fd5e3813b29f18283f2b0bab29c0dc98645f79f8 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 15 Dec 2025 18:31:48 -0300 Subject: [PATCH 31/31] - document ascii doc support - fix curl bug --- docs/asciidoc/modules/openapi-ascii.adoc | 445 ++++++++++++++++++ docs/asciidoc/modules/openapi.adoc | 157 ++++-- .../main/java/io/jooby/adoc/DocGenerator.java | 1 - .../asciidoc/display/RequestToCurl.java | 2 +- .../java/issues/i3820/PebbleSupportTest.java | 16 + 5 files changed, 568 insertions(+), 53 deletions(-) create mode 100644 docs/asciidoc/modules/openapi-ascii.adoc diff --git a/docs/asciidoc/modules/openapi-ascii.adoc b/docs/asciidoc/modules/openapi-ascii.adoc new file mode 100644 index 0000000000..2034d6fafc --- /dev/null +++ b/docs/asciidoc/modules/openapi-ascii.adoc @@ -0,0 +1,445 @@ +==== Ascii Doc + +===== Setup + +1) create your template: `doc/library.adoc` + +[source, asciidoc] +---- += 📚 {{info.title}} Guide +:source-highlighter: highlightjs + +{{ info.description }} + +== Base URL + +All requests start with: `{{ server(0).url }}/library` + +=== Summary + +{{ routes | table(grid="rows") }} +---- + +2) add to build process + +.pom.xml +[source, xml, role = "primary", subs="verbatim,attributes"] +---- +... + + ... + + io.jooby + jooby-maven-plugin + {joobyVersion} + + + + openapi + + + + doc/library.adoc + + + + + + +---- + +.build.gradle +[source, groovy, role = "secondary", subs="verbatim,attributes"] +---- +openAPI { + adoc = ["doc/library.adoc"] +} +---- + +3) The output directory will have two files: + - library.adoc (final asciidoctor file) + - library.html (asciidoctor output) + +===== 1. Overview +The **Jooby OpenAPI Template Engine** is a tool designed to generate comprehensive **AsciiDoc (`.adoc`)** documentation directly from your Jooby application's OpenAPI model. + +It uses https://pebbletemplates.io[pebble] as a pre-processor to automate redundant tasks. Instead of manually writing repetitive documentation for every endpoint, you write a single template that pulls live data from your code (routes, schemas, javadoc). + +====== Pebble Syntax Primer +You mix standard AsciiDoc text with Pebble logic. + +* **`{{ expression }}`**: **Output.** Use this to print values to the file. ++ +_Example:_ `{{ info.title }}` prints the API title. + +* **`{% tag %}`**: **Logic.** Use this for control flow (loops, variables, if/else) without printing output. ++ +_Example:_ `{% for route in routes %} ... {% endfor %}` loops through your API routes. + +--- + +===== 2. The Pipeline Concept + +Data generation follows a flexible pipeline architecture. You start with a source and can optionally transform it before rendering. + +[source, subs="verbatim,quotes"] +---- +{{ *Source* | [*Mutator*] | *Display* }} +---- + +. **Source**: Finds an object in the OpenAPI model (e.g., a route or schema). +. **Mutator** _(Optional)_: Transforms or filters the data (e.g., extracting just the body, or filtering parameters). +. **Display**: Renders the final output (e.g., as JSON, a Table, or a cURL command). + +====== Examples +* **Simple:** Source -> Display ++ +`{{ info.description }}` + +* **Chained:** Source -> Mutator -> Display ++ +`{{ GET("/users") | request | body | example | json }}` + +--- + +===== 3. Data Sources (Lookups) +These functions are your entry points to locate objects within the OpenAPI definition. + +[cols="2m,3,3"] +|=== +|Function |Description |Example + +|operation(method, path) +|Generic lookup for an API operation. +|`{{ operation("GET", "/books") }}` + +|GET(path) +|Shorthand for `operation("GET", path)`. +|`{{ GET("/books") }}` + +|POST(path) +|Shorthand for `operation("POST", path)`. +|`{{ POST("/books") }}` + +|PUT / PATCH / DELETE +|Shorthand for respective HTTP methods. +|`{{ DELETE("/books/{id}") }}` + +|schema(name) +|Looks up a Schema/Model definition by name. +|`{{ schema("User") }}` + +|tag(name) +|Selects a specific Tag group (containing name, description, and routes). +|`{{ tag("Inventory") }}` + +|routes() +|Returns a collection of all available routes in the API. +|`{% for r in routes() %}...{% endfor %}` + +|server(index) +|Selects a server definition from the OpenAPI spec by index. +|`{{ server(0).url }}` + +|error(code) +|Generates an error response object. + +**Default:** `{statusCode, reason, message}`. + +**Custom:** Looks for a global `error` variable map and interpolates values. +|`{{ error(404) }}` + +|statusCode(input) +|Generates status code descriptions. Accepts: + +1. **Int:** Default reason. + +2. **List:** `[200, 404]` + +3. **Map:** `{200: "OK", 400: "Bad Syntax"}` (Overrides defaults). +|`{{ statusCode(200) }}` + +`{{ statusCode([200, 400]) }}` + +`{{ statusCode( {200: "OK", 400: "Bad Syntax"} ) }}` + +|=== + +--- + +===== 4. Mutators (Transformers) +Mutators modify the data stream. They are optional but powerful for drilling down into specific parts of an object. + +[cols="1m,2,2"] +|=== +|Filter |Description |Input Context + +|request +|Extracts the Request component from an operation. +|Operation + +|response(code) +|Extracts a specific Response component. + +**Default:** `200` (OK). +|Operation + +|body +|Extracts the Body payload definition. +|Operation / Request / Response + +|form +|Extracts form-data parameters specifically. +|Operation / Request + +|parameters(type) +|Extracts parameters. + +**Default:** Returns all parameters. + +**Filter Arguments:** `query`, `header`, `path`, `cookie`. +|Operation / Request + +|example +|Populates a Schema with example data. +|Schema / Body + +|truncate +|Takes a complex Schema and returns a new Schema containing **only direct fields**. Removes nested objects and deep relationships. +|Schema / Body +|=== + +--- + +===== 5. Display (Renderers) +The final step in the chain. These filters determine how the data is written to the AsciiDoc file. + +[cols="1m,3"] +|=== +|Filter |Description + +|curl +|Generates a ready-to-use **cURL** command. + +Includes method, headers, and body. + +|http +|Renders the raw **HTTP** Request/Response wire format. + +(Status line, Headers, Body). + +|path(params...) +|Renders the full relative URI. + +**Arguments:** Pass `key=value` pairs to override path variables or query parameters in the output. + +_Example:_ `path(id=123, sort="asc")` + +|json +|Renders the input object as a formatted JSON block. + +|yaml +|Renders the input object as a YAML block. + +|table +|Renders a standard AsciiDoc/Markdown table. + +Great for lists of parameters or schema fields. + +|list +|Renders a simple bulleted list. + +Used mostly for Status Codes or Enums. + +|link +|Renders an ascii doc on schema. + +Only for Schemas. + +|=== + +--- + +===== 6. Common Recipes + +====== A. Documenting a Route (The "Standard" Block) +Use this pattern to document a specific endpoint, separating path parameters from query parameters. + +[source, asciidoc] +---- +// 1. Define the route +{% set route = GET("/library/books/{isbn}") %} + +=== {{ route.summary }} + +{{ route.description }} + +// 2. Render Path Params +.Path Parameters +{{ route | parameters(path) | table }} + +// 3. Render Query Params +.Query Parameters +{{ route | parameters(query) | table }} + +// 4. Render Response + example +.Response +{{ route | response | body | example | json }} + +// 5. Render Response and Override Status Code +.Created(201) Response +{{ route | response(201) | http }} + +// 6. Render Response 400 +.Bad Request Response +{{ route | response(400) | http }} + +{{ route | response(400) | json }} +---- + +====== B. The "Try It" Button (cURL) +Provide a copy-paste command for developers. + +[source] +---- +{{ POST("/items") | curl }} +---- + +.Passing curl options +[source] +---- +{{ POST("/items") | curl("-i", "-H", "'Accept: application/xml'") }} +---- + +.Generate a bash source +[source, bash] +---- +{{ POST("/items") | curl(language="bash") }} +---- + +====== C. Simulating specific scenarios +Use the `path` filter to inject specific values into the URL, making the documentation more realistic. + +[source, pebble] +---- +// Scenario: Searching for Sci-Fi books on page 2 +GET {{ GET("/search") | path(q="Sci-Fi", page=2) }} +---- + +_Output:_ `GET /search?q=Sci-Fi&page=2` + +====== D. Simplifying Complex Objects +If your database entities have deep nesting (e.g., Book -> Author -> Address -> Country), use `truncate` to show a summary view. + +[source, pebble] +---- +// Full Graph (Huge JSON) +{{ schema("Book") | example | json }} + +// Summary (Flat JSON) +{{ schema("Book") | truncate | example | json }} +---- + +====== E. Error Response Reference +You can generate error examples using the standard format or a custom structure. + +**1. Default Structure** +Generates a JSON with `statusCode`, `reason`, and `message`. + +[source, pebble] +---- +.404 Not Found +{{ error(404) | json }} +---- + +**2. Custom Error Structure** +Define a variable named `error` with a map containing your fields. Use `{{code}}`, `{{message}}`, and `{{reason}}` placeholders which the engine will automatically populate. + +[source, pebble] +---- +// Define the custom error shape once +{%- set error = { + "code": "{{code}}", + "message": "{{message}}", + "reason": "{{reason}}", + "timestamp": "2025-01-01T12:00:00Z", + "support": "help@example.com" +} -%} + +// Now generate the error. It will use the map above. +.400 Bad Request +{{ error(400) | json }} +---- + +_Output:_ +[source, json] +---- +{ + "code": 400, + "message": "Bad Request", + "reason": "Bad Request", + "timestamp": "2025-01-01T12:00:00Z", + "support": "help@example.com" +} +---- + +====== F. Dynamic Tag Loop +Automatically document your API by iterating over tags defined in Java. + +[source, pebble] +---- +{% for tag in tags %} +== {{ tag.name }} + {% for route in tag.routes %} + === {{ route.summary }} + {{ route.method }} {{ route.path }} + {% endfor %} +{% endfor %} +---- + +--- + +===== 7. Advanced Patterns + +====== G. Reusable Macros (DRY) +As your template grows, use macros to create reusable UI components (like warning blocks or deprecated notices) to keep the main logic clean. + +[source, pebble] +---- +{# 1. Define the Macro at the top of your file #} +{% macro deprecationWarning(since) %} +[WARNING] +==== +This endpoint is deprecated since version {{ since }}. +Please use the newer version instead. +==== +{% endmacro %} + +{# 2. Use it inside your route loop #} +{% if route.deprecated %} + {{ deprecationWarning("v2.1") }} +{% endif %} +---- + +====== H. Security & Permissions +If your API uses authentication (OAuth2, API Keys), the `security` property on the route contains the requirements. + +[source, pebble] +---- +{% if route.security %} +.Required Permissions +[cols="1,3"] +|=== +|Type | Scopes + +{# Iterate through security schemes #} +{% for scheme in route.security %} + {% for req in scheme %} +| *{{ loop.key }}* | {{ req | join(", ") }} + {% endfor %} +{% endfor %} +|=== +{% endif %} +---- + +====== I. Linking to Schema Definitions +AsciiDoc supports internal anchors. You can automatically link a route's return type to its full Schema definition elsewhere in the document. + +[source, pebble] +---- +{# 1. Create Anchors in your Schema Loop #} +{% for s in schemas %} +[id="{{ s.name }}"] +== {{ s.name }} +{{ s | table }} +{% endfor %} + +{# 2. Link to them in your Route Loop #} +.Response Type +Returns a {{ route | response | link }} object. +---- diff --git a/docs/asciidoc/modules/openapi.adoc b/docs/asciidoc/modules/openapi.adoc index 773c3af54c..cd8125b2df 100644 --- a/docs/asciidoc/modules/openapi.adoc +++ b/docs/asciidoc/modules/openapi.adoc @@ -30,6 +30,12 @@ This library supports: openapi + + ... + + ... + + @@ -193,7 +199,9 @@ class Pets { The Maven plugin and Gradle task provide two filter properties `includes` and `excludes`. These properties filter routes by their path pattern. The filter is a regular expression. -=== JavaDoc comments +=== Documenting your API + +==== JavaDoc comments JavaDoc comments are supported on Java in script and MVC routes. @@ -384,6 +392,49 @@ Whitespaces (including new lines) are ignored. To introduce a new line, you must | | +|@securityScheme.name +|[x] +| +| +| + +|@securityScheme.in +|[x] +| +| +| + +|@securityScheme.paramName +|[x] +| +| +| + +|@securityScheme.flows.implicit.authorizationUrl +|[x] +| +| +| + +|@securityScheme.flows.implicit.scopes.name +|[x] +| +| +| + +|@securityScheme.flows.implicit.scopes.description +|[x] +| +| +| + +|@securityRequirement +| +|[x] +|[x] +|Name of the `securityScheme` and optionally scopes. Example: `myOauth2Security read:pets` + + |@server.description |[x] | @@ -443,7 +494,56 @@ Whitespaces (including new lines) are ignored. To introduce a new line, you must This feature is only available for Java routes. Kotlin source code is not supported. -=== Annotations +==== Documentation Template + +The OpenAPI output generates some default values for `info` and `server` section. It generates +the necessary to follow the specification and produces a valid output. These sections can be override +with better information/metadata. + +To do so just write an `openapi.yaml` file inside the `conf` directory to use it as template. + +.conf/openapi.yaml +[source, yaml] +---- +openapi: 3.0.1 +info: + title: My Super API + description: | + Nunc commodo ipsum vitae dignissim congue. Quisque convallis malesuada tortor, non + lacinia quam malesuada id. Curabitur nisi mi, lobortis non tempus vel, vestibulum et neque. + + ... + version: "1.0" + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + paths: + /api/pets: + get: + operationId: listPets + description: List and sort pets. + parameters: + name: page + descripton: Page number. + +---- + +All sections from template file are merged into the final output. + +The extension property: `x-merge-policy` controls how merge must be done: + +- ignore: Silently ignore a path or operation present in template but not found in generated output. This is the default value. +- keep: Add a path or operation to final output. Must be valid path or operation. +- fail: Throw an error when path or operation is present in template but not found in generated output. + +The extension property can be added at root/global level, paths, pathItem, operation or parameter level. + +[NOTE] +==== +Keep in mind that any section found here in the template overrides existing metadata. +==== + +=== Swagger Annotations Optionally this plugin depends on some OpenAPI annotations. To use them, you need to add a dependency to your project: @@ -679,56 +779,11 @@ You need the `ApiResponse` annotation: }) ---- -=== Documentation Template - -The OpenAPI output generates some default values for `info` and `server` section. It generates -the necessary to follow the specification and produces a valid output. These sections can be override -with better information/metadata. - -To do so just write an `openapi.yaml` file inside the `conf` directory to use it as template. - -.conf/openapi.yaml -[source, yaml] ----- -openapi: 3.0.1 -info: - title: My Super API - description: | - Nunc commodo ipsum vitae dignissim congue. Quisque convallis malesuada tortor, non - lacinia quam malesuada id. Curabitur nisi mi, lobortis non tempus vel, vestibulum et neque. - - ... - version: "1.0" - license: - name: Apache 2.0 - url: http://www.apache.org/licenses/LICENSE-2.0.html - paths: - /api/pets: - get: - operationId: listPets - description: List and sort pets. - parameters: - name: page - descripton: Page number. - ----- - -All sections from template file are merged into the final output. - -The extension property: `x-merge-policy` controls how merge must be done: - -- ignore: Silently ignore a path or operation present in template but not found in generated output. This is the default value. -- keep: Add a path or operation to final output. Must be valid path or operation. -- fail: Throw an error when path or operation is present in template but not found in generated output. - -The extension property can be added at root/global level, paths, pathItem, operation or parameter level. +=== Output/Display -[NOTE] -==== -Keep in mind that any section found here in the template overrides existing metadata. -==== +include::modules/openapi-ascii.adoc[] -=== Swagger UI +==== Swagger UI To use swagger-ui just add the dependency to your project: @@ -737,7 +792,7 @@ To use swagger-ui just add the dependency to your project: The swagger-ui application will be available at `/swagger`. To modify the default path, just call javadoc:OpenAPIModule[swaggerUI, java.lang.String] -=== Redoc +==== Redoc To use redoc just add the dependency to your project: diff --git a/docs/src/main/java/io/jooby/adoc/DocGenerator.java b/docs/src/main/java/io/jooby/adoc/DocGenerator.java index 04cc4e85e8..2aa68f2be2 100644 --- a/docs/src/main/java/io/jooby/adoc/DocGenerator.java +++ b/docs/src/main/java/io/jooby/adoc/DocGenerator.java @@ -242,7 +242,6 @@ private static Options createOptions(Path basedir, Path outdir, String version, var attributes = Attributes.builder(); attributes.attribute("docfile", docfile.toString()); - attributes.attribute("stylesheet", "js/styles/site.css"); attributes.attribute("love", "♡"); attributes.attribute("docinfo", "shared"); attributes.title(title == null ? "jooby: do more! more easily!!" : "jooby: " + title); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/RequestToCurl.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/RequestToCurl.java index e3937c9a40..9da8674661 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/RequestToCurl.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/RequestToCurl.java @@ -28,9 +28,9 @@ public RequestToCurl(AsciiDocContext context, HttpRequest request) { @Override public String render(Map args) { + var language = (String) args.remove("language"); var options = args(args); var method = removeOption(options, "-X", request.getMethod()).toUpperCase(); - var language = removeOption(options, "language", null); /* Accept/Content-Type: */ var addAccept = true; var addContentType = true; diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java b/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java index d46431d195..a02643228e 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java +++ b/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java @@ -852,6 +852,22 @@ public void schema(OpenAPIExt openapi) throws IOException { public void curl(OpenAPIExt openapi) throws IOException { var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + templates + .evaluateThat("{{POST(\"/library/authors\") | curl(\"-i\", language=\"bash\") }}") + .isEqualToIgnoringNewLines( + """ + [source, bash] + ---- + curl -i\\ + --data-urlencode "ssn=string"\\ + --data-urlencode "name=string"\\ + --data-urlencode "address.street=string"\\ + --data-urlencode "address.city=string"\\ + --data-urlencode "address.zip=string"\\ + -X POST '/library/authors' + ----\ + """); + templates .evaluateThat("{{POST(\"/library/authors\") | curl }}") .isEqualToIgnoringNewLines(