diff --git a/README.md b/README.md index e44c538c65..e97d8d088a 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,24 @@ # ∞ do more, more easily -[Jooby](https://jooby.io) is a modern, performant and easy to use web framework for Java and Kotlin built on top of your -favorite web server. +[Jooby](https://jooby.io) is a modular, high-performance web framework for Java and Kotlin. Designed for simplicity and speed, it gives you the freedom to build on your favorite server with a clean, modern API. + +## 🚀 Built for Speed +- **High Performance**: Consistently ranks among the fastest Java frameworks in TechEmpower benchmarks. +- **Lightweight Footprint**: Low memory usage and fast startup times make it ideal for microservices environments. +- **Choose Your Engine**: Built to run on your favorite high-performance servers: Netty, Jetty, or Undertow. + +## 🛠️ Developer Productivity +- **Instant Hot-Reload**: Save your code and see changes immediately without restarting the entire JVM. +- **Modular by Design**: Only use what you need. Jooby offers over 50 "thin" modules for database access (Hibernate, JDBI, Flyway), security (Pac4j), and more. +- **OpenAPI & Swagger**: Automatically generate interactive documentation for your APIs with built-in OpenAPI 3 support. + +## 🧩 Unrivaled Flexibility +- **The Power of Choice**: Use the Script API (fluent, lambda-based routes) for simple apps, or the MVC API (annotation-based) for complex enterprise projects. +- **Reactive & Non-Blocking**: Full support for modern async patterns, including Kotlin Coroutines, RxJava, Reactor, and CompletableFutures. +- **First-Class Kotlin Support**: Native DSLs and features designed specifically to make Kotlin development feel intuitive and type-safe. + +## Quick Start Java: @@ -72,11 +88,6 @@ Previous version - v2: [Documentation](https://jooby.io/v2) and [source code](https://github.com/jooby-project/jooby/tree/2.x) - v1: [Documentation](https://jooby.io/v1) and [source code](https://github.com/jooby-project/jooby/tree/1.x) -author -===== - - [Edgar Espina](https://twitter.com/edgarespina) - license ===== diff --git a/docs/asciidoc/body.adoc b/docs/asciidoc/body.adoc index bbb0ceb00c..de1fb3a1b4 100644 --- a/docs/asciidoc/body.adoc +++ b/docs/asciidoc/body.adoc @@ -1,6 +1,6 @@ -=== Request Body +==== Request Body -Raw `request body` is available via javadoc:Context[body] method: +The raw request body is available via the javadoc:Context[body] method: .Java [source,java,role="primary"] @@ -8,17 +8,17 @@ Raw `request body` is available via javadoc:Context[body] method: { post("/string", ctx -> { String body = ctx.body().value(); // <1> - ... + // ... }); post("/bytes", ctx -> { byte[] body = ctx.body().bytes(); // <2> - ... + // ... }); post("/stream", ctx -> { InputStream body = ctx.body().stream(); // <3> - ... + // ... }); } ---- @@ -29,57 +29,52 @@ Raw `request body` is available via javadoc:Context[body] method: { post("/string") { val body = ctx.body().value() // <1> - ... + // ... } post("/bytes") { val body = ctx.body().bytes() // <2> - ... + // ... } post("/stream") { val body = ctx.body().stream() // <3> - ... + // ... } } ---- -<1> `HTTP Body` as `String` -<2> `HTTP Body` as `byte array` -<3> `HTTP Body` as `InputStream` +<1> Reads the HTTP body as a `String`. +<2> Reads the HTTP body as a `byte array`. +<3> Reads the HTTP body as an `InputStream`. -This gives us the `raw body`. +===== Message Decoder -==== Message Decoder - -Request body parsing is achieved using the javadoc:MessageDecoder[] functional interface. +Request body parsing (converting the raw body into a specific object) is handled by the javadoc:MessageDecoder[] functional interface. [source, java] ---- public interface MessageDecoder { - T decode(Context ctx, Type type) throws Exception; } ---- -javadoc:MessageDecoder[] has a single `decode` method that takes two input arguments: `(context, type)` -and returns a single result of the given type. +The javadoc:MessageDecoder[] has a single `decode` method that takes the request context and the target type, returning the parsed result. -.JSON example: +.JSON Decoder Example: [source, java, role="primary"] ---- { FavoriteJson lib = new FavoriteJson(); // <1> - decoder(MediaType.json, (ctx, type) -> { // <2> - + decoder(MediaType.json, (ctx, type) -> { // <2> byte[] body = ctx.body().bytes(); // <3> - return lib.fromJson(body, type); // <4> }); post("/", ctx -> { MyObject myObject = ctx.body(MyObject.class); // <5> + return myObject; }); } ---- @@ -90,40 +85,37 @@ and returns a single result of the given type. { val lib = FavoriteJson() // <1> - decoder(MediaType.json) { ctx, type -> // <2> - + decoder(MediaType.json) { ctx, type -> // <2> val body = ctx.body().bytes() // <3> - lib.fromJson(body, type) // <4> } post("/") { val myObject = ctx.body() // <5> + myObject } } ---- -<1> Choose your favorite `json` library -<2> Check if the `Content-Type` header matches `application/json` -<3> Read the body as `byte[]` -<4> Parse the `body` and use the requested type -<5> Route handler now call the `body(Type)` function to trigger the decoder function +<1> Initialize your favorite JSON library. +<2> Register the decoder to trigger when the `Content-Type` header matches `application/json`. +<3> Read the raw body as a `byte[]`. +<4> Parse the payload into the requested type. +<5> Inside the route, calling `ctx.body(Type)` automatically triggers the registered decoder. -=== Response Body +==== Response Body -Response body is generated from `handler` function: +The response body is generated by the route handler. -.Response body +.Response Body Example [source, java,role="primary"] ---- { get("/", ctx -> { - ctx.setResponseCode(200); // <1> - + ctx.setResponseCode(200); // <1> ctx.setResponseType(MediaType.text); // <2> - ctx.setResponseHeader("Date", new Date()); // <3> - + return "Response"; // <4> }); } @@ -135,56 +127,46 @@ Response body is generated from `handler` function: { get("/") { ctx.responseCode = 200 // <1> - ctx.responseType = MediaType.text // <2> - ctx.setResponseHeader("Date", Date()) // <3> - + "Response" // <4> } } ---- -<1> Set `status code` to `OK(200)`. This is the default `status code` -<2> Set `content-type` to `text/plain`. This is the default `content-type` -<3> Set the `date` header -<4> Send a `Response` string to the client +<1> Set the status code to `200 OK` (this is the default). +<2> Set the `Content-Type` to `text/plain` (this is the default for strings). +<3> Set a custom response header. +<4> Return the response body to the client. -==== Message Encoder +===== Message Encoder -Response encoding is achieved using the javadoc:MessageEncoder[] functional interface. +Response encoding (converting an object into a raw HTTP response) is handled by the javadoc:MessageEncoder[] functional interface. [source, java] ---- public interface MessageEncoder { - Output encode(@NonNull Context ctx, @NonNull Object value) throws Exception; } ---- -MessageEncoder has a single `encode` method that accepts two input arguments: `(context, value)` and -produces a result. +The javadoc:MessageEncoder[] has a single `encode` method that accepts the context and the value returned by the handler, producing an output. (Internally, javadoc:output.Output[] works like a `java.nio.ByteBuffer` for performance reasons). -The javadoc:output.Output[] works like `java.nio.ByteBuffer` and it is used internally -for performance reason. - -.JSON example: +.JSON Encoder Example: [source, java, role="primary"] ---- { FavoriteJson lib = new FavoriteJson(); // <1> - encoder(MediaType.json, (ctx, result) -> { // <2> - + encoder(MediaType.json, (ctx, result) -> { // <2> String json = lib.toJson(result); // <3> - ctx.setDefaultResponseType(MediaType.json); // <4> - return json; // <5> }); get("/item", ctx -> { - MyObject myObject = ...; + MyObject myObject = new MyObject(); return myObject; // <6> }); } @@ -196,25 +178,22 @@ for performance reason. { val lib = FavoriteJson() // <1> - encoder(MediaType.json) { ctx, result -> // <2> - + encoder(MediaType.json) { ctx, result -> // <2> val json = lib.toJson(result) // <3> - ctx.defaultResponseType = MediaType.json // <4> - json // <5> } get("/item") { - val myObject = ...; + val myObject = MyObject() myObject // <6> } } ---- -<1> Choose your favorite `json` library -<2> Check if the `Accept` header matches `application/json` -<3> Convert `result` to `JSON` -<4> Set default `Content-Type` to `application/json` -<5> Produces JSON response -<6> Route handler returns a user defined type +<1> Initialize your favorite JSON library. +<2> Register the encoder to trigger when the client's `Accept` header matches `application/json`. +<3> Convert the route's result into JSON. +<4> Set the `Content-Type` header to `application/json`. +<5> Return the encoded JSON payload. +<6> The route handler returns a user-defined POJO, which is automatically intercepted and encoded. diff --git a/docs/asciidoc/configuration.adoc b/docs/asciidoc/configuration.adoc index a8cfda1809..bf5c46989a 100644 --- a/docs/asciidoc/configuration.adoc +++ b/docs/asciidoc/configuration.adoc @@ -1,19 +1,16 @@ -== Configuration -Application configuration is based on https://github.com/lightbend/config[config] library. Configuration -can by default be provided in either Java properties, JSON, and https://github.com/lightbend/config/blob/master/HOCON.md[HOCON] files. +=== Configuration -Jooby allows overriding any property via system properties or environment variables. +Application configuration is built on the https://github.com/lightbend/config[Typesafe Config] library. By default, Jooby supports configuration provided in Java properties, JSON, or https://github.com/lightbend/config/blob/master/HOCON.md[HOCON] format. -=== Environment +Jooby allows you to override any property via system properties, environment variables, or program arguments. -The application environment is available via the javadoc:Environment[Environment] class, which allows specifying one -or many unique environment names. +==== Environment -The active environment names serve the purpose of allowing loading different configuration files -depending on the environment. Also, javadoc:Extension[] modules might configure application -services differently depending on the environment too. For example: turn on/off caches, reload files, etc. +The javadoc:Environment[Environment] class manages your application's configuration and active environment names (e.g., `dev`, `prod`, `test`). -.Initializing the Environment +Environment names allow you to load different configuration files or toggle features (like caching or file reloading) depending on the deployment stage. + +.Accessing the Environment [source, java, role = "primary"] ---- { @@ -29,85 +26,42 @@ services differently depending on the environment too. For example: turn on/off } ---- -The active environment names property is set in one of this way: - -- As program argument: `java -jar myapp.jar application.env=foo,bar`; or just `java -jar myapp.jar foo,bar` - -NOTE: This method works as long you start the application using one of the `runApp` methods - -- As system property: `java -Dapplication.env=foo,bar -jar myapp.jar` - -- As environment variable: `application.env=foo,bar` - - -The javadoc:Jooby[getEnvironment] loads the default environment. - -=== Default Environment - -The default environment is available via javadoc:Environment[loadEnvironment, io.jooby.EnvironmentOptions] method. - -This method search for an `application.conf` file in three location (first-listed are higher priority): - -- `${user.dir}/conf`. This is a file system location, useful is you want to externalize configuration (outside of jar file) -- `${user.dir}`. This is a file system location, useful is you want to externalize configuration (outside of jar file) -- `classpath://` (root of classpath). No external configuration, configuration file lives inside the jar file - -NOTE: We use `$user.dir` to reference `System.getProperty("user.dir")`. This system property is set -by the JVM at application startup time. It represent the current directory from where the JVM was -launch it. - -.File system loading -[source,bash] ----- -└── conf - └── application.conf -└── myapp.jar ----- - -A call to: +You can set active environment names in several ways: -[source] ----- - Environment env = getEnvironment(); ----- +* **Program Argument:** `java -jar myapp.jar prod,cloud` (This works when using Jooby's `runApp` methods). +* **System Property:** `java -Dapplication.env=prod -jar myapp.jar` +* **Environment Variable:** `application.env=prod` -Loads the `application.conf` from `conf` directory. You get the same thing if you -move the `application.conf` to `myapp.jar` directory. +==== Default Loading and Precedence -.Classpath loading -[source,bash] ----- -└── myapp.jar - └── application.conf (file inside jar) ----- +When you call `getEnvironment()`, Jooby searches for an `application.conf` file in the following order of priority: -WARNING: Jooby favors file system property loading over classpath property loading. So, if there -is a property file either in the current directory or conf directory it hides the same file -available in the classpath. +1. `${user.dir}/conf/application.conf` (External file system) +2. `${user.dir}/application.conf` (External file system) +3. `classpath://application.conf` (Internal jar resource) -=== Overrides +[NOTE] +==== +`${user.dir}` refers to the directory from which the JVM was launched. Jooby **favors file system files** over classpath files, allowing you to easily externalize configuration without rebuilding your jar. +==== -Property overrides is done in multiple ways (first-listed are higher priority): +==== Overrides -- Program arguments -- System properties -- Environment variables -- Environment property file -- Property file +Properties are resolved using the following precedence (highest priority first): -.application.conf -[source, properties] ----- -foo = foo ----- +1. Program arguments (e.g., `java -jar app.jar foo=bar`) +2. System properties (e.g., `-Dfoo=bar`) +3. Environment variables (e.g., `foo=bar java -jar app.jar`) +4. Environment-specific property file (e.g., `application.prod.conf`) +5. Default property file (`application.conf`) -.Property access +.Accessing Properties [source, java, role="primary"] ---- { - Environment env = getEnvironment(); <1> - Config conf = env.getConfig(); <2> - System.out.println(conf.getString("foo")); <3> + Environment env = getEnvironment(); // <1> + Config conf = env.getConfig(); // <2> + System.out.println(conf.getString("foo")); // <3> } ---- @@ -115,101 +69,41 @@ foo = foo [source, kotlin, role="secondary"] ---- { - val env = environment <1> - val conf = env.config <2> - println(conf.getString("foo")) <3> + val env = environment // <1> + val conf = env.config // <2> + println(conf.getString("foo")) // <3> } ---- -<1> Get environment -<2> Get configuration -<3> Get `foo` property and prints `foo` - -At runtime you can override properties using: - -.Program argument -[source, bash] ----- -java -jar myapp.jar foo=argument ----- - -Example prints: `argument` - -.System property -[source, bash] ----- -java -Dfoo=sysprop -jar myapp.jar ----- - -Prints: `syspro` +<1> Retrieve the current environment. +<2> Access the underlying `Config` object. +<3> Extract the value for the key `foo`. -.Environment variable -[source, bash] ----- -foo=envar java -jar myapp.jar ----- - -Prints: `envar` +==== Multi-Environment Configuration -If you have multiple properties to override, it is probably better to collect all them into a new file -and use active environment name to select them. +It is best practice to keep common settings in `application.conf` and override environment-specific values in separate files named `application.[env].conf`. -.Environment property file -[source, bash] +.Example Structure ---- -└── application.conf -└── application.prod.conf +└── application.conf (foo = "default", bar = "base") +└── application.prod.conf (foo = "production") ---- -.application.conf -[source, properties] ----- -foo = foo -bar = devbar ----- +Running with `java -jar myapp.jar prod` results in: +* `foo`: `"production"` (overridden) +* `bar`: `"base"` (inherited from default) -.application.prod.conf -[source, properties] ----- -bar = prodbar ----- +To activate multiple environments, separate them with commas: `java -jar app.jar prod,cloud`. -.Run with `prod` environment ----- -java -jar my.app application.env=prod ----- +==== Custom Configuration -Or just ----- -java -jar my.app prod ----- +If you want to bypass Jooby's default loading logic, you can provide custom options or instantiate the environment manually. -TIP: You only need to override the properties that changes between environment not all the properties. - -The `application.conf` defines two properties : `foo` and `bar`, while the environment property file -defines only `bar`. - -For Multiple environment activation you need to separate them with `,` (comma): - -.Run with `prod` and `cloud` environment ----- - java -jar my.app application.env=prod,cloud ----- - -=== Custom environment - -Custom configuration and environment are available too using: - -- The javadoc:EnvironmentOptions[] class, or -- Direct instantiation of the javadoc:Environment[] class - -.Environment options +.Using Environment Options [source,java,role="primary"] ---- { - Environment env = setEnvironmentOptions( - new EnvironmentOptions().setFilename("myapp.conf")); <1> - ) + setEnvironmentOptions(new EnvironmentOptions().setFilename("myapp.conf")); // <1> } ---- @@ -217,26 +111,21 @@ Custom configuration and environment are available too using: [source,kotlin,role="secondary"] ---- { - val env = environmentOptions { <1> - filename = "myapp.conf" + environmentOptions { + filename = "myapp.conf" // <1> } } ---- -<1> Load `myapp.conf` using the loading and precedence mechanism described before - -The javadoc:Jooby[setEnvironmentOptions, io.jooby.EnvironmentOptions] method loads, set and returns -the environment. - -To skip/ignore Jooby loading and precedence mechanism, just instantiate and set the environment: +<1> Loads `myapp.conf` instead of the default `application.conf` while maintaining standard precedence rules. -.Direct instantiation +.Direct Instantiation [source,java,role="primary"] ---- { - Config conf = ConfigFactory.load("myapp.conf"); <1> - Environment env = new Environment(getClassLoader(), conf); <2> - setEnvironment(env); <3> + Config conf = ConfigFactory.load("custom.conf"); // <1> + Environment env = new Environment(getClassLoader(), conf); // <2> + setEnvironment(env); // <3> } ---- @@ -244,149 +133,50 @@ To skip/ignore Jooby loading and precedence mechanism, just instantiate and set [source,kotlin,role="secondary"] ---- { - val conf = ConfigFactory.load("myapp.conf") <1> - val env = Environment(classLoader, conf) <2> - environment = env <3> + val conf = ConfigFactory.load("custom.conf") // <1> + val env = Environment(classLoader, conf) // <2> + environment = env // <3> } ---- -<1> Loads and parses configuration -<2> Create a new environment with configuration and (optionally) active names -<3> Set environment on Jooby instance +<1> Manually load a configuration file. +<2> Wrap it in a Jooby Environment. +<3> Assign it to the application before startup. -IMPORTANT: Custom configuration is very flexible. You can reuse Jooby mechanism or provide your own. -The only thing to keep in mind is that environment setting must be done at very early stage, before -starting the application. +==== Logging -=== Logging +Jooby uses **SLF4J**, allowing you to plug in your preferred logging framework. -Jooby uses https://www.slf4j.org[Slf4j] for logging which give you some flexibility for choosing -the logging framework. +===== Logback -==== Logback +1. **Add Dependency:** `logback-classic`. +2. **Configure:** Place `logback.xml` in your `conf` directory or classpath root. -The https://logback.qos.ch/manual/index.html[Logback] is probably the first alternative for -https://www.slf4j.org[Slf4j] due its natively implements the SLF4J API. Follow the next steps to use -logback in your project: +===== Log4j2 -1) Add dependency +1. **Add Dependencies:** `log4j-slf4j-impl` and `log4j-core`. +2. **Configure:** Place `log4j2.xml` in your `conf` directory or classpath root. -[dependency, artifactId="logback-classic"] +===== Environment-Aware Logging -2) Creates a `logback.xml` file in the `conf` directory: - -.logback.xml -[source, xml] ----- - - - - - - %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - - ----- - -That's all! https://www.slf4j.org[Slf4j] is going to redirect log message to logback. - -==== Log4j2 - -The https://logging.apache.org/log4j[Log4j2] project is another good alternative for logging. Follow -the next steps to use logback in your project: - -1) Add dependencies - -[dependency, artifactId="log4j-slf4j-impl, log4j-core"] - -2) Creates a `log4j.xml` file in the `conf` directory: - -.log4j.xml -[source, xml] ----- - - - - - - - - - - - - - ----- - -All these extensions are considered valid: `.xml`, `.propertines`, `.yaml` and `.json`. As well as `log4j2` for file name. - -==== Environment logging - -Logging is integrated with the environment names. So it is possible to have a file name: - -- `logback[.name].xml` (for loggback) -- `log4j[.name].xml` (for log4j2) - -Jooby favors the environment specific logging configuration file over regular/normal logging configuration file. - -.Example -[source, bash] ----- -conf -└── logback.conf -└── logback.prod.conf ----- - -To use `logback.prod.conf`, start your application like: - -`java -jar myapp.jar application.env=prod` +Logging is also environment-aware. Jooby will look for `logback.[env].xml` or `log4j2.[env].xml` and favor them over the default files. [IMPORTANT] ==== -The logging configuration file per environment works as long you don't use *static* loggers -before application has been start it. The next example won't work: - -[source, java] ----- -public class App extends Jooby { - private static final Logger log = ... - - public static void main(String[] args) { - runApp(args, App::new); - } -} ----- - -The `runApp` method is the one who configures the logging framework. Adding a static logger force -the logging framework to configure without taking care the environment setup. - -There are a couple of solution is for this: - -- use an instance logger -- use the getLog() method of Jooby +To ensure environment-specific logging works correctly, avoid using **static** loggers in your main App class before `runApp` is called. Static loggers force the logging framework to initialize before Jooby can apply the environment-specific configuration. Use an instance logger or Jooby's `getLog()` method instead. ==== -=== Application Properties +==== Application Properties -These are the application properties that Jooby uses: - -[options="header"] +[cols="2,1,3"] |=== -|Property name | Description | Default value -|application.charset | Charset used by your application. Used by template engine, HTTP encoding/decoding, database driver, etc. | `UTF-8` -|application.env | The active <> names. Use to identify `dev` vs `non-dev` application deployment. Jooby applies some optimizations for `non-dev`environments | `dev` -|application.lang | The languages your application supports. Used by `Context.locale()` | A single locale provided by `Locale.getDefault()`. -|application.logfile | The logging configuration file your application uses. You don't need to set this property, see <>. | -|application.package | The base package of your application. | -|application.pid | JVM process ID. | The native process ID assigned by the operating system. -|application.startupSummary | The level of information logged during startup. | -|application.tmpdir | Temporary directory used by your application. | `tmp` +|Property | Default | Description + +|`application.charset` | `UTF-8` | Charset for encoding/decoding and templates. +|`application.env` | `dev` | Active environment names. Jooby optimizes performance for non-`dev` environments. +|`application.lang` | `Locale.getDefault()` | Supported languages for `Context.locale()`. +|`application.tmpdir` | `tmp` | Temporary directory for the application. +|`application.pid` | System assigned | The JVM process ID. |=== -See javadoc:AvailableSettings[] for more details. +See javadoc:AvailableSettings[] for a complete reference. diff --git a/docs/asciidoc/context.adoc b/docs/asciidoc/context.adoc index 47f5921248..45a12a64a6 100644 --- a/docs/asciidoc/context.adoc +++ b/docs/asciidoc/context.adoc @@ -1,14 +1,14 @@ -== Context +=== Context -A javadoc:Context[Context] allows you to interact with the HTTP Request and manipulate the HTTP Response. +A javadoc:Context[Context] allows you to interact with the HTTP request and manipulate the HTTP response. -In most of the cases you can access the context object as a parameter of your route handler: +In most cases, you access the context object as a parameter of your route handler: .Java [source, java, role="primary"] ---- { - get("/", ctx -> { /* do important stuff with variable 'ctx'. */ }); + get("/", ctx -> { /* do important stuff with the 'ctx' variable */ }); } ---- @@ -16,23 +16,15 @@ In most of the cases you can access the context object as a parameter of your ro [source, kotlin, role="secondary"] ---- { - get("/") { /* variable 'it' holds the context now. */ } + get("/") { /* the 'it' variable (or implicit ctx) holds the context */ } } ---- -javadoc:Context[Context] also provides derived information about the current request such as a -matching locale (or locales) based on the `Accept-Language` header (if presents). You may use -the result of javadoc:Context[locale] or javadoc:Context[locales] to present content matching to -the user's language preference. +javadoc:Context[Context] also provides derived information about the current request, such as matching locales based on the `Accept-Language` header. You can use javadoc:Context[locale] or javadoc:Context[locales] to present content matching the user's language preference. -The above methods use `Locale.lookup(...)` and `Locale.filter(...)` respectively to perform the -language tag matching. See their overloads if you need to plug in your own matching strategy. +These methods use `Locale.lookup(...)` and `Locale.filter(...)` to perform language tag matching. (See their overloads if you need to plug in a custom matching strategy). -To leverage language matching however, you need to tell Jooby which languages your application -supports. This can be done by either setting the `application.lang` configuration property -to a value compatible with the -https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language[Accept-Language] -header: +To leverage language matching, you must tell Jooby which languages your application supports. Set the `application.lang` configuration property to a value compatible with the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language[Accept-Language] header: .application.conf [source, properties] @@ -40,8 +32,7 @@ header: application.lang = en, en-GB, de ---- -or calling the javadoc:Jooby[setLocales, java.util.List] -or javadoc:Jooby[setLocales, java.util.Locale...] method at runtime: +Or configure it programmatically using javadoc:Jooby[setLocales, java.util.List]: .Java [source, java, role="primary"] @@ -59,24 +50,17 @@ or javadoc:Jooby[setLocales, java.util.Locale...] method at runtime: } ---- -If you don't set the supported locales explicitly, Jooby uses a single locale provided by -`Locale.getDefault()`. +If you don't explicitly set the supported locales, Jooby falls back to a single locale provided by `Locale.getDefault()`. -=== Parameters +==== Parameters -There are several parameter types: `header`, `cookie`, `path`, `query`, `form`, `multipart`, -`session` and `flash`. All them share a unified/type-safe API for accessing and manipulating their values. +There are several parameter types: `header`, `cookie`, `path`, `query`, `form`, `multipart`, `session`, and `flash`. They all share a unified, type-safe API for accessing and manipulating their values. -We are going to describe them briefly in the next sections, then go into specific features of the -<>. +This section covers how to extract raw parameters. The next section covers how to convert them into complex objects using the <>. -There is also a <> feature by which you can access a parameter from any combination -of the above types with well-defined priority. +===== Header -==== Header - -HTTP headers allow the client and the server to pass additional information with the request or the -response. +HTTP headers allow the client and server to pass additional information. .Java [source, java, role="primary"] @@ -84,13 +68,10 @@ response. { get("/", ctx -> { String token = ctx.header("token").value(); // <1> - Value headers = ctx.headers(); // <2> - Map headerMap = ctx.headerMap(); // <3> - ... + // ... }); - } ---- @@ -100,24 +81,20 @@ response. { get("/") { ctx -> val token = ctx.header("token").value() // <1> - val headers = ctx.headers() // <2> - - val headerMap = ctx.headerMap(); // <3> - ... + val headerMap = ctx.headerMap() // <3> + // ... } - } ---- -<1> Header variable `token` -<2> All headers as javadoc:value.Value[] -<3> All headers as map +<1> Retrieves the header `token`. +<2> Retrieves all headers as a javadoc:value.Value[]. +<3> Retrieves all headers as a Map. -==== Cookie +===== Cookie -Request cookies are send to the server using the `Cookie` header, but we do provide a simple -`key/value` access to them: +Request cookies are sent to the server via the `Cookie` header. Jooby provides simple key/value access: .Cookies [source, java, role="primary"] @@ -125,11 +102,9 @@ Request cookies are send to the server using the `Cookie` header, but we do prov { get("/", ctx -> { String token = ctx.cookie("token").value(); // <1> - Map cookieMap = ctx.cookieMap(); // <2> - ... + // ... }); - } ---- @@ -139,34 +114,28 @@ Request cookies are send to the server using the `Cookie` header, but we do prov { get("/") { ctx -> val token = ctx.cookie("token").value() // <1> - val cookieMap = ctx.cookieMap() // <2> - ... + // ... } - } ---- -<1> Cookie variable `token` -<2> All cookies as map +<1> Retrieves the cookie named `token`. +<2> Retrieves all cookies as a Map. -==== Path +===== Path -Path parameter are part of the `URI`. To define a path variable you need to use the `{identifier}` notation. +Path parameters are part of the URI. Use the `{identifier}` notation to define a path variable. .Syntax: [source,java,role="primary"] ---- { - get("/{id}" ctx -> ctx.path("id").value()); // <1> - - get("/@{id}" ctx -> ctx.path("id").value()); // <2> - - get("/file/{name}.{ext}", ctx -> cxt.path("name") + "." + ctx.path("ext")); // <3> - - get("/file/*", ctx -> ctx.path("*")) // <4> - - get("/{id:[0-9]+}", ctx -> ctx.path("id)) // <5> + get("/{id}", ctx -> ctx.path("id").value()); // <1> + get("/@{id}", ctx -> ctx.path("id").value()); // <2> + get("/file/{name}.{ext}", ctx -> ctx.path("name") + "." + ctx.path("ext")); // <3> + get("/file/*", ctx -> ctx.path("*")); // <4> + get("/{id:[0-9]+}", ctx -> ctx.path("id")); // <5> } ---- @@ -175,37 +144,29 @@ Path parameter are part of the `URI`. To define a path variable you need to use ---- { get("/{id}") { ctx -> ctx.path("id").value() } // <1> - get("/@{id}") { ctx -> ctx.path("id").value() } // <2> - - get("/file/{name}.{ext}") { ctx -> cxt.path("name") + "." + ctx.path("ext") } // <3> - + get("/file/{name}.{ext}") { ctx -> ctx.path("name") + "." + ctx.path("ext") } // <3> get("/file/*") { ctx -> ctx.path("*") } // <4> - - get("/{id:[0-9]+}") { ctx -> ctx.path("id) } // <5> + get("/{id:[0-9]+}") { ctx -> ctx.path("id") } // <5> } ---- -<1> Path variable `id` -<2> Path variable `id` prefixed with `@` -<3> Multiple variables `name` and `ext` -<4> Unnamed catchall path variable -<5> Path variable with a regular expression +<1> Standard path variable `id`. +<2> Path variable `id` prefixed with `@`. +<3> Multiple variables: `name` and `ext`. +<4> Unnamed catchall path variable. +<5> Path variable strictly matching a regular expression. -.Java +.Accessing Path Variables [source, java, role="primary"] ---- { get("/{name}", ctx -> { String pathString = ctx.getRequestPath(); // <1> - Value path = ctx.path(); // <2> - Map pathMap = ctx.pathMap(); // <3> - String name = ctx.path("name").value(); // <4> - - ... + // ... }); } ---- @@ -216,45 +177,22 @@ Path parameter are part of the `URI`. To define a path variable you need to use { get("/{name}") { ctx -> val pathString = ctx.getRequestPath() // <1> - val path = ctx.path() // <2> - val pathMap = ctx.pathMap() // <3> - val name = ctx.path("name").value() // <4> - - ... + // ... } } ---- -<1> Access to the `raw` path string: - -- `/a+b` => `/a+b` -- `/a b` => `/a%20b` (not decoded) -- `/%2F%2B` => `/%2F%2B` (not decoded) - -<2> Path as javadoc:value.Value[] object: - -- `/a+b` => `{name=a+b}` -- `/a b` => `{name=a b}` (decoded) -- `/%2F%2B` => `{name=/+}` (decoded) - -<3> Path as `Map` object: - -- `/a+b` => `{name=a+b}` -- `/a b` => `{name=a b}` (decoded) -- `/%2F%2B` => `{name=/+}` (decoded) +<1> Access the `raw` path string (e.g., `/a b` returns `/a%20b`). +<2> Path as a javadoc:value.Value[] object (decoded). +<3> Path as a `Map` (decoded). +<4> Specific path variable `name` as a `String` (decoded). -<4> Path variable `name` as `String`: +===== Query -- `/a+b` => `a+b` -- `/a b` => `a b` (decoded) -- `/%2F%2B` => `/+` (decoded) - -==== Query - -Query String is part of the `URI` that start after the `?` character. +The query string is the part of the URI that starts after the `?` character. .Java [source, java, role="primary"] @@ -262,26 +200,17 @@ Query String is part of the `URI` that start after the `?` character. { get("/search", ctx -> { String queryString = ctx.queryString(); // <1> - QueryString query = ctx.query(); // <2> - Map> queryMap = ctx.queryMultimap(); // <3> - String q = ctx.query("q").value(); // <4> - SearchQuery searchQuery = ctx.query(SearchQuery.class); // <5> - - ... + // ... }); } class SearchQuery { - public final String q; - - public SearchQuery(String q) { - this.q = q; - } + public SearchQuery(String q) { this.q = q; } } ---- @@ -291,57 +220,26 @@ class SearchQuery { { get("/search") { ctx -> val queryString = ctx.queryString() // <1> - val query = ctx.query() // <2> - val queryMap = ctx.queryMultimap() // <3> - val q = ctx.query("q").value() // <4> - val searchQuery = ctx.query() // <5> - - ... + // ... } } -data class SearchQuery (val q: String) +data class SearchQuery(val q: String) ---- -<1> Access to `raw` queryString: - -- `/search` => `""` (empty) -- `/search?q=a+b` => `?q=a+b` -- `/search?q=a b` => `?q=a%20b` (not decoded) +<1> Access the `raw` query string (e.g., `?q=a%20b`). +<2> Query string as a javadoc:QueryString[] object (e.g., `{q=a b}`). +<3> Query string as a multi-value map (e.g., `{q=[a b]}`). +<4> Access decoded variable `q`. Throws a `400 Bad Request` if missing. +<5> Binds the query string directly to a `SearchQuery` object. -<2> Query String as javadoc:QueryString[] object: +===== Formdata -- `/search` => `{}` (empty) -- `/search?q=a+b` => `{q=a+b}` -- `/search?q=a b` => `{q=a b}` (decoded) - -<3> Query string as `multi-value map` - -- `/search` => `{}` (empty) -- `/search?q=a+b` => `{q=[a+b]}` -- `/search?q=a b` => `{q=[a b]}` (decoded) - -<4> Access to decoded variable `q`: - -- `/search` => `Bad Request (400). Missing value: "q"` -- `/search?q=a+b` => `a+b` -- `/search?q=a b` => `a b` (decoded) - -<5> Query string as `SearchQuery` - -- `/search` => `Bad Request (400). Missing value: "q"` -- `/search?q=a+b` => `SearchQuery(q="a+b")` -- `/search?q=a b` => `SearchQuery(q="a b")` (decoded) - -==== Formdata - -Formdata is expected to be in HTTP body, or for as part of the `URI` for `GET` requests. - -Data is expected to be encoded as `application/x-www-form-urlencoded`. +Form data is sent in the HTTP body (or as part of the URI for `GET` requests) and is encoded as `application/x-www-form-urlencoded`. .Java [source, java, role="primary"] @@ -349,25 +247,17 @@ Data is expected to be encoded as `application/x-www-form-urlencoded`. { post("/user", ctx -> { Formdata form = ctx.form(); // <1> - Map> formMap = ctx.formMultimap(); // <2> - String userId = ctx.form("id").value(); // <3> - String pass = ctx.form("pass").value(); // <4> - User user = ctx.form(User.class); // <5> - - ... + // ... }); } class User { - public final String id; - public final String pass; - public User(String id, String pass) { this.id = id; this.pass = pass; @@ -381,36 +271,26 @@ class User { { post("/user") { ctx -> val form = ctx.form() // <1> - val formMap = ctx.formMultimap() // <2> - val userId = ctx.form("id").value() // <3> - val pass = ctx.form("pass").value() // <4> - val user = ctx.form() // <5> - - ... + // ... } } -data class User (val id: String, val pass: String) - +data class User(val id: String, val pass: String) ---- ----- -curl -d "id=root&pass=pwd" -X POST http://localhost:8080/user ----- +<1> Form as javadoc:Formdata[]. +<2> Form as a multi-value map. +<3> Specific form variable `id`. +<4> Specific form variable `pass`. +<5> Form automatically bound to a `User` object. -<1> Form as javadoc:Formdata[] => `{id=root, pass=pwd}` -<2> Form as `multi-value map` => `{id=root, pass=[pwd]}` -<3> Form variable `id` => `root` -<4> Form variable `pass` => `pwd` -<5> Form as `User` object => `User(id=root, pass=pwd)` +===== Multipart & File Uploads -==== Multipart - -Form-data must be present in the HTTP body and encoded as `multipart/form-data`: +Multipart data is sent in the HTTP body and encoded as `multipart/form-data`. It is required for file uploads. .Java [source, java, role="primary"] @@ -418,29 +298,19 @@ Form-data must be present in the HTTP body and encoded as `multipart/form-data`: { post("/user", ctx -> { Multipart multipart = ctx.multipart(); // <1> - - Map multipartMap = ctx.multipartMultimap(); // <2> - + Map> multipartMap = ctx.multipartMultimap(); // <2> String userId = ctx.multipart("id").value(); // <3> - String pass = ctx.multipart("pass").value(); // <4> - FileUpload pic = ctx.file("pic"); // <5> - User user = ctx.multipart(User.class); // <6> - - ... + // ... }); } class User { - public final String id; - public final String pass; - public final FileUpload pic; - public User(String id, String pass, FileUpload pic) { this.id = id; this.pass = pass; @@ -455,48 +325,35 @@ class User { { post("/user") { ctx -> val multipart = ctx.multipart() // <1> - val multipartMap = ctx.multipartMultimap() // <2> - val userId = ctx.multipart("id").value() // <3> - val pass = ctx.multipart("pass").value() // <4> - val pic = ctx.file("pic") // <5> - val user = ctx.multipart() // <6> - - ... + // ... } } -data class User (val id: String, val pass: String, val pic: FileUpload) ----- - ----- -curl -F id=root -F pass=root -F pic=@/path/to/local/file/profile.png http://localhost:8080/user +data class User(val id: String, val pass: String, val pic: FileUpload) ---- -<1> Form as javadoc:Multipart[] => `{id=root, pass=pwd, pic=profile.png}` -<2> Form as `multi-value map` => `{id=root, pass=[pwd]}` -<3> Form variable `id` => `root` -<4> Form variable `pass` => `pwd` -<5> javadoc:FileUpload[] variable `pic` -<6> Form as `User` object => `User(id=root, pass=pwd, pic=profile.png)` +<1> Form as javadoc:Multipart[]. +<2> Form as a multi-value map. +<3> Specific multipart text variable `id`. +<4> Specific multipart text variable `pass`. +<5> Single file upload named `pic`. +<6> Multipart form bound to a `User` object (including the file). [NOTE] .File Upload ==== - -File upload are available ONLY for multipart requests. +File uploads are **only** available for multipart requests. .Java [source,java,role="primary"] ---- FileUpload pic = ctx.file("pic"); // <1> - - List pic = ctx.files("pic"); // <2> - + List pics = ctx.files("pic"); // <2> List files = ctx.files(); // <3> ---- @@ -504,58 +361,50 @@ File upload are available ONLY for multipart requests. [source,kotlin,role="secondary"] ---- val pic = ctx.file("pic") // <1> - - val pic = ctx.files("pic") // <2> - + val pics = ctx.files("pic") // <2> val files = ctx.files() // <3> ---- <1> Single file upload named `pic` <2> Multiple file uploads named `pic` <3> All file uploads - ==== -==== Session +===== Session -Session parameters are available via javadoc:Context[session] or javadoc:Context[sessionOrNull] -methods. HTTP Session is covered in his own <>, but here is a quick look: +Session parameters are available via javadoc:Context[session] or javadoc:Context[sessionOrNull]. (See the full <> for details). .Java [source,java,role="primary"] ---- Session session = ctx.session(); // <1> - String attribute = ctx.session("attribute").value(); // <2> - ---- .Kotlin [source,kotlin,role="secondary"] ---- val session = ctx.session() // <1> - val attribute = session.attribute("attribute").value() // <2> ---- -<1> Find an existing Session or create one -<2> Get a session attribute +<1> Finds an existing Session or creates a new one. +<2> Gets a specific session attribute. -==== Flash +===== Flash -Flash parameters are designed to transport success/error messages between requests. It is similar to -a javadoc:Session[] but the lifecycle is shorter: *data is kept for only one request*. +Flash parameters transport success/error messages between requests. They are similar to a session, but their lifecycle is shorter: **data is kept for only one request**. .Java [source,java,role="primary"] ---- get("/", ctx -> { - return ctx.flash("success").value("Welcome!"); <3> + return ctx.flash("success").value("Welcome!"); // <3> }); post("/save", ctx -> { - ctx.flash().put("success", "Item created"); <1> - return ctx.sendRedirect("/"); <2> + ctx.flash().put("success", "Item created"); // <1> + return ctx.sendRedirect("/"); // <2> }); ---- @@ -563,28 +412,26 @@ a javadoc:Session[] but the lifecycle is shorter: *data is kept for only one req [source,kotlin,role="secondary"] ---- get("/") { ctx -> - ctx.flash("success").value("Welcome!") <3> + ctx.flash("success").value("Welcome!") // <3> } post("/save") { ctx -> - ctx.flash().put("success", "Item created") <1> - ctx.sendRedirect("/") <2> + ctx.flash().put("success", "Item created") // <1> + ctx.sendRedirect("/") // <2> } ---- -<1> Set a flash attribute: `success` -<2> Redirect to home page -<3> Display an existing flash attribute `success` or shows `Welcome!` +<1> Sets a flash attribute: `success`. +<2> Redirects to the home page. +<3> Displays the flash attribute `success` (if it exists) or defaults to `Welcome!`. -Flash attributes are implemented using an `HTTP Cookie`. To customize the cookie -(its name defaults to `jooby.flash`) use the javadoc:Router[setFlashCookie, io.jooby.Cookie] method: +Flash attributes are implemented using an HTTP Cookie. To customize the cookie (the default name is `jooby.flash`), use javadoc:Router[setFlashCookie, io.jooby.Cookie]. .Java [source,java,role="primary"] ---- { setFlashCookie(new Cookie("myflash").setHttpOnly(true)); - // or if you're fine with the default name getFlashCookie().setHttpOnly(true); } @@ -595,16 +442,14 @@ Flash attributes are implemented using an `HTTP Cookie`. To customize the cookie ---- { flashCookie = Cookie("myflash").setHttpOnly(true) - // or if you're fine with the default name flashCookie.isHttpOnly = true } ---- -==== Parameter Lookup +===== Parameter Lookup -You can search for parameters in multiple sources with an explicitly defined priority using the -javadoc:Context[lookup] or javadoc:Context[lookup, java.lang.String, io.jooby.ParamSource...] method: +You can search for parameters across multiple sources with an explicitly defined priority using javadoc:Context[lookup]. .Java [source,java,role="primary"] @@ -634,21 +479,18 @@ get("/{foo}") { ctx -> } ---- -In case of a request like `/bar?foo=baz`, `foo is: baz` will be returned since the query parameter -takes precedence over the path parameter. +If a request is made to `/bar?foo=baz`, the result will be `foo is: baz` because the query parameter takes precedence over the path parameter. -==== Client Certificates +===== Client Certificates -If mutual TLS is enabled, you can access the client's certificates from the context. The first -certificate in the list is the peer certificate, followed by the ca certificates in the chain -(the order is preserved). +If mutual TLS is enabled, you can access the client's certificates from the context. The first certificate in the list is the peer certificate, followed by the CA certificates in the chain. .Java [source,java,role="primary"] ---- get("/{foo}", ctx -> { - List certificates = ctx.getClientCertificates(); <1> - Certificate peerCertificate = certificates.get(0); <2> + List certificates = ctx.getClientCertificates(); // <1> + Certificate peerCertificate = certificates.get(0); // <2> }); ---- @@ -656,12 +498,12 @@ get("/{foo}", ctx -> { [source,kotlin,role="secondary"] ---- get("/{foo}") { ctx -> - val certificates = ctx.clientCertificates <1> - val peerCertificate = certificates.first() <2> + val certificates = ctx.clientCertificates // <1> + val peerCertificate = certificates.first() // <2> } ---- -<1> Get all of the certificates presented by the client during the SSL handshake. +<1> Get all certificates presented by the client during the SSL handshake. <2> Get only the peer certificate. include::value-api.adoc[] diff --git a/docs/asciidoc/core.adoc b/docs/asciidoc/core.adoc new file mode 100644 index 0000000000..fd813fd0cf --- /dev/null +++ b/docs/asciidoc/core.adoc @@ -0,0 +1,15 @@ +== Core +[.lead] +The foundational building blocks of your Jooby application. This section covers how to bootstrap the server, manage application environments and configuration, and leverage the modular architecture that makes Jooby both lightweight and highly extensible. + +include::routing.adoc[] + +include::context.adoc[] + +include::responses.adoc[] + +include::handlers.adoc[] + +include::error-handler.adoc[] + +include::execution-model.adoc[] diff --git a/docs/asciidoc/dev-tools.adoc b/docs/asciidoc/dev-tools.adoc index 8fc01fa288..88d39c8038 100644 --- a/docs/asciidoc/dev-tools.adoc +++ b/docs/asciidoc/dev-tools.adoc @@ -1,49 +1,35 @@ -== Development +=== Development -The `jooby run` tool allows to restart your application on code changes without exiting the JVM. +The `jooby run` tool provides a "hot reload" experience by restarting your application automatically whenever code changes are detected, without exiting the JVM. This makes Java and Kotlin development feel as fast and iterative as a scripting language. -This feature is also known as hot reload/swap. Makes you feel like coding against a script -language where you modify your code and changes are visible immediately. - -The tool uses the https://jboss-modules.github.io/jboss-modules/manual[JBoss Modules] library -that effectively reload application classes. +The tool leverages https://jboss-modules.github.io/jboss-modules/manual[JBoss Modules] to efficiently reload application classes. It is available as both a Maven and a Gradle plugin. -For now `jooby run` is available as Maven and Gradle plugins. +==== Usage -=== Usage - -1) Add build plugin: +1) Add the build plugin: .pom.xml [source, xml, role = "primary", subs="verbatim,attributes"] ---- - ... io.jooby jooby-maven-plugin {joobyVersion} - ... ---- .build.gradle [source, gradle, role = "secondary", subs="verbatim,attributes"] ---- -buildscript { - ext { - joobyVersion = "{joobyVersion}" - } -} - plugins { id "application" id "io.jooby.run" version "{joobyVersion}" } ---- -2) Set main class +2) Configure the Main Class: .pom.xml [source, xml, role = "primary"] @@ -59,7 +45,7 @@ plugins { mainClassName = "myapp.App" ---- -3) Run application +3) Launch the Application: .Maven [source, bash, role = "primary"] @@ -73,77 +59,57 @@ mvn jooby:run ./gradlew joobyRun ---- -=== Compilation & Restart - -Changing a `java` or `kt` file triggers a compilation request. Compilation is executed by -Maven/Gradle using an incremental build process. - -If compilation succeed, application is restarted. - -Compilation errors are printed to the console by Maven/Gradle. +==== Compilation and Restart -Changing a `.conf`, `.properties` file triggers just an application restart request. They don't trigger -a compilation request. +* **Source Files (`.java`, `.kt`):** Changing a source file triggers an incremental compilation request. If the compilation succeeds, the application restarts automatically. +* **Configuration Files (`.conf`, `.properties`):** Changes to these files trigger an immediate application restart without a compilation step. +* **Compilation Errors:** Any errors during the build process are printed directly to the console by Maven or Gradle. -Compiler is enabled by default, except for Eclipse users. Plugin checks for `.classpath` file in -project directory, when found plugin compiler is OFF and let Eclipse compiles the code. +[NOTE] +==== +For **Eclipse** users: The plugin detects the `.classpath` file in your project directory. If found, the plugin's internal compiler is disabled, letting Eclipse handle the compilation while the plugin focuses on the restart logic. +==== -=== Options +==== Options -The next example shows all the available options with their default values: +Below are the available configuration options with their default values: .pom.xml [source, xml, role = "primary", subs="verbatim,attributes"] ---- - - ... - - io.jooby - jooby-maven-plugin - {joobyVersion} - - ${application.class} <1> - conf,properties,class <2> - java,kt <3> - 8080 <4> - 500 <5> - false <6> - - - ... - + + ${application.class} + conf,properties,class + java,kt + 8080 + 500 + false + ---- .build.gradle [source, gradle, role = "secondary", subs="verbatim,attributes"] ---- -buildscript { - ext { - joobyVersion = "{joobyVersion}" - } -} - -plugins { - id "application" - id "io.jooby.run" version "{joobyVersion}" -} - joobyRun { - mainClassName = "${mainClassName}" <1> - restartExtensions = ["conf", "properties", "class"] <2> - compileExtensions = ["java", "kt"] <3> - port = 8080 <4> - waitTimeBeforeRestart = 500 <5> - useSingleClassLoader = false <6> + mainClassName = "${mainClassName}" // <1> + restartExtensions = ["conf", "properties", "class"] // <2> + compileExtensions = ["java", "kt"] // <3> + port = 8080 // <4> + waitTimeBeforeRestart = 500 // <5> + useSingleClassLoader = false // <6> } ---- -<1> Application main class -<2> Restart extensions. A change on these files trigger a restart request. -<3> Source extensions. A change on these files trigger a compilation request, followed by a restart request. -<4> Application port -<5> How long to wait after last file change to restart. Default is: `500` milliseconds. -<6> Use a single/fat class loader to run your application. This is required on complex project classpath where you start seeing weird reflection errors. This was the default mode in Jooby 2.x. The new model since 3.x uses a modular classloader which improves restart times and memory usage making it faster. Default is: `false`. +<1> The application's entry point (Main class). +<2> Extensions that trigger an immediate restart. +<3> Extensions that trigger a compilation followed by a restart. +<4> The local development port. +<5> The delay (in milliseconds) to wait after the last file change before restarting. Default is `500ms`. +<6> If `true`, Jooby uses a single "fat" classloader. Set this to `true` if you encounter strange reflection or class-loading errors in complex projects. Since 3.x, Jooby uses a modular classloader by default for faster restarts and lower memory usage. + +==== Testing with Classpath + +To run the application while including the `test` scope/source set in the classpath, use the following commands: -For Maven and Gradle there are two variant `mvn jooby:testRun` and `./gradlew joobyTestRun` they work -by expanding the classpath to uses the `test` scope or source set. +* **Maven:** `mvn jooby:testRun` +* **Gradle:** `./gradlew joobyTestRun` diff --git a/docs/asciidoc/docinfo-footer.html b/docs/asciidoc/docinfo-footer.html index 52f25902ee..7ef6b48b49 100644 --- a/docs/asciidoc/docinfo-footer.html +++ b/docs/asciidoc/docinfo-footer.html @@ -1,60 +1,157 @@ - + + + + + - - + /** 5. Copy Header Anchor Links to Clipboard */ + document.querySelectorAll('a.anchor').forEach(anchor => { + anchor.addEventListener('click', function(e) { + // We do NOT prevent default, so the browser still scrolls to the header naturally - - diff --git a/docs/asciidoc/docinfo-header.html b/docs/asciidoc/docinfo-header.html new file mode 100644 index 0000000000..9c5e1f744f --- /dev/null +++ b/docs/asciidoc/docinfo-header.html @@ -0,0 +1,6 @@ +
+ Jooby + +
diff --git a/docs/asciidoc/docinfo.html b/docs/asciidoc/docinfo.html index 9320623452..27704b6ebf 100644 --- a/docs/asciidoc/docinfo.html +++ b/docs/asciidoc/docinfo.html @@ -1,123 +1,12 @@ - + + + + diff --git a/docs/asciidoc/ecosystem.adoc b/docs/asciidoc/ecosystem.adoc new file mode 100644 index 0000000000..36359f1679 --- /dev/null +++ b/docs/asciidoc/ecosystem.adoc @@ -0,0 +1,209 @@ +== Ecosystem +[.lead] +Extend the power of Jooby through its rich ecosystem of modules and standards. Learn how to seamlessly integrate with OpenAPI 3 to automatically generate interactive documentation and client SDKs, and explore a wide array of community and first-party modules that bring database access, security, and messaging to your application with minimal configuration. + +The Jooby ecosystem is built on three core, interconnected concepts: + +1. **Services:** The objects and dependencies your application needs to run. +2. **Extensions:** The mechanism for packaging and registering those services, along with routes and configuration. +3. **Modules:** Pre-built extensions provided by Jooby to integrate popular third-party libraries. + +=== Services and the Registry + +At its core, Jooby uses a simple, built-in map called the `ServiceRegistry` to manage application state and dependencies. + +Services can be registered as immediate singletons, or their lifecycle can be customized by registering a `jakarta.inject.Provider`. + +You can explicitly put and retrieve services from the registry: + +.Basic Service Registry +[source, java, role = "primary"] +---- +import jakarta.inject.Provider; + +{ + // 1. Put a singleton service into the registry + getServices().put(MyDatabase.class, new MyDatabase()); + + // 2. Put a provider to customize lifecycle (e.g., prototype/lazy creation) + getServices().put(MyService.class, (Provider) () -> new MyService()); + + get("/", ctx -> { + // 3. Require the service at runtime + MyDatabase db = require(MyDatabase.class); + MyService service = require(MyService.class); + + return db.query(); + }); +} +---- + +.Kotlin +[source, kotlin, role = "secondary"] +---- +import jakarta.inject.Provider + +{ + // 1. Put a singleton service into the registry + services.put(MyDatabase::class.java, MyDatabase()) + + // 2. Put a provider to customize lifecycle (e.g., prototype/lazy creation) + services.put(MyService::class.java, Provider { MyService() }) + + get("/") { ctx -> + // 3. Require the service at runtime + val db = require(MyDatabase::class.java) + val service = require(MyService::class.java) + + db.query() + } +} +---- + +==== Collections of Services + +The registry also supports grouping multiple services of the same type using Lists, Sets, or Maps. + +.Collections +[source,java,role="primary"] +---- +import io.jooby.Reified; + +{ + // Add to a List + getServices().listOf(Animal.class).add(new Cat()); + getServices().listOf(Animal.class).add(new Dog()); + + // Add to a Map + getServices().mapOf(String.class, Animal.class).put("cat", new Cat()); + + get("/list", ctx -> { + // Retrieve the List using the Reified type helper + List animals = ctx.require(Reified.list(Animal.class)); + return animals; + }); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +import io.jooby.Reified + +{ + // Add to a List + services.listOf(Animal::class.java).add(Cat()) + services.listOf(Animal::class.java).add(Dog()) + + // Add to a Map + services.mapOf(String::class.java, Animal::class.java).put("cat", Cat()) + + get("/list") { ctx -> + // Retrieve the List using the Reified type helper + val animals = ctx.require(Reified.list(Animal::class.java)) + animals + } +} +---- + +==== Dependency Injection (DI) Bridge + +While the `ServiceRegistry` acts as a simple service locator out-of-the-box, its true power lies in its ability to bridge to full Dependency Injection frameworks. + +When you install a DI module (like Guice, Dagger, or Avaje Inject), the `require()` method seamlessly delegates to the underlying DI container. This allows you to use standard `jakarta.inject.Inject` annotations on your controllers and services, while still falling back to the Jooby registry when needed. + +Checkout our <>. + +=== Extensions + +The javadoc:Extension[] API is how you package and distribute configuration, infrastructure, and services. It is a simple way of reusing code and decoupling technical features from your business logic. + +==== Writing a Custom Extension + +Let's develop a custom extension that configures a `DataSource` service, registers it, and ensures it closes when the application shuts down. + +.Java +[source, java, role = "primary"] +---- +import io.jooby.Extension; +import io.jooby.Jooby; + +public class MyExtension implements Extension { + @Override + public void install(Jooby app) { + DataSource dataSource = createDataSource(); // <1> + + app.getServices().put(DataSource.class, dataSource);// <2> + + app.onStop(dataSource::close); // <3> + } + + private DataSource createDataSource() { + // Initialization logic + } +} +---- + +.Kotlin +[source, kotlin, role = "secondary"] +---- +import io.jooby.Extension +import io.jooby.Jooby + +class MyExtension : Extension { + override fun install(app: Jooby) { + val dataSource = createDataSource() // <1> + + app.services.put(DataSource::class.java, dataSource) // <2> + + app.onStop(dataSource::close) // <3> + } + + private fun createDataSource(): DataSource { + // Initialization logic + } +} +---- + +<1> Create the service. +<2> Save the service into the application's service registry. +<3> Register a lifecycle hook to clean up the service when the application stops. + +Now, you can install the extension in your main application and use the service: + +.Java +[source, java, role = "primary"] +---- +public class App extends Jooby { + { + install(new MyExtension()); // <1> + + get("/", ctx -> { + DataSource ds = require(DataSource.class); // <2> + // Use the datasource... + return "Success"; + }); + } +} +---- + +.Kotlin +[source, kotlin, role = "secondary"] +---- +class App : Kooby({ + install(MyExtension()) // <1> + + get("/") { + val ds = require(DataSource::class.java) // <2> + // Use the datasource... + "Success" + } +}) +---- + +<1> Install the custom extension. +<2> Retrieve the service that the extension registered. + +Extensions are incredibly flexible. In addition to registering services, an extension can add standard routes, configure body decoders/encoders, or set up template engines. + +include::modules/modules.adoc[] diff --git a/docs/asciidoc/error-handler.adoc b/docs/asciidoc/error-handler.adoc index 26e62b467b..f93c3b514b 100644 --- a/docs/asciidoc/error-handler.adoc +++ b/docs/asciidoc/error-handler.adoc @@ -1,17 +1,15 @@ -== Error Handler +=== Error Handler -Jooby catches application exception using the javadoc:ErrorHandler[] class. The -javadoc:DefaultErrorHandler[] error handler produces simple HTML page or JSON based on the value -of the `ACCEPT` header and log the exception. +Jooby catches application exceptions using the javadoc:ErrorHandler[] class. By default, the javadoc:DefaultErrorHandler[] produces a simple HTML page or a JSON response based on the request's `Accept` header, and logs the exception. -.HTML: +.HTML output: ---- Not Found message: Page not found status code: 404 ---- -.JSON: +.JSON output: ---- { "message": "Page not found", @@ -20,57 +18,51 @@ status code: 404 } ---- -.Log: +.Log output: ---- GET /xx 404 Not Found -io.jooby.StatusCodeException: Not found - at ... - at ... - at ... - at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149) - at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624) - at java.lang.Thread.run(Thread.java:748) +io.jooby.exception.StatusCodeException: Not found + at ... ---- -The javadoc:exception.StatusCodeException[] works as generic exception that let you specify an status code. +The javadoc:exception.StatusCodeException[] is a generic exception that lets you explicitly specify an HTTP status code: +[source, java] ---- throw new StatusCodeException(StatusCode.FORBIDDEN); - throw new StatusCodeException(StatusCode.NOT_FOUND); - -... ---- -These exception types have a default status code: +Several standard Java exceptions are automatically mapped to default status codes: -- IllegalArgumentException: BAD_REQUEST(400) (or sub-classes of it) -- NoSuchElementException: BAD_REQUEST(400) (or sub-classes of it) -- FileNotFound: NOT_FOUND(404) (or sub-classes of it) -- Exception: SERVER_ERROR(500) (or sub-classes of it) +* `IllegalArgumentException` (and subclasses): `400 BAD_REQUEST` +* `NoSuchElementException` (and subclasses): `400 BAD_REQUEST` +* `FileNotFoundException` (and subclasses): `404 NOT_FOUND` +* `Exception` (and all other subclasses): `500 SERVER_ERROR` -To set a custom status code, an entry should be added it to the error code map: +To map a custom exception to a specific status code, register it using the `errorCode` method: [source, java] ---- { - errorCode(MyException.class, StatusCode.XXX); + errorCode(MyException.class, StatusCode.UNPROCESSABLE_ENTITY); } ---- -=== Custom Error Handler +==== Custom Error Handler -You can provide your own error handler using the javadoc:Router[error, io.jooby.ErrorHandler] method: +You can override the default behavior and provide a custom global error handler using the javadoc:Router[error, io.jooby.ErrorHandler] method: -.Error Handler +.Global Error Handler [source, java, role = "primary"] ---- { - error((ctx, cause, statusCode) -> { <1> + error((ctx, cause, statusCode) -> { // <1> Router router = ctx.getRouter(); - router.getLog().error("found `{}` error", statusCode.value(), cause); <2> + router.getLog().error("Found {} error", statusCode.value(), cause); // <2> + ctx.setResponseCode(statusCode); - ctx.send("found `" + statusCode.value() + "` error"); <3> + ctx.send("Encountered a " + statusCode.value() + " error"); // <3> }); } ---- @@ -79,47 +71,42 @@ You can provide your own error handler using the javadoc:Router[error, io.jooby. [source, kotlin, role = "secondary"] ---- { - error { ctx, cause, statusCode -> <1> + error { ctx, cause, statusCode -> // <1> val router = ctx.router - router.log.error("found `{}` error", statusCode.value(), cause) <2> + router.log.error("Found {} error", statusCode.value(), cause) // <2> + ctx.responseCode = statusCode - ctx.send("found `${statusCode.value()}` error") <3> + ctx.send("Encountered a ${statusCode.value()} error") // <3> } } ---- -<1> Add a global/catch-all exception handler -<2> Log the error to logging system -<3> Send an error response to the client +<1> Register a global (catch-all) exception handler. +<2> Log the error. +<3> Send a custom error response to the client. -You can use the javadoc:Context[render, java.lang.Object] object which looks for a registered -javadoc:MessageEncoder[] or javadoc:TemplateEngine[]. +You can use the javadoc:Context[render, java.lang.Object] method inside the error handler to delegate the response to a registered javadoc:MessageEncoder[] or javadoc:TemplateEngine[]. -The next example produces a HTML or JSON response based on the value of the `Accept` header. +The next example produces an HTML or JSON response based on the client's `Accept` header using content negotiation: -.Error with content negotiation +.Error Handling with Content Negotiation [source, java, role = "primary"] ---- - import static io.jooby.MediaType.json; -import static io.jooby.MediaType.html; { - install(new MyTemplateEngineModule()); <1> - - install(new MyJsonModule()); <2> + install(new MyTemplateEngineModule()); // <1> + install(new MyJsonModule()); // <2> error((ctx, cause, statusCode) -> { - Router router = ctx.getRouter(); - router.getLog().error("found `{}` error", statusCode.value(), cause); + ctx.getRouter().getLog().error("Error: {}", statusCode.value(), cause); - if (ctx.accept(json)) { <3> - Map error = ...; - ctx.render(error); <4> + Map errorData = Map.of("message", cause.getMessage()); + + if (ctx.accept(json)) { // <3> + ctx.render(errorData); // <4> } else { - // fallback to html - Map error = ...; - ctx.render(new ModelAndView("error.template", error)); <5> + ctx.render(new ModelAndView("error.template", errorData)); // <5> } }); } @@ -129,46 +116,43 @@ import static io.jooby.MediaType.html; [source, kotlin, role = "secondary"] ---- import io.jooby.MediaType.json -import io.jooby.MediaType.html { - install(MyTemplateEngineModule()) <1> - - install(MyJsonModule()) <2> + install(MyTemplateEngineModule()) // <1> + install(MyJsonModule()) // <2> - error {ctx, cause, statusCode -> - val router = ctx.router - router.log.error("found `{}` error", statusCode.value(), cause) + error { ctx, cause, statusCode -> + ctx.router.log.error("Error: {}", statusCode.value(), cause) - if (ctx.accept(json)) { <3> - val error = mapOf(...) - ctx.render(error) <4> + val errorData = mapOf("message" to cause.message) + + if (ctx.accept(json)) { // <3> + ctx.render(errorData) // <4> } else { - // fallback to html - val error = mapOf(...) - ctx.render(ModelAndView("error.template", error)) <5> + ctx.render(ModelAndView("error.template", errorData)) // <5> } } } ---- -<1> Install one of the available <> -<2> Install one of the available <> -<3> Test if the accept header matches the `application/json` content type -<4> Render json if matches -<5> Render html as fallback +<1> Install a <> module. +<2> Install a <>. +<3> Check if the `Accept` header prefers `application/json`. +<4> Render the JSON response if matched. +<5> Fallback to rendering an HTML template. -=== Catch by Code +===== Catch by Code -In addition to the generic/global error handler you can catch specific status code: +In addition to the global error handler, you can register handlers for specific HTTP status codes: -.Status Code Error Handler +.Status Code Handler [source, java, role = "primary"] ---- import static io.jooby.StatusCode.NOT_FOUND; + { error(NOT_FOUND, (ctx, cause, statusCode) -> { - ctx.send(statusCode); <1> + ctx.send(statusCode); // <1> }); } ---- @@ -177,32 +161,33 @@ import static io.jooby.StatusCode.NOT_FOUND; [source, kotlin, role = "secondary"] ---- import io.jooby.StatusCode.NOT_FOUND + { - error (NOT_FOUND) { ctx, cause, statusCode -> - ctx.send(statusCode) <1> + error(NOT_FOUND) { ctx, cause, statusCode -> + ctx.send(statusCode) // <1> } } ---- -<1> Send `404` response to the client +<1> Send a silent `404` response to the client. -Here we kind of silence all the `404` response due we don't log anything and send an empty response. +In this example, we silence all `404` responses by bypassing the logging system and sending an empty response body. [TIP] ==== -The javadoc:Context[send, io.jooby.StatusCode] method send an empty response to the client +The javadoc:Context[send, io.jooby.StatusCode] method sends an empty HTTP response with the specified status code. ==== -=== Catch by Exception +===== Catch by Exception -In addition to the generic/global error handler you can catch specific exception type: +You can also register handlers for specific exception types. This is useful for intercepting business-logic exceptions before they hit the global handler: -.Exception Handler +.Exception Type Handler [source, java, role = "primary"] ---- { - error(MyException.class, (ctx, cause, statusCode) -> { - // log and process MyException + error(MyBusinessException.class, (ctx, cause, statusCode) -> { + // Log and handle MyBusinessException specifically }); } ---- @@ -211,9 +196,10 @@ In addition to the generic/global error handler you can catch specific exception [source, kotlin, role = "secondary"] ---- { - error (MyException::class) { ctx, cause, statusCode -> - // log and process MyException + error(MyBusinessException::class) { ctx, cause, statusCode -> + // Log and handle MyBusinessException specifically } } ---- +include::problem-details.adoc[] diff --git a/docs/asciidoc/execution-model.adoc b/docs/asciidoc/execution-model.adoc index 80e96610c5..d71fe1aea3 100644 --- a/docs/asciidoc/execution-model.adoc +++ b/docs/asciidoc/execution-model.adoc @@ -1,22 +1,18 @@ -== Execution Model +=== Execution Model -Jooby is a flexible performant microframework providing both blocking and non-blocking APIs for -building web applications in Java and Kotlin. +Jooby is a flexible, performant micro-framework that provides both blocking and non-blocking APIs for building web applications in Java and Kotlin. -In this chapter we are going to learn about Jooby execution model, more specifically: +In this chapter, we will cover the Jooby execution model, specifically: -- Execute code on the *event loop* +* Executing code on the **event loop**. +* Safely executing **blocking code**. +* Working with **non-blocking types** like `CompletableFuture`, Reactive Streams, and Kotlin Coroutines. -- Safely execution of blocking code +==== Mode -- Working with non-blocking types, like: `CompletableFuture`, Reactive Streams, Kotlin Coroutines, etc. +===== Event Loop -=== Mode - -==== Event Loop - -The javadoc:ExecutionMode[EVENT_LOOP] mode allows us to run a route handler from the -*event loop* (a.k.a as non-blocking mode). +The javadoc:ExecutionMode[EVENT_LOOP] mode allows you to run route handlers directly on the event loop (a.k.a. non-blocking mode). .Java [source,java,role="primary"] @@ -25,9 +21,8 @@ import static io.jooby.ExecutionMode.EVENT_LOOP; import static io.jooby.Jooby.runApp; public class App extends Jooby { - { - get("/", ctx -> "I'm non-blocking!" ); + get("/", ctx -> "I'm non-blocking!"); } public static void main(String[] args) { @@ -40,7 +35,7 @@ public class App extends Jooby { [source,kotlin,role="secondary"] ---- import io.jooby.ExecutionMode.EVENT_LOOP -import io.jooby.Jooby.runApp +import io.jooby.kt.runApp fun main(args: Array) { runApp(args, EVENT_LOOP) { @@ -49,13 +44,11 @@ fun main(args: Array) { } ---- -The javadoc:ExecutionMode[EVENT_LOOP] mode is the more advanced execution mode and requires you carefully -design and implement your application due to that *BLOCKING IS NOT ALLOWED* +The javadoc:ExecutionMode[EVENT_LOOP] mode is an advanced execution model that requires careful application design, because **BLOCKING IS STRICTLY FORBIDDEN** on the event loop thread. -What if you need to block? +**What if you need to block?** -The javadoc:Router[dispatch, java.lang.Runnable] operator moves execution to a *worker executor* which -allows to do *blocking calls*: +The javadoc:Router[dispatch, java.lang.Runnable] operator shifts execution to a worker executor, which safely allows blocking calls (like database queries or remote service calls): .Java [source,java,role="primary"] @@ -64,21 +57,16 @@ import static io.jooby.ExecutionMode.EVENT_LOOP; import static io.jooby.Jooby.runApp; public class App extends Jooby { - { - get("/", ctx -> { - return "I'm non-blocking!"; - }); + get("/", ctx -> "I'm non-blocking!"); dispatch(() -> { - // All the routes defined here are allowed to block: - + // All routes defined inside this block are allowed to block: get("/db-list", ctx -> { - /** Safe to block! */ - Object result = ...; // Remote service, db call, etc.. + // Safe to block! + Object result = fetchFromDatabase(); return result; }); - }); } @@ -92,60 +80,54 @@ public class App extends Jooby { [source,kotlin,role="secondary"] ---- import io.jooby.ExecutionMode.EVENT_LOOP -import io.jooby.Jooby.runApp +import io.jooby.kt.runApp fun main(args: Array) { runApp(args, EVENT_LOOP) { - - get("/") { - "I'm non-blocking!" - } + get("/") { "I'm non-blocking!" } dispatch { - // All the routes defined here are allowed to block: - + // All routes defined inside this block are allowed to block: get("/db-list") { - /** Safe to block! */ - val result = ...; // Remote service, db call, etc.. + // Safe to block! + val result = fetchFromDatabase() result } - } } } ---- -By default, the javadoc:Router[dispatch, java.lang.Runnable] operator moves execution to the *server -worker executor* (executor provided by web server). +By default, the `dispatch` operator moves execution to the default worker executor provided by the underlying web server. -You can provide your own *worker executor* at application level or at dispatch level: +However, you can provide your own custom worker executor at the application level or specifically for a single dispatch block: .Java [source,java,role="primary"] ---- import static io.jooby.ExecutionMode.EVENT_LOOP; import static io.jooby.Jooby.runApp; +import java.util.concurrent.Executors; public class App extends Jooby { - { - // Application level executor + // Application-level executor worker(Executors.newCachedThreadPool()); - // Dispatch to application level executor which is cached thread pool + // Dispatches to the application-level executor (the cached thread pool) dispatch(() -> { - ... + // ... }); - // Dispatch to a explicit executor - Executor cpuIntensive = Executors.newSingleThreadExecutor(); - dispatch(cpuIntesive, () -> { - ... + // Dispatches to an explicit, custom executor + var cpuIntensive = Executors.newSingleThreadExecutor(); + dispatch(cpuIntensive, () -> { + // ... }); } public static void main(String[] args) { - runApp(args, EVENT_LOOP, App:new); + runApp(args, EVENT_LOOP, App::new); } } ---- @@ -154,32 +136,32 @@ public class App extends Jooby { [source,kotlin,role="secondary"] ---- import io.jooby.ExecutionMode.EVENT_LOOP -import io.jooby.Jooby.runApp +import io.jooby.kt.runApp +import java.util.concurrent.Executors fun main(args: Array) { runApp(args, EVENT_LOOP) { - // Application level executor + // Application-level executor worker(Executors.newCachedThreadPool()) - // Dispatch to application level executor which is cached thread pool + // Dispatches to the application-level executor (the cached thread pool) dispatch { - ... + // ... } - // Dispatch to a explicit executor - Executor cpuIntensive = Executors.newSingleThreadExecutor() - dispatch(cpuIntesive) { - ... + // Dispatches to an explicit, custom executor + val cpuIntensive = Executors.newSingleThreadExecutor() + dispatch(cpuIntensive) { + // ... } } } ---- -==== Worker +===== Worker -The javadoc:ExecutionMode[WORKER] mode allows us to do blocking calls from a route handler (a.k.a blocking mode). -You just write code without worrying about blocking calls. +The javadoc:ExecutionMode[WORKER] mode allows you to make blocking calls from *any* route handler (a.k.a. blocking mode). You can write code sequentially without worrying about blocking the server. .Java [source, java,role="primary"] @@ -188,11 +170,10 @@ import static io.jooby.ExecutionMode.WORKER; import static io.jooby.Jooby.runApp; public class App extends Jooby { - { get("/", ctx -> { - /** Safe to block! */ - Object result = // Remote service, db call, etc.. + // Safe to block! + Object result = fetchFromDatabase(); return result; }); } @@ -207,36 +188,35 @@ public class App extends Jooby { [source, kotlin,role="secondary"] ---- import io.jooby.ExecutionMode.WORKER -import io.jooby.Jooby.runApp +import io.jooby.kt.runApp fun main(args: Array) { runApp(args, WORKER) { - get("/") { - /** Safe to block! */ - val result = ...;// Remote service, db call, etc.. + // Safe to block! + val result = fetchFromDatabase() result } } } ---- -Like with javadoc:ExecutionMode[EVENT_LOOP] mode, you can provide your own worker executor: +Just like in `EVENT_LOOP` mode, you can override the default server worker and provide your own custom executor: .Java [source,java,role="primary"] ---- import static io.jooby.ExecutionMode.WORKER; import static io.jooby.Jooby.runApp; +import java.util.concurrent.Executors; public class App extends Jooby { - { worker(Executors.newCachedThreadPool()); get("/", ctx -> { - /** Safe to block from cached thread pool! */ - Object result = // Remote service, db call, etc.. + // Safe to block! Handled by the cached thread pool. + Object result = fetchFromDatabase(); return result; }); } @@ -251,16 +231,16 @@ public class App extends Jooby { [source,kotlin,role="secondary"] ---- import io.jooby.ExecutionMode.WORKER -import io.jooby.Jooby.runApp +import io.jooby.kt.runApp +import java.util.concurrent.Executors fun main(args: Array) { runApp(args, WORKER) { - worker(Executors.newCachedThreadPool()) get("/") { - /** Safe to block from cached thread pool! */ - val result = ...;// Remote service, db call, etc.. + // Safe to block! Handled by the cached thread pool. + val result = fetchFromDatabase() result } } @@ -269,38 +249,34 @@ fun main(args: Array) { [NOTE] ==== -While running in javadoc:ExecutionMode[WORKER] mode, Jooby internally does the dispatch call to the -worker executor. This is done per route, not globally. +When running in `WORKER` mode, Jooby implicitly applies a dispatch call to the worker executor for every route. ==== -==== Default - -The javadoc:ExecutionMode[DEFAULT] execution mode is a mix between javadoc:ExecutionMode[WORKER] -and javadoc:ExecutionMode[EVENT_LOOP] modes. This (as name implies) is the default execution mode in Jooby. +===== Default -Jooby detects the route response type and determines which execution mode fits better. +The javadoc:ExecutionMode[DEFAULT] execution mode is a smart hybrid between the `WORKER` and `EVENT_LOOP` modes. As the name implies, **this is the default execution mode in Jooby**. -If the response type is considered non-blocking, then it uses the *event loop*. Otherwise, it uses -the *worker executor*. +Jooby analyzes the return type of your route handler to determine which execution mode fits best. +If the response type is non-blocking, it executes on the event loop. Otherwise, it dispatches to the worker executor. -A response type is considered *non-blocking* when route handler produces: +A response type is considered **non-blocking** if the route handler produces: -- A `CompletableFuture` type -- A https://github.com/ReactiveX/RxJava[RxJava type] -- A https://projectreactor.io/[Reactor type] -- A https://kotlinlang.org/docs/reference/coroutines/coroutines-guide.html[Kotlin coroutine] +* A `CompletableFuture` +* An RxJava type (e.g., `Single`, `Flowable`) +* A Reactor type (e.g., `Mono`, `Flux`) +* A Kotlin Coroutine .Java [source, java,role="primary"] ---- import static io.jooby.Jooby.runApp; +import java.util.concurrent.CompletableFuture; public class App extends Jooby { - { get("/non-blocking", ctx -> { return CompletableFuture - .supplyAsync(() -> "I'm non-blocking!") // <1> + .supplyAsync(() -> "I'm non-blocking!"); // <1> }); get("/blocking", ctx -> { @@ -317,73 +293,61 @@ public class App extends Jooby { .Kotlin [source, kotlin,role="secondary"] ---- -import io.jooby.Jooby.runApp +import io.jooby.kt.runApp +import java.util.concurrent.CompletableFuture fun main(args: Array) { runApp(args) { get("/non-blocking") { CompletableFuture - .supplyAsync { "I'm non-blocking!" } // <1> + .supplyAsync { "I'm non-blocking!" } // <1> } get("/blocking") { - "I'm blocking" // <2> + "I'm blocking" // <2> } } } ---- -<1> `CompletableFuture` is a non-blocking type, run in *event loop* -<2> `String` is a blocking type, run in *worker executor* +<1> `CompletableFuture` is a non-blocking type; this route executes directly on the **event loop**. +<2> `String` is a blocking type; this route is dispatched to the **worker executor**. [TIP] ==== -You are free to use *non-blocking* types in all the other execution mode too. Non-blocking response -types are not specific to the **default mode** execution. All the *default mode* does with them is -to dispatch or not to a *worker executor*. +You are free to return non-blocking types while running in explicit `WORKER` or `EVENT_LOOP` modes. Non-blocking response types are not exclusive to `DEFAULT` mode; all `DEFAULT` mode does is dynamically decide whether to dispatch to a worker based on that return type. ==== -=== Worker Executor - -This section described some details about the default *worker executor* provided by web server. The -worker executor is used when: +==== Worker Executor -- Application mode was set to javadoc:ExecutionMode[WORKER] +This section details the default worker executors provided by the underlying web servers. The worker executor is used when: -- Application mode was set to javadoc:ExecutionMode[EVENT_LOOP] and there is a javadoc:Router[dispatch, java.lang.Runnable] call +* The application mode is set to `WORKER` (or `DEFAULT` returning a blocking type). -Each web server provides a default *worker executor*: +* The application mode is set to `EVENT_LOOP` and an explicit `dispatch` block is used. -- Netty: The javadoc:netty.NettyServer[text=Netty server, artifact=jooby-netty] implementation multiply the number of available processors -(with a minimum of 2) by 8. +Each web server provides its own default worker executor tuning: +* **Netty:** The javadoc:netty.NettyServer[text=Netty server, artifact=jooby-netty] implementation multiplies the number of available processors (with a minimum of 2) by 8. ---- workerThreads = Math.max(Runtime.getRuntime().availableProcessors(), 2) * 8 ---- -For example `8` cores gives us `64` worker threads. - -- Undertow: The javadoc:undertow.UndertowServer[text=Undertow server, artifact=jooby-undertow] implementation multiply the number of available processors -by 8. - +* **Undertow:** The javadoc:undertow.UndertowServer[text=Undertow server, artifact=jooby-undertow] implementation multiplies the number of available processors by 8. ---- workerThreads = Runtime.getRuntime().availableProcessors() * 8 ---- -For `8` cores gives us `64` worker threads. - -- Jetty: The javadoc:jetty.JettyServer[text=Jetty server, artifact=jooby-jetty] implementation uses the default configuration -with `200` worker threads. +* **Jetty:** The javadoc:jetty.JettyServer[text=Jetty server, artifact=jooby-jetty] implementation uses a default configuration of **200** worker threads. -These are sensible defaults suggested by the server implementation. If you need to increase/decrease -worker threads: +These are sensible defaults provided by the server implementations. If you need to increase or decrease the number of worker threads globally, you can configure the server: .Java [source,java,role="primary"] ---- { configureServer(server -> { - server.workerThreads(Number); + server.setWorkerThreads(100); }); } ---- @@ -393,7 +357,7 @@ worker threads: ---- { configureServer { server -> - server.workerThreads(Number) + server.setWorkerThreads(100) } } ----- +---- diff --git a/docs/asciidoc/extension.adoc b/docs/asciidoc/extension.adoc deleted file mode 100644 index 520812f0b4..0000000000 --- a/docs/asciidoc/extension.adoc +++ /dev/null @@ -1,152 +0,0 @@ -== Extensions and Services - -Jooby comes with a simple extension mechanism. The javadoc:Extension[] API allows to configure -, extend an application by adding shared/single services, infrastructure/technical concerns like -dependency injection, database connection pools, cron-job, etc. - -Services are shared/singleton objects, usually with a clear lifecycle for starting and stopping them. - -=== Writing Custom Extension - -We are going to develop a custom extension that configure a `DataSource` service. - -.Java -[source, java, role = "primary"] ----- -import io.jooby.Extension; - -public class MyExtension implements Extension { - - public void install(Jooby application) { - DataSource dataSource = createDataSource(); <1> - - ServiceRegistry registry = application.getServices(); <2> - registry.put(MyDataSource.class, dataSource); <3> - - application.onStop(dataSource::close) <4> - } -} ----- - -.Kotlin -[source, kotlin, role = "secondary"] ----- -import io.jooby.Extension - -class MyExtension: Extension { - override fun install(application: Jooby) { - val dataSource = createDataSource() <1> - - val registry = application.services <2> - registry.put(MyDataSource::class, dataSource) <3> - application.onStop(dataSource::close) <4> - } -} ----- - -<1> Create the service (DataSource) -<2> Access to the service registry -<3> Save into the service registry -<4> Close/release service on application stop - -Let's install the extension and use the service!! - - -.Java -[source, java, role = "primary"] ----- -public class App extends Jooby { - - { - install(new MyExtension()); <1> - - get("/", ctx -> { - MyDataSource ds = require(MyDataSource.class); <2> - // ... - }); - } -} ----- - -.Kotlin -[source, kotlin, role = "secondary"] ----- -import io.jooby.Extension - -class App: Kooby({ - - install(MyExtension()) <1> - - get("/") { - val ds = require(MyDataSource::class) <2> - } -}) ----- - -<1> Install the extension -<2> Use the service - -=== Collection of Services - -Adding a collection to the service registry is supported too. - -.List/Set -[source,java] ----- -import io.jooby.Extension;import io.jooby.Reified; - -public class Cats implements Extension { - - public void install(Jooby application) { - ServiceRegistry registry = application.getServices(); - // as list - registry.listOf(Animal.class).add(new Cat()); - // as set - registry.setOf(Animal.class).add(new Cat()); - // as map - registry.mapOf(String.class, Animal.class).put("cat", new Cat()); - } -} - -public class Dogs implements Extension { - - public void install(Jooby application) { - ServiceRegistry registry = application.getServices(); - // as list - registry.listOf(Animal.class).add(new Dog()); - // as set - registry.setOf(Animal.class).add(new Dog()); - // as map - registry.mapOf(String.class, Animal.class).put("dog", new Dog()); - } -} - -{ - install(new Cats()); - install(new Dogs()); - // as list - get("/list", ctx -> { - return ctx.require(Reified.list(Animal.class)); - }); - // as set - get("/set", ctx -> { - return ctx.require(Reified.set(Animal.class)); - }); - // as map - get("/map", ctx -> { - return ctx.require(Reified.map(String.class, Animal.class)); - }); -} ----- - -Services are accessible via javadoc:Registry[require, java.lang.Class] and integrated with -dependency injection frameworks provided by Jooby like Guice and Avaje Inject. This mean they are -accessible using the `jakarta.inject.Inject` annotation. - -In addition to services, an extension module may provides infrastructure routes, body decoder/encoder, -template engines, etc. - -The extension mechanism is a simple way of reusing code and decoupling technical features from -business logic. - -More advanced techniques are described in the next section. diff --git a/docs/asciidoc/getting-started.adoc b/docs/asciidoc/getting-started.adoc index 0e5f28f4fc..bd72bc1b53 100644 --- a/docs/asciidoc/getting-started.adoc +++ b/docs/asciidoc/getting-started.adoc @@ -1,167 +1,5 @@ == Getting Started -The best way of getting started is using the `jooby console`. It is a small application that generates -Jooby projects very quickly. +include::intro.adoc[] -**Features** - -- Maven or Gradle build -- Java or Kotlin application -- Script or MVC routes -- Jetty, Netty or Undertow application -- Uber/Fat jar or https://github.com/fizzed/stork[Stork native launcher] -- Dockerfile - -To install the console: - -- Download https://repo1.maven.org/maven2/io/jooby/jooby-cli/{joobyVersion}/jooby-cli-{joobyVersion}.zip[jooby-cli.zip] -- Unzip `jooby-cli.zip` in your user home directory (or any other directory you prefer) -- Find the native launchers in the `bin` directory - -[TIP] -==== -You might want to add the native launcher `bin/jooby` or `bin/jooby.bat` to your system path variable, -to make it globally accessible from any location. -==== - -[NOTE] -==== -To simplify documentation we use `jooby` as command, it requires Java 17 as minimum. Windows users must use `jooby.bat` -==== - -.Setting workspace: -[source, bash] ----- -jooby set -w ~/Source ----- - -All code will be saved inside the `~/Source` directory. - -Workspace directory is ready! - -Now type `jooby` and hit ENTER. - -After prompt, type `help create`: - -.jooby -[source, bash] ----- -jooby> help create -Missing required parameter: -Usage: jooby create [-dgikms] [--server=] -Creates a new application - Application name or coordinates (groupId:artifactId: - version) - -d, --docker Generates a Dockerfile - -g, --gradle Generates a Gradle project - -i Start interactive mode - -k, --kotlin Generates a Kotlin application - -m, --mvc Generates a MVC application - -s, --stork Add Stork Maven plugin to build (Maven only) - --server= Choose one of the available servers: jetty, netty or - undertow -jooby> ----- - -The `create` command generates a Jooby application. Some examples: - -.Creates a Maven Java project: -[source, bash] ----- -jooby> create myapp ----- - -.Creates a Maven Kotlin project: -[source, bash] ----- -jooby> create myapp --kotlin ----- - -[NOTE] -.Since 3.x -==== -Kotlin was removed from core, you need to the `jooby-kotlin` dependency: - -[dependency, artifactId="jooby-kotlin"] -. - -==== - -.Creates a Gradle Java project: -[source, bash] ----- -jooby> create myapp --gradle ----- - -.Creates a Gradle Kotlin project: -[source, bash] ----- -jooby> create myapp --gradle --kotlin ----- - -Maven and Java are the default options but you can easily override these with `-g -k` or `-gk` (order doesn't matter). -Along with the build and language, the `create` command adds two test classes: `UnitTest` and `IntegrationTest`. - -Passing the `-m` or `--mvc` generates an MVC application: - -.Creates a Maven Java Mvc project: -[source, bash] ----- -jooby> create myapp --mvc ----- - -The `--server` option, allow you to choose between: (J)etty, (N)etty or (U)ndertow: - -.Creates a Maven Java Project using Undertow: -[source, bash] ----- -jooby> create myapp --server undertow ----- - -Maven/Gradle configuration generates an `uber/fat` jar at package time. Maven builds supports -generation of https://github.com/fizzed/stork[Stork launchers]. - -.Creates a Maven Java Project with stork launchers: -[source, bash] ----- -jooby> create myapp --stork ----- - -There is a `-d` or `--docker` option which generates a `Dockerfile` - -.Creates a docker file: -[source, bash] ----- -jooby> create myapp --docker ----- - -The default package in all these examples is set to `app`. To get full control of groupId, package, version, etc., use the interactive mode: - -.Interactive mode: -[source, bash] ----- -jooby> create myapp -i ----- - -=== Code Snippets - -For simplicity and brevity we are going to skip the `runApp` function and extending `Jooby`. -Code example will look like: - -.Snippet -[source, java, role = "primary"] ----- -{ - get("/", ctx -> "Snippet"); -} ----- - -.Kotlin -[source, kotlin, role = "secondary"] ----- -{ - get("/") { "Snippet" } -} ----- - -The use of `application class` or `runApp function` will be included when strictly necessary. +include::quick-start.adoc[] diff --git a/docs/asciidoc/handlers.adoc b/docs/asciidoc/handlers.adoc index c43cb9366e..e37ea6dba8 100644 --- a/docs/asciidoc/handlers.adoc +++ b/docs/asciidoc/handlers.adoc @@ -1,6 +1,6 @@ -== Handlers +=== Built-in Handlers -This section describes some built-in handler provided by Jooby. +This section describes some built-in handlers provided by Jooby. include::handlers/access-log.adoc[] diff --git a/docs/asciidoc/handlers/access-log.adoc b/docs/asciidoc/handlers/access-log.adoc index 4a5dcfb0bb..6becaab983 100644 --- a/docs/asciidoc/handlers/access-log.adoc +++ b/docs/asciidoc/handlers/access-log.adoc @@ -1,20 +1,16 @@ -=== AccessLogHandler +==== AccessLogHandler -The javadoc:handler.AccessLogHandler[] logs incoming requests using the https://en.wikipedia.org/wiki/Common_Log_Format[NCSA format] (a.k.a common log format). +The javadoc:handler.AccessLogHandler[] logs all incoming requests using the https://en.wikipedia.org/wiki/Common_Log_Format[NCSA format] (also known as the Common Log Format). .Usage [source, java, role = "primary"] ---- -import io.jooby.Jooby; import io.jooby.handler.AccessLogHandler; -... + { + use(new AccessLogHandler()); // <1> - use(new AccessLogHandler()); <1> - - get("/", ctx -> { - ... - }); + get("/", ctx -> "Logging..."); } ---- @@ -22,39 +18,38 @@ import io.jooby.handler.AccessLogHandler; [source, kotlin, role = "secondary"] ---- import io.jooby.handler.AccessLogHandler -... + { - use(AccessLogHandler()) <1> + use(AccessLogHandler()) // <1> - get("/") { - ... - } + get("/") { "Logging..." } } ---- -<1> Install AccessLogHandler +<1> Install the `AccessLogHandler` as a global decorator. -Prints a message like: +Once installed, it prints a message to your log similar to this: 127.0.0.1 - - [04/Oct/2016:17:51:42 +0000] "GET / HTTP/1.1" 200 2 3 -Message is represented by: +The message components are: + +* **Remote Address:** The IP address of the client. +* **User ID:** Usually a dash (`-`) unless specified. +* **Date and Time:** The timestamp of the request. +* **Request Line:** The HTTP method, request path, and protocol. +* **Status Code:** The HTTP response status code. +* **Content-Length:** The size of the response in bytes (or `-` if missing). +* **Latency:** The total time taken to process the request in milliseconds. -- Remote Address. -- User ID (or dash when missing) -- Date and time -- HTTP method, requestPath and protocol -- Response Status Code -- Response Content-Length (or dash when missing) -- Time took to process the request in milliseconds +===== Custom Headers -Extra request or response headers can be appended at the end using the available methods: +You can append specific request or response headers to the log entry using the following methods: -- javadoc:handler.AccessLogHandler[requestHeader, java.lang.String...] -- javadoc:handler.AccessLogHandler[responseHeader, java.lang.String...] +* javadoc:handler.AccessLogHandler[requestHeader, java.lang.String...]: Appends request headers to the log line. +* javadoc:handler.AccessLogHandler[responseHeader, java.lang.String...]: Appends response headers to the log line. [TIP] ==== -If you run behind a reverse proxy that has been configured to send the X-Forwarded-* header, -please consider to use <> option. +If your application runs behind a reverse proxy (like Nginx or AWS ALB) that sends `X-Forwarded-*` headers, remember to enable the <> option to ensure the correct remote IP address is logged. ==== diff --git a/docs/asciidoc/handlers/cors.adoc b/docs/asciidoc/handlers/cors.adoc index f0beba461f..982e3bcb1d 100644 --- a/docs/asciidoc/handlers/cors.adoc +++ b/docs/asciidoc/handlers/cors.adoc @@ -1,23 +1,16 @@ -=== CorsHandler +==== CorsHandler -https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS[Cross-Origin Resource Sharing (CORS)] is a mechanism that uses additional HTTP headers to tell a -browser to let a web application running at one origin (domain) have permission to access selected -resources from a server at a different origin. A web application executes a cross-origin HTTP -request when it requests a resource that has a different origin (domain, protocol, or port) than -its own origin. +https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS[Cross-Origin Resource Sharing (CORS)] is a mechanism that uses HTTP headers to grant a web application running at one origin permission to access resources from a server at a different origin. -Jooby supports CORS out of the box. By default, **CORS requests will be rejected**. -To enable processing of CORS requests, use the javadoc:handler.CorsHandler[]: +By default, **Jooby rejects all cross-origin requests**. To enable them, you must install the javadoc:handler.CorsHandler[]. -.CorsExample +.CORS Usage [source, java, role = "primary"] ---- -import io.jooby.Jooby; import io.jooby.handler.CorsHandler; -... + { - - use(new CorsHandler()); <1> + use(new CorsHandler()); // <1> path("/api", () -> { // API methods @@ -29,9 +22,9 @@ import io.jooby.handler.CorsHandler; [source, kotlin, role = "secondary"] ---- import io.jooby.handler.CorsHandler -... + { - use(CorsHandler()) <1> + use(CorsHandler()) // <1> path("/api") { // API methods @@ -39,106 +32,92 @@ import io.jooby.handler.CorsHandler } ---- -<1> Install CorsHandler with defaults options +<1> Install the `CorsHandler` with default options. + +The default configuration is: -Default options are: +* **Origin:** `*` (All origins) +* **Credentials:** `true` +* **Methods:** `GET`, `POST` +* **Headers:** `X-Requested-With`, `Content-Type`, `Accept`, and `Origin` +* **Max Age:** `30m` -- origin: `*` -- credentials: `true` -- allowed methods: `GET`, `POST` -- allowed headers: `X-Requested-With`, `Content-Type`, `Accept` and `Origin` -- max age: `30m`; +===== Customizing CORS Options -To customize default options use javadoc:handler.Cors[]: +To customize these settings, use the javadoc:handler.Cors[] class: -.Cors options +.CORS Options [source, java, role = "primary"] ---- -import io.jooby.Jooby; import io.jooby.handler.Cors; import io.jooby.handler.CorsHandler; -... + { Cors cors = new Cors() - .setMethods("GET", "POST", "PUT"); <1> + .setMethods("GET", "POST", "PUT", "DELETE"); // <1> - use(new CorsHandler(cors)); <2> - - path("/api", () -> { - // API methods - }); + use(new CorsHandler(cors)); // <2> } ---- .Kotlin [source, kotlin, role = "secondary"] ---- -import io.jooby.Jooby import io.jooby.handler.CorsHandler import io.jooby.cors -... + { val cors = cors { - methods = listOf("GET", "POST", "PUT") <1> - } - use(CorsHandler(cors)) <2> - - path("/api") { - // API methods + methods = listOf("GET", "POST", "PUT", "DELETE") // <1> } + use(CorsHandler(cors)) // <2> } ---- -<1> Specify allowed methods -<2> Pass cors options to cors handler +<1> Specify the allowed HTTP methods. +<2> Pass the custom configuration to the `CorsHandler`. -Optionally cors options can be specified in the application configuration file: +===== Configuration-based CORS + +You can also define your CORS settings directly in your `application.conf` file: .application.conf -[source,json] +[source, properties] ---- cors { - origin: "*" + origin: "https://example.com" credentials: true - methods: [GET, POST], - headers: [Content-Type], + methods: [GET, POST, PUT] + headers: [Content-Type, X-App-Version] maxAge: 30m - exposedHeaders: [Custom-Header] + exposedHeaders: [X-Custom-Header] } ---- -.Loading options +Then, load the configuration into the handler: + +.Loading from Config [source, java, role = "primary"] ---- -import io.jooby.Jooby; import io.jooby.handler.Cors; import io.jooby.handler.CorsHandler; -... -{ - Cors cors = Cors.from(getConfig()); <1> +{ + Cors cors = Cors.from(getConfig()); // <1> use(new CorsHandler(cors)); - - path("/api", () -> { - // API methods - }); } ---- .Kotlin [source, kotlin, role = "secondary"] ---- -import io.jooby.Jooby +import io.jooby.handler.Cors import io.jooby.handler.CorsHandler -... + { - val cors = Cors.from(config) <1> + val cors = Cors.from(config) // <1> use(CorsHandler(cors)) - - path("/api") { - // API methods - } } ---- -<1> Load cors options from application configuration file +<1> Loads the CORS options defined in the `cors` namespace of your configuration file. diff --git a/docs/asciidoc/handlers/csrf.adoc b/docs/asciidoc/handlers/csrf.adoc index c51dddbd2e..e8e977dd4f 100644 --- a/docs/asciidoc/handlers/csrf.adoc +++ b/docs/asciidoc/handlers/csrf.adoc @@ -1,38 +1,80 @@ -=== CsrfHandler +==== CsrfHandler -The javadoc:handler.CsrfHandler[text="Cross Site Request Forgery Handler"] helps to protect from (CSRF) -attacks. Cross-site request forgeries are a type of malicious exploit whereby unauthorized commands -are performed on behalf of an authenticated user. +The javadoc:handler.CsrfHandler[text="Cross-Site Request Forgery (CSRF) Handler"] protects your application against unauthorized commands performed on behalf of an authenticated user. -Jooby automatically generates a CSRF "token" for each active user session managed by the -application. This token is used to verify that the authenticated user is the one actually making -the requests to the application. +Jooby generates a unique CSRF token for each active user session. This token is used to verify that the authenticated user is the one actually making the requests to the application. -Anytime you define an HTML form in your application, you should include a hidden CSRF token -field in the form so that the CSRF protection middleware can validate the request +Anytime you define an HTML form that performs a state-changing operation (like `POST`), you must include the CSRF token. -.CSRF +.HTML Form with CSRF [source, html] ---- -
+ - ... +
---- -The `csrf` is a request attribute created by the javadoc:handler.CsrfHandler[] handler and rendered by a -template engine. Here `{{csrf}}` we use Handlebars template engine (as example). +In the example above, `{{csrf}}` is a request attribute created by the handler. When the form is submitted, the javadoc:handler.CsrfHandler[] automatically verifies that the token in the request matches the token stored in the user's session. -The javadoc:handler.CsrfHandler[] handler, will automatically verify that the token in the request input -matches the token stored in the session. +===== Usage -The token defaults name is `csrf` and can be provided as: +CSRF protection requires an active **Session Store** to be configured first. -- header -- cookie -- form parameter +.CSRF Usage +[source, java, role = "primary"] +---- +import io.jooby.handler.CsrfHandler; + +{ + // 1. Session store is required + setSessionStore(SessionStore.memory()); + + // 2. Install CSRF handler + use(new CsrfHandler()); + + get("/form", ctx -> { + // Token is available as a request attribute + return new ModelAndView("form.hbs", Map.of("csrf", ctx.getAttribute("csrf"))); + }); +} +---- + +.Kotlin +[source, kotlin, role = "secondary"] +---- +import io.jooby.handler.CsrfHandler + +{ + // 1. Session store is required + sessionStore = SessionStore.memory() + + // 2. Install CSRF handler + use(CsrfHandler()) + + get("/form") { ctx -> + // Token is available as a request attribute + ModelAndView("form.hbs", mapOf("csrf" to ctx.getAttribute("csrf"))) + } +} +---- + +===== Token Delivery + +By default, the handler looks for a token named `csrf` in the following order: + +1. **HTTP Header:** `X-CSRF-Token` or `csrf` +2. **Cookie:** `csrf` +3. **Form Parameter:** `csrf` + +===== Customization + +You can customize the behavior of the handler using the following methods: -Configuration methods: +* javadoc:handler.CsrfHandler[setTokenGenerator, java.util.function.Function]: Customize how tokens are generated (defaults to a random UUID). +* javadoc:handler.CsrfHandler[setRequestFilter, java.util.function.Predicate]: Define which requests should be validated. By default, it validates `POST`, `PUT`, `PATCH`, and `DELETE` requests. -- javadoc:handler.CsrfHandler["setTokenGenerator", java.util.function.Function]: Set a custom token generator. Defaults uses a random UUID. -- javadoc:handler.CsrfHandler["setRequestFilter", java.util.function.Predicate]: Set a custom request filter. Defaults is to process `POST`, `PUT`, `PATCH` and `DELETE`. +[TIP] +==== +If you are building a Single Page Application (SPA), you can configure the handler to read the token from a custom header and have your frontend (e.g., React or Vue) send it back on every request. +==== diff --git a/docs/asciidoc/handlers/graceful-shutdown.adoc b/docs/asciidoc/handlers/graceful-shutdown.adoc index 1106314ffc..3831dd17c6 100644 --- a/docs/asciidoc/handlers/graceful-shutdown.adoc +++ b/docs/asciidoc/handlers/graceful-shutdown.adoc @@ -1,18 +1,18 @@ -=== GracefulShutdown +==== GracefulShutdownHandler -The javadoc:GracefulShutdown[] extension waits for existing requests to finish. +The javadoc:GracefulShutdown[] extension allows the application to finish processing active requests before the JVM exits. -.Example +Once a shutdown is initiated, the extension interceptor ensures that all new incoming requests are rejected with a `Service Unavailable (503)` status code, while existing requests are allowed to complete within a specified grace period. + +.Usage [source, java, role = "primary"] ---- -import io.jooby.Jooby; import io.jooby.GracefulShutdown; -... + { - - install(new GracefulShutdown()); <1> + install(new GracefulShutdown()); // <1> - // other routes go here + get("/", ctx -> "Hello!"); } ---- @@ -20,22 +20,26 @@ import io.jooby.GracefulShutdown; [source, kotlin, role = "secondary"] ---- import io.jooby.GracefulShutdown -... + { - install(GracefulShutdown()) <1> + install(GracefulShutdown()) // <1> - // other routes go here + get("/") { "Hello!" } } ---- -<1> Install GracefulShutdown. +<1> Install the `GracefulShutdown` extension. -Incoming request are resolved as `Service Unavailable(503)`. Optionally you can specify a max -amount of time to wait before shutdown: +===== Timeout - install(new GracefulShutdown(Duration.ofMinutes(1))); +By default, the extension waits for all requests to finish. You can optionally specify a maximum duration to wait before forcing the application to stop: + +[source, java] +---- +install(new GracefulShutdown(Duration.ofMinutes(1))); +---- -[INFO] +[IMPORTANT] ==== -This extension must be installed at very beginning of your route pipeline. +This extension must be installed at the **very beginning** of your route pipeline to ensure it can intercept and manage all incoming traffic during the shutdown phase. ==== diff --git a/docs/asciidoc/handlers/head.adoc b/docs/asciidoc/handlers/head.adoc index bb286e137e..872d97243d 100644 --- a/docs/asciidoc/handlers/head.adoc +++ b/docs/asciidoc/handlers/head.adoc @@ -1,45 +1,38 @@ -=== HeadHandler +==== HeadHandler -Jooby doesn't support `HTTP HEAD` requests by default. To support them you have two options: +By default, Jooby does not automatically handle `HTTP HEAD` requests. To support them without manually defining `head(...)` routes for every resource, you can use the javadoc:handler.HeadHandler[]. -- Use the built-in `HeadHandler` -- Write your own head handler +The javadoc:handler.HeadHandler[] automatically routes `HEAD` requests to your existing `GET` handlers. -The javadoc:handler.HeadHandler[] supports `HEAD` requests over existing `GET` handlers. - -.Head Example +.Usage [source, java, role = "primary"] ---- -import io.jooby.Jooby; import io.jooby.handler.HeadHandler; -... + { + use(new HeadHandler()); // <1> - use(new HeadHandler()); <1> - - get("/", ctx -> { - ... - }); + get("/", ctx -> "Full response body"); } ---- .Kotlin [source, kotlin, role = "secondary"] ---- -import io.jooby.HeadHandler -... +import io.jooby.handler.HeadHandler + { - use(HeadHandler()) <1> + use(HeadHandler()) // <1> - get("/") { - ... - } + get("/") { "Full response body" } } ---- -<1> Install HeadHandler +<1> Install the `HeadHandler`. -`HEAD /` produces an empty response with a `Content-Length` header (when possible) and any other -header produce it by the `GET` handler. +When a `HEAD` request is received, the corresponding `GET` handler is executed to compute response metadata (like `Content-Length` and other headers), but the actual response body is stripped before being sent to the client. -The `GET` handler is executed but produces an empty response. +[NOTE] +==== +This handler is a convenient way to support `HEAD` requests globally, but keep in mind that the `GET` handler logic still runs. If your `GET` handler performs expensive operations to generate a body, you might prefer a custom implementation that skips body generation for `HEAD` requests. +==== diff --git a/docs/asciidoc/handlers/rate-limit.adoc b/docs/asciidoc/handlers/rate-limit.adoc index 9337c47513..905016918a 100644 --- a/docs/asciidoc/handlers/rate-limit.adoc +++ b/docs/asciidoc/handlers/rate-limit.adoc @@ -1,38 +1,56 @@ -=== RateLimitHandler +==== RateLimitHandler -Rate limit handler using https://github.com/vladimir-bukhtoyarov/bucket4j[Bucket4j]. +The javadoc:handler.RateLimitHandler[] provides request throttling using the popular https://github.com/vladimir-bukhtoyarov/bucket4j[Bucket4j] library. -Add the dependency to your project: +To use this handler, add the following dependency to your project: [dependency, artifactId="bucket4j_jdk17-core", subs="verbatim,attributes"] . -.10 requests per minute +===== Basic Usage + +The simplest configuration applies a global limit to all incoming requests. + +.10 requests per minute (Global) [source, java, role="primary"] ---- +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import io.github.bucket4j.Bucket4j; +import io.jooby.handler.RateLimitHandler; + { Bandwidth limit = Bandwidth.simple(10, Duration.ofMinutes(1)); - Bucket bucket = Bucket4j.builder().addLimit(limit).build(); <1> + Bucket bucket = Bucket4j.builder().addLimit(limit).build(); // <1> - before(new RateLimitHandler(bucket)); <2> + before(new RateLimitHandler(bucket)); // <2> } ---- .Kotlin [source, kotlin, role="secondary"] ---- +import io.github.bucket4j.Bandwidth +import io.github.bucket4j.Bucket4j +import io.jooby.handler.RateLimitHandler +import java.time.Duration + { val limit = Bandwidth.simple(10, Duration.ofMinutes(1)) - val bucket = Bucket4j.builder().addLimit(limit).build() <1> + val bucket = Bucket4j.builder().addLimit(limit).build() // <1> - before(RateLimitHandler(bucket)) <2> + before(RateLimitHandler(bucket)) // <2> } ---- -<1> Creates a bucket -<2> Install the RateLimitHandler +<1> Create a bucket with the desired capacity and refill rate. +<2> Install the `RateLimitHandler` as a `before` filter. + +===== Throttling by Key + +Often, you want to limit requests per user, API key, or IP address rather than globally. -.10 requests per minute per ip/remote address +.Throttling by Remote IP [source, java, role="primary"] ---- { @@ -47,21 +65,21 @@ Add the dependency to your project: [source, kotlin, role="secondary"] ---- { - before(RateLimitHandler {remoteAddress -> + before(RateLimitHandler { remoteAddress -> val limit = Bandwidth.simple(10, Duration.ofMinutes(1)) Bucket4j.builder().addLimit(limit).build() }) } ---- -.10 requests per minute per header value +.Throttling by API Key Header [source, java, role="primary"] ---- { before(new RateLimitHandler(key -> { - Bandwidth limit = Bandwidth.simple(10, Duration.ofMinutes(1)); + Bandwidth limit = Bandwidth.simple(50, Duration.ofHours(1)); return Bucket4j.builder().addLimit(limit).build(); - }, "ApiKey")); + }, "X-API-Key")); } ---- @@ -69,46 +87,28 @@ Add the dependency to your project: [source, kotlin, role="secondary"] ---- { - before(RateLimitHandler {key -> - val limit = Bandwidth.simple(10, Duration.ofMinutes(1)) + before(RateLimitHandler({ key -> + val limit = Bandwidth.simple(50, Duration.ofHours(1)) Bucket4j.builder().addLimit(limit).build() - }, "ApiKey") -} ----- - -.10 requests per minute -[source, java, role="primary"] ----- -{ - Bandwidth limit = Bandwidth.simple(10, Duration.ofMinutes(1)); - Bucket bucket = Bucket4j.builder().addLimit(limit).build(); <1> - - before(new RateLimitHandler(bucket)); <2> + }, "X-API-Key")) } ---- -.Kotlin -[source, kotlin, role="secondary"] ----- -{ - val limit = Bandwidth.simple(10, Duration.ofMinutes(1)) - val bucket = Bucket4j.builder().addLimit(limit).build() <1> - - before(RateLimitHandler(bucket)) <2> -} ----- +===== Clustered Rate Limiting -<1> Creates a bucket -<2> Install the RateLimitHandler +If you are running multiple Jooby instances, you can use a distributed bucket using Bucket4j's `ProxyManager`. This allows the rate limit state to be shared across the cluster via a backend like Redis or Hazelcast. -.Rate limit in a cluster +.Cluster Support [source, java, role="primary"] ---- { - ProxyManager buckets = ...; + ProxyManager buckets = ...; // Configure your backend (Redis, etc.) + before(RateLimitHandler.cluster(key -> { return buckets.getProxy(key, () -> { - return ...; + return Bucket4j.configurationBuilder() + .addLimit(Bandwidth.simple(100, Duration.ofMinutes(1))) + .build(); }); })); } @@ -118,13 +118,16 @@ Add the dependency to your project: [source, kotlin, role="secondary"] ---- { - val buckets = ...; - before(RateLimitHandler.cluster {key -> + val buckets: ProxyManager = ... // Configure backend + + before(RateLimitHandler.cluster { key -> buckets.getProxy(key) { - .... + Bucket4j.configurationBuilder() + .addLimit(Bandwidth.simple(100, Duration.ofMinutes(1))) + .build() } }) } ---- -For using it inside a cluster you need to configure one of the bucket4j options for https://github.com/vladimir-bukhtoyarov/bucket4j#supported-back-ends[clustering]. +For more details on setting up backends like Redis, Hazelcast, or Infinispan, refer to the https://github.com/vladimir-bukhtoyarov/bucket4j#supported-back-ends[Bucket4j documentation]. diff --git a/docs/asciidoc/handlers/ssl.adoc b/docs/asciidoc/handlers/ssl.adoc index 6d5a421b27..ab2b33c204 100644 --- a/docs/asciidoc/handlers/ssl.adoc +++ b/docs/asciidoc/handlers/ssl.adoc @@ -1,83 +1,60 @@ -=== SSLHandler +==== SSLHandler -The javadoc:handler.SSLHandler[] forces client to use HTTPS by redirecting non-HTTPS calls to the HTTPS version. +The javadoc:handler.SSLHandler[] forces clients to use HTTPS by automatically redirecting all non-secure (HTTP) requests to their secure (HTTPS) version. -Refer to <> to see how to setup SSL in your project. +Before using this handler, ensure you have followed the instructions in the <> section to enable SSL on your server. -.Force SSL +.Usage [source, java, role = "primary"] ---- -import io.jooby.Jooby; import io.jooby.handler.SSLHandler; -... + { + before(new SSLHandler()); // <1> - before(new SSLHandler()); <1> - - get("/", ctx -> { - return ctx.getScheme(); - }); + get("/", ctx -> "You are on: " + ctx.getScheme()); } ---- .Kotlin [source, kotlin, role = "secondary"] ---- -import io.jooby.Jooby -import io.jooby.handler.SSHandler -... +import io.jooby.handler.SSLHandler + { - before(SSLHandler()) <1> + before(SSLHandler()) // <1> - get("/") { - ... - } + get("/") { "You are on: ${ctx.scheme}" } } ---- -<1> Install SSLHandler +<1> Install the `SSLHandler` as a `before` filter. -The SSL Handler recreates the HTTPs URL version using the `Host` header, if you are behind a proxy -you will need to use the `X-Forwarded-Host` header. To do that set the <> option. +The handler reconstructs the HTTPS URL using the `Host` header. If your application is running behind a load balancer or reverse proxy, you must enable the <> option to ensure the handler correctly identifies the client's original protocol and host. -Optionally, you can specify the host to use: +===== Customizing the Host -.Force SSL +By default, the handler redirects to the host provided in the request. You can explicitly specify a destination host if needed: + +.Explicit Host Redirect [source, java, role = "primary"] ---- -import io.jooby.Jooby; -import io.jooby.handler.SSLHandler; -... { - - before(new SSLHandler("myhost.org")); <1> - - get("/", ctx -> { - return ctx.getScheme(); - }); + // Redirects all traffic to https://myhost.org + before(new SSLHandler("myhost.org")); } ---- .Kotlin [source, kotlin, role = "secondary"] ---- -import io.jooby.Jooby -import io.jooby.handler.SSHandler -... { - - before(SSLHandler("myhost.org")) <1> - - get("/") { - ctx.scheme - } + // Redirects all traffic to https://myhost.org + before(SSLHandler("myhost.org")) } ---- -For more information about SSL, please check the <> section. - [TIP] ==== -If you run behind a reverse proxy that has been configured to send the X-Forwarded-* header, -please consider adding the <> to your pipeline. +For production environments behind a reverse proxy (like Nginx, HAProxy, or an AWS ALB) that terminates SSL, the `SSLHandler` relies on the `X-Forwarded-Proto` header. Make sure your proxy is configured to send it and that you have enabled `setTrustProxy(true)` in your Jooby configuration. ==== diff --git a/docs/asciidoc/handlers/trace.adoc b/docs/asciidoc/handlers/trace.adoc index 374cc745e2..8ea81dadcf 100644 --- a/docs/asciidoc/handlers/trace.adoc +++ b/docs/asciidoc/handlers/trace.adoc @@ -1,44 +1,38 @@ -=== TraceHandler +==== TraceHandler -Jooby doesn't support `HTTP Trace` requests by default. To support them you have two options: +By default, Jooby does not support `HTTP TRACE` requests. To enable them for debugging or diagnostic purposes, you can use the javadoc:handler.TraceHandler[]. -- Use the built-in `TraceHandler` -- Write your own trace handler +The javadoc:handler.TraceHandler[] implements a loop-back test by returning the received request message back to the client as the entity body of a 200 (OK) response. -The javadoc:handler.TraceHandler[] supports `TRACE` requests over existing handlers. - -.Head Example +.Usage [source, java, role = "primary"] ---- -import io.jooby.Jooby; import io.jooby.handler.TraceHandler; -... + { + use(new TraceHandler()); // <1> - use(new TraceHandler()); <1> - - get("/", ctx -> { - ... - }); + get("/", ctx -> "Hello World"); } ---- .Kotlin [source, kotlin, role = "secondary"] ---- -import io.jooby.Jooby import io.jooby.handler.TraceHandler -... + { - use(TraceHandler()) <1> + use(TraceHandler()) // <1> - get("/") { - ... - } + get("/") { "Hello World" } } ---- -<1> Install TraceHandler +<1> Install the `TraceHandler` as a decorator. + +When a `TRACE` request is received, the handler bypasses the normal route logic and echoes the request headers and body back to the client. This is useful for seeing what transformations are being applied to the request by intermediate proxies or firewalls. -`TRACE /` performs a message loop-back test along the path to the target resource, providing a -useful debugging mechanism. +[WARNING] +==== +`HTTP TRACE` can potentially expose sensitive information (like cookies or authentication headers) to malicious scripts (Cross-Site Tracing). It is generally recommended to enable this handler only in development or strictly controlled diagnostic environments. +==== diff --git a/docs/asciidoc/index.adoc b/docs/asciidoc/index.adoc index e046578281..518d583ee1 100644 --- a/docs/asciidoc/index.adoc +++ b/docs/asciidoc/index.adoc @@ -16,24 +16,17 @@ Style guidelines: //// -= Welcome to Jooby! -{joobyVersion} - [discrete] == ∞ do more, more easily -== Introduction - -Jooby is a modern, performant and easy to use web framework for Java and Kotlin built on top of your -favorite web server. +Jooby is a modular, high-performance web framework for Java and Kotlin. Designed for simplicity and speed, it gives you the freedom to build on your favorite server with a clean, modern API. -.Welcome!! +.Welcome! [source,java,role="primary"] ---- import io.jooby.Jooby; public class App extends Jooby { - { get("/", ctx -> "Welcome to Jooby!"); } @@ -51,213 +44,43 @@ import io.jooby.kt.runApp fun main(args: Array) { runApp(args) { - get ("/") { "Welcome to Jooby!" } + get("/") { "Welcome to Jooby!" } } } ---- +[discrete] +=== Features + +* **Lightweight and Fast:** See the https://www.techempower.com/benchmarks/#section=data-r19&hw=ph&test=composite&l=xan9tr-1r[TechEmpower Benchmark]. +* **High Productivity:** Built-in <>. +* **Flexible Routing:** Use the fluent Script/Lambda API or <> (Jooby or JAX-RS). +* **Modern Tooling:** Full link:modules/openapi[OpenAPI 3] support. +* **Execution Options:** Choose between <>. +* **Reactive Ready:** Support for <> (CompletableFuture, RxJava, Reactor, Mutiny, and Kotlin Coroutines). +* **Server Choice:** Run on https://www.eclipse.org/jetty[Jetty], https://netty.io[Netty], https://vertx.io[Vert.x], or http://undertow.io[Undertow]. +* **Extensible:** Scale to a full-stack framework using extensions and link:modules[modules]. + [TIP] ==== Latest Release: https://github.com/jooby-project/jooby/releases/tag/v{joobyVersion}[{joobyVersion}]. Looking for a previous version? -* Access to link:v3[3.x] documentation. link:{uiVersion}/migration/4.x[Migrating from 3.x to 4.x] -* Access to link:v2[2.x] documentation. link:{uiVersion}/migration/3.x[Migrating from 2.x to 3.x] -* Access to link:v1[1.x] documentation. +* Access link:v3[3.x] documentation. link:{uiVersion}/migration/4.x[Migrating from 3.x to 4.x] +* Access link:v2[2.x] documentation. link:{uiVersion}/migration/3.x[Migrating from 2.x to 3.x] +* Access link:v1[1.x] documentation. ==== -=== Features - -* Lightweight and Fast. See https://www.techempower.com/benchmarks/#section=data-r19&hw=ph&test=composite&l=xan9tr-1r[Tech Empower Benchmark] -* Increase productivity with <> -* Script/lambda routes using fluent API -* <> using Jooby or JAX-RS annotations -* link:modules/openapi[OpenAPI 3] support -* <> -* <> (Completable Futures, RxJava, Reactor, SmallRye types and Kotlin Coroutines) -* <> including https://www.eclipse.org/jetty[Jetty], https://netty.io[Netty], https://vertx.io[Vertx] and http://undertow.io[Undertow] -* Make the jump to full-stack framework with the extension/plugin mechanism and a variety of link:modules[] - -=== Script API - -Script API (a.k.a. script routes) provides a fluent DSL based on `lambda` functions, free of -reflection and annotations. - -We usually extend `Jooby` and define routes in the instance initializer: - -.Script with sub-class: -[source,java,role="primary"] ----- -import io.jooby.Jooby; - -public class App extends Jooby { - - { - get("/", ctx -> "Hello Jooby!"); - } - - public static void main(String[] args) { - runApp(args, App::new); - } -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -import io.jooby.kt.Kooby -import io.jooby.kt.runApp - -class App : Kooby({ - - get("/") { "Hello Jooby!" } - -}) - -fun main(args: Array) { - runApp(args, ::App) -} ----- - -For Java applications we favor extending `Jooby` because the DSL looks better (no need -to prefix the `get` method with a variable). - -This is not strictly necessary (of course); you may prefer to do it without extending `Jooby`: - -.Script without subclass: -[source,java,role="primary"] ----- -import io.jooby.Jooby; - -public class App { - - public static void main(String[] args) { - runApp(args, app -> { - - app.get("/", ctx -> "Hello Jooby!"); - - }); - } -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -import io.jooby.kt.runApp - -fun main(args: Array) { - runApp(args) { - - get("/") { "Hello Jooby!" } - - } -} ----- - -For Kotlin, it doesn't matter which one you choose. The DSL looks great with or without extending -`Kooby`. - -=== MVC API - -The MVC API (a.k.a. MVC routes) uses annotation to define routes and byte code generation to execute -them. - -.MVC API: -[source,java,role="primary"] ----- - -import io.jooby.annotation.*; - -public class MyController { - - @GET - public String sayHi() { - return "Hello Jooby!"; - } -} - -public class App { - - public static void main(String[] args) { - runApp(args, app -> { - - app.use(new MyController_()); - - }); - } -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -import io.jooby.annotation.*; - -class MyController { - - @GET - fun sayHi() : String { - return "Hello Jooby!" - } -} - -fun main(args: Array) { - runApp(args) { - - use(MyController_()) - - } -} ----- - -Read more about MVC and JAX-RS support in the <> chapter. - - include::getting-started.adoc[] -include::routing.adoc[] - -include::context.adoc[] - -include::mvc-api.adoc[] - -include::static-files.adoc[] - -include::templates.adoc[] - -include::session.adoc[] - -include::websocket.adoc[] - -include::server-sent-event.adoc[] - -include::execution-model.adoc[] - -include::responses.adoc[] - -include::error-handler.adoc[] - -include::problem-details.adoc[] - -include::configuration.adoc[] - -include::extension.adoc[] - -include::dependency-injection.adoc[] - -include::testing.adoc[] - -include::dev-tools.adoc[] - -include::handlers.adoc[] +include::core.adoc[] -include::packaging/packaging.adoc[] +include::web.adoc[] -include::servers.adoc[] +include::ecosystem.adoc[] -include::modules.adoc[] +include::tooling.adoc[] == Appendix diff --git a/docs/asciidoc/intro.adoc b/docs/asciidoc/intro.adoc new file mode 100644 index 0000000000..a084374f84 --- /dev/null +++ b/docs/asciidoc/intro.adoc @@ -0,0 +1,114 @@ +=== Introduction + +==== Script API + +The Script API (also known as script routes) provides a fluent, reflection-free DSL based on lambda functions. + +We typically extend `Jooby` and define routes in the instance initializer: + +.Extending Jooby (Recommended for Java): +[source,java,role="primary"] +---- +import io.jooby.Jooby; + +public class App extends Jooby { + { + get("/", ctx -> "Hello Jooby!"); + } + + public static void main(String[] args) { + runApp(args, App::new); + } +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +import io.jooby.kt.Kooby +import io.jooby.kt.runApp + +class App : Kooby({ + get("/") { "Hello Jooby!" } +}) + +fun main(args: Array) { + runApp(args, ::App) +} +---- + +For Java applications, we favor extending `Jooby` to keep the DSL clean (avoiding the need to prefix methods like `get` with a variable name). However, you can also define routes without subclassing: + +.Without extending Jooby: +[source,java,role="primary"] +---- +import io.jooby.Jooby; + +public class App { + public static void main(String[] args) { + Jooby.runApp(args, app -> { + app.get("/", ctx -> "Hello Jooby!"); + }); + } +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +import io.jooby.kt.runApp + +fun main(args: Array) { + runApp(args) { + get("/") { "Hello Jooby!" } + } +} +---- + +*(Note: For Kotlin, the DSL remains clean whether you extend `Kooby` or not).* + +==== MVC API + +The MVC API uses annotations to define routes and bytecode generation to execute them quickly. + +.MVC Routing: +[source,java,role="primary"] +---- +import io.jooby.annotation.*; + +public class MyController { + @GET + public String sayHi() { + return "Hello Jooby!"; + } +} + +public class App { + public static void main(String[] args) { + Jooby.runApp(args, app -> { + app.use(new MyController_()); + }); + } +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +import io.jooby.annotation.* + +class MyController { + @GET + fun sayHi(): String { + return "Hello Jooby!" + } +} + +fun main(args: Array) { + runApp(args) { + use(MyController_()) + } +} +---- + +Read more about MVC and JAX-RS support in the <> chapter. diff --git a/docs/asciidoc/migration.adoc b/docs/asciidoc/migration.adoc index 7ca8109281..9d12d3a1c8 100644 --- a/docs/asciidoc/migration.adoc +++ b/docs/asciidoc/migration.adoc @@ -102,4 +102,4 @@ The routing matching algorithm in 2.x is more efficient and fast, because: - Uses a radix tree, not regular expression like in 1.x - It never executes a decorator if there isn't a matching handler -More detailed explanation of route pipeline is available in the <>. +More detailed explanation of route pipeline is available in the <>. diff --git a/docs/asciidoc/migration/3.x.adoc b/docs/asciidoc/migration/3.x.adoc index e25187c5bd..78abe47887 100644 --- a/docs/asciidoc/migration/3.x.adoc +++ b/docs/asciidoc/migration/3.x.adoc @@ -64,9 +64,10 @@ Kotlin was removed from core, you need to the `jooby-kotlin` dependency: |=== ==== Class renames +[cols="1,1,2"] |=== |2.x|3.x|Module -|io.jooby.Route.Decorator|io.jooby.Route.Filter| jooby (core) +|io.jooby.Route.Decorator| jooby (core) |io.jooby.Route.Filter |io.jooby.Kooby|io.jooby.kt.Kooby| jooby-kotlin (new module) |io.jooby.jetty.Jetty|io.jooby.jetty.JettyServer| jooby-jetty |io.jooby.netty.Netty|io.jooby.netty.NettyServer| jooby-netty @@ -87,6 +88,7 @@ Kotlin was removed from core, you need to the `jooby-kotlin` dependency: |=== ==== Method renames +[cols="1,1,2"] |=== |2.x|3.x|Description |Router.decorator(Decorator)|Router.use(Filter)| `decorator` has been deprecated in favor of `use` diff --git a/docs/asciidoc/migration/4.x.adoc b/docs/asciidoc/migration/4.x.adoc index 301f3517d0..8529e33455 100644 --- a/docs/asciidoc/migration/4.x.adoc +++ b/docs/asciidoc/migration/4.x.adoc @@ -65,18 +65,20 @@ runApp(args, new NettyServer(new ServerOptions()), App::new); |=== ==== Classes +[cols="1,1,1,4"] |=== -|3.x|4.x|Description|Module -|io.jooby.buffer.*|-| removed | jooby (core) -||io.jooby.output.*| new output API | jooby (core) -|io.jooby.MvcFactory|-| was deprecated and now removed | jooby (core) -|io.jooby.annotation.ResultType|-| removed | jooby (core) -|io.jooby.ValueNode|io.jooby.value.Value| replaced/merged | jooby (core) -|io.jooby.ValueNodeConverter|io.jooby.value.ValueConverter| replaced/merged | jooby (core) -|io.jooby.RouteSet|io.jooby.Route.Set| moved into Route and renamed to Set | jooby (core) +|3.x|4.x|Module|Description +|io.jooby.buffer.*|-| jooby (core) | removed +||io.jooby.output.*| jooby (core) | new output API +|io.jooby.MvcFactory|-| jooby (core) | was deprecated and now removed +|io.jooby.annotation.ResultType|-| jooby (core) | removed +|io.jooby.ValueNode|io.jooby.value.Value | jooby (core) | replaced/merged +|io.jooby.ValueNodeConverter|io.jooby.value.ValueConverter| jooby (core) | replaced/merged +|io.jooby.RouteSet|io.jooby.Route.Set | jooby (core) | moved into Route and renamed to Set |=== ==== Method +[cols="1,1,2"] |=== |3.x|4.x|Description |io.jooby.Jooby.setServerOptions()|Server.setOptions()| removed in favor of `Server.setOptions()` diff --git a/docs/asciidoc/modules/avaje-inject.adoc b/docs/asciidoc/modules/avaje-inject.adoc index 10106c83ca..ef68ecb4b5 100644 --- a/docs/asciidoc/modules/avaje-inject.adoc +++ b/docs/asciidoc/modules/avaje-inject.adoc @@ -57,7 +57,7 @@ Please note that the order of annotation processors is important. For example, i public class App extends Jooby { { - install(AvajeInjectModule.of()); <1> + install(AvajeInjectModule.of()); <1> get("/", ctx -> { MyService service = require(MyService.class); <2> diff --git a/docs/asciidoc/modules/jackson.adoc b/docs/asciidoc/modules/jackson2.adoc similarity index 69% rename from docs/asciidoc/modules/jackson.adoc rename to docs/asciidoc/modules/jackson2.adoc index 13eaa4f927..efcfb70c77 100644 --- a/docs/asciidoc/modules/jackson.adoc +++ b/docs/asciidoc/modules/jackson2.adoc @@ -1,6 +1,6 @@ == Jackson -JSON support using https://github.com/FasterXML/jackson[Jackson] library. +JSON support using https://github.com/FasterXML/jackson[Jackson 2] library. === Usage @@ -14,7 +14,7 @@ JSON support using https://github.com/FasterXML/jackson[Jackson] library. .Java [source, java, role="primary"] ---- -import io.jooby.json.JacksonModule; +import io.jooby.jackson.JacksonModule; { install(new JacksonModule()); <1> @@ -34,7 +34,7 @@ import io.jooby.json.JacksonModule; .Kotlin [source, kt, role="secondary"] ---- -import io.jooby.json.JacksonModule; +import io.jooby.jackson.JacksonModule; { install(JacksonModule()) <1> @@ -52,8 +52,8 @@ import io.jooby.json.JacksonModule; ---- <1> Install Jackson -<2> Use Jackson to encode arbitrary object as JSON -<3> Use Jackson to decode JSON to Java object. Client must specify the `Content-Type: application/json` header +<2> Use Jackson to encode an arbitrary object as JSON +<3> Use Jackson to decode JSON to a Java object. Client must specify the `Content-Type: application/json` header === Working with ObjectMapper @@ -62,7 +62,7 @@ Access to default object mapper is available via require call: .Default object mapper [source, java, role="primary"] ---- -import io.jooby.json.JacksonModule; +import io.jooby.jackson.JacksonModule; { install(new JacksonModule()); @@ -76,7 +76,7 @@ import io.jooby.json.JacksonModule; .Kotlin [source, kt, role="secondary"] ---- -import io.jooby.json.JacksonModule +import io.jooby.jackson.JacksonModule { install(JacksonModule()) @@ -90,7 +90,7 @@ You can provide your own `ObjectMapper`: .Custom ObjectMapper [source, java, role="primary"] ---- -import io.jooby.json.JacksonModule; +import io.jooby.jackson.JacksonModule; { ObjectMapper mapper = new ObjectMapper(); @@ -102,7 +102,7 @@ import io.jooby.json.JacksonModule; .Kotlin [source, kt, role="secondary"] ---- -import io.jooby.json.JacksonModule +import io.jooby.jackson.JacksonModule { val mapper = ObjectMapper() @@ -116,7 +116,7 @@ This allows to configure JacksonModule for doing `xml` processing: .XmlMapper [source, java, role="primary"] ---- -import io.jooby.json.JacksonModule; +import io.jooby.jackson.JacksonModule; { install(new JacksonModule(new XmlMapper())); @@ -126,19 +126,19 @@ import io.jooby.json.JacksonModule; .Kotlin [source, kt, role="secondary"] ---- -import io.jooby.json.JacksonModule +import io.jooby.jackson.JacksonModule { install(JacksonModule(XmlMapper())) } ---- -If you want `jackson` and `xml` processing then install twice: +If you want `json` and `xml` processing then install twice: .XmlMapper [source, java, role="primary"] ---- -import io.jooby.json.JacksonModule; +import io.jooby.jackson.JacksonModule; { install(new JacksonModule(new ObjectMapper())); @@ -149,7 +149,7 @@ import io.jooby.json.JacksonModule; .Kotlin [source, kt, role="secondary"] ---- -import io.jooby.json.JacksonModule +import io.jooby.jackson.JacksonModule { install(JacksonModule(ObjectMapper())) @@ -159,12 +159,12 @@ import io.jooby.json.JacksonModule === Provisioning Jackson Modules -Jackson module can be provided by a link:{uiVersion}/#extensions-and-services-dependency-injection[dependency injection] framework. +Jackson module can be provided by a link:{uiVersion}/#ecosystem-services-and-the-registry-dependency-injection-di-bridge[dependency injection] framework. .Provisioning Modules [source, java, role="primary"] ---- -import io.jooby.json.JacksonModule; +import io.jooby.jackson.JacksonModule; { install(new JacksonModule().module(MyModule.class); @@ -174,11 +174,11 @@ import io.jooby.json.JacksonModule; .Kotlin [source, kt, role="secondary"] ---- -import io.jooby.json.JacksonModule +import io.jooby.jackson.JacksonModule { install(JacksonModule().module(MyModule::class.java) } ---- -At startup time Jooby ask to dependency injection framework to provide a `MyModule` instance. +At startup time Jooby ask dependency injection framework to provide a `MyModule` instance. diff --git a/docs/asciidoc/modules/jackson3.adoc b/docs/asciidoc/modules/jackson3.adoc new file mode 100644 index 0000000000..510d02bf7e --- /dev/null +++ b/docs/asciidoc/modules/jackson3.adoc @@ -0,0 +1,183 @@ +== Jackson + +JSON support using https://github.com/FasterXML/jackson[Jackson 3] library. + +=== Usage + +1) Add the dependency: + +[dependency, artifactId="jooby-jackson3"] +. + +2) Install and encode/decode JSON + +.Java +[source, java, role="primary"] +---- +import io.jooby.jackson3.Jackson3Module; + +{ + install(new Jackson3Module()); <1> + + get("/", ctx -> { + MyObject myObject = ...; + return myObject; <2> + }); + + post("/", ctx -> { + MyObject myObject = ctx.body(MyObject.class); <3> + ... + }); +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +import io.jooby.jackson3.Jackson3Module; + +{ + install(Jackson3Module()) <1> + + get("/") { + val myObject = ...; + myObject <2> + } + + post("/") { + val myObject = ctx.body() <3> + ... + } +} +---- + +<1> Install Jackson +<2> Use Jackson to encode an arbitrary object as JSON +<3> Use Jackson to decode JSON to a Java object. Client must specify the `Content-Type: application/json` header + +=== Working with ObjectMapper + +Access to default object mapper is available via require call: + +.Default object mapper +[source, java, role="primary"] +---- +import io.jooby.jackson3.Jackson3Module; + +{ + install(new Jackson3Module()); + + ObjectMapper mapper = require(ObjectMapper.class); + ... +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +import io.jooby.jackson3.Jackson3Module + +{ + install(Jackson3Module()) + + val mapper = require() +} +---- + +You can provide your own `ObjectMapper`: + +.Custom ObjectMapper +[source, java, role="primary"] +---- +import io.jooby.jackson3.Jackson3Module; + +{ + ObjectMapper mapper = new ObjectMapper(); + + install(new Jackson3Module(mapper)); +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +import io.jooby.jackson3.Jackson3Module + +{ + val mapper = ObjectMapper() + + install(Jackson3Module(mapper)) +} +---- + +This allows to configure `Jackson3Module` for doing `xml` processing: + +.XmlMapper +[source, java, role="primary"] +---- +import io.jooby.jackson3.Jackson3Module; + +{ + install(new Jackson3Module(new XmlMapper())); +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +import io.jooby.jackson3.Jackson3Module + +{ + install(Jackson3Module(XmlMapper())) +} +---- + +If you want `json` and `xml` processing then install twice: + +.XmlMapper+JsonMapper +[source, java, role="primary"] +---- +import io.jooby.jackson3.Jackson3Module; + +{ + install(new Jackson3Module(new JsonMapper())); + install(new Jackson3Module(new XmlMapper())); +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +import io.jooby.jackson3.Jackson3Module + +{ + install(Jackson3Module(JsonMapper())) + install(Jackson3Module(XmlMapper())) +} +---- + +=== Provisioning Jackson Modules + +Jackson module can be provided by a link:{uiVersion}/#ecosystem-services-and-the-registry-dependency-injection-di-bridge[dependency injection] framework. + +.Provisioning Modules +[source, java, role="primary"] +---- +import io.jooby.jackson3.Jackson3Module; + +{ + install(new Jackson3Module().module(MyModule.class); +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +import io.jooby.jackson3.Jackson3Module + +{ + install(Jackson3Module().module(MyModule::class.java) +} +---- + +At startup time Jooby asks dependency injection framework to provide a `MyModule` instance. diff --git a/docs/asciidoc/modules/modules.adoc b/docs/asciidoc/modules/modules.adoc index 4d58f29427..8029f4b8be 100644 --- a/docs/asciidoc/modules/modules.adoc +++ b/docs/asciidoc/modules/modules.adoc @@ -1,74 +1,72 @@ -== Modules +=== Modules -Modules are a key concept for building reusable and configurable pieces of software. +**Modules are simply built-in Extensions.** They are thin layers that bootstrap and configure external third-party libraries (like HikariCP, Jackson, or Hibernate) using Jooby's `Extension` API. -Modules (unlike in other frameworks) are thin and do a lot of work to bootstrap and configure an -external library, but they DO NOT provide a new level of abstraction nor [do] they provide a custom -API to access functionality in that library. Instead they expose the library components as they are. +Unlike other frameworks, Jooby modules **do not** create new layers of abstraction or custom wrappers around the libraries they integrate. Instead, they expose the raw library components directly to your application via the Service Registry, allowing you to use the library's native API exactly as its creators intended. -Modules are distributed as separated jar/dependency and usually implement the javadoc:Extension[] API. +Modules are distributed as separate dependencies. Below is the catalog of officially supported Jooby modules: -In general they provide a `builder` class to create the and configure the external library from -configuration properties. - -Available modules are listed next. - -=== Cloud +==== Cloud * link:{uiVersion}/modules/awssdkv2[AWS-SDK v2]: Amazon Web Service module SDK 2. * link:{uiVersion}/modules/aws[AWS SDK v1]: Amazon Web Service module SDK 1. -=== Data - * link:{uiVersion}/modules/ebean[Ebean]: Ebean ORM module. - * link:{uiVersion}/modules/flyway[Flyway]: Flyway migration module. - * link:{uiVersion}/modules/graphql[GraphQL]: GraphQL Java module. - * link:{uiVersion}/modules/hikari[HikariCP]: A high-performance JDBC connection pool. - * link:{uiVersion}/modules/hibernate[Hibernate]: Hibernate ORM module. - * link:{uiVersion}/modules/jdbi[Jdbi]: Jdbi module. - * link:{uiVersion}/modules/kafka[Kafka]: Kafka module. - * link:{uiVersion}/modules/redis[Redis]: Redis module. - * link:{uiVersion}/modules/vertx-mysql-client[Vertx mySQL client]: Vertx reactive mySQL client module. - * link:{uiVersion}/modules/vertx-pg-client[Vertx Postgres client]: Vertx reactive Postgres client module. - -=== Validation - * link:{uiVersion}/modules/avaje-validator[Avaje Validator]: Avaje Validator module. - * link:{uiVersion}/modules/hibernate-validator[Hibernate Validator]: Hibernate Validator module. - -=== Development Tools - * link:{uiVersion}/#development[Jooby Run]: Run and hot reload your application. - * link:{uiVersion}/modules/whoops[Whoops]: Pretty page stacktrace reporter. - * link:{uiVersion}/modules/metrics[Metrics]: Application metrics from the excellent metrics library. - -=== Event Bus +==== Data + * link:{uiVersion}/modules/ebean[Ebean]: Ebean ORM module. + * link:{uiVersion}/modules/flyway[Flyway]: Flyway migration module. + * link:{uiVersion}/modules/graphql[GraphQL]: GraphQL Java module. + * link:{uiVersion}/modules/hikari[HikariCP]: A high-performance JDBC connection pool. + * link:{uiVersion}/modules/hibernate[Hibernate]: Hibernate ORM module. + * link:{uiVersion}/modules/jdbi[Jdbi]: Jdbi module. + * link:{uiVersion}/modules/kafka[Kafka]: Kafka module. + * link:{uiVersion}/modules/redis[Redis]: Redis module. + * link:{uiVersion}/modules/vertx-mysql-client[Vertx mySQL client]: Vertx reactive mySQL client module. + * link:{uiVersion}/modules/vertx-pg-client[Vertx Postgres client]: Vertx reactive Postgres client module. + +==== Dependency Injection + * link:{uiVersion}/modules/avaje-inject[Avaje Inject]: Avaje Inject module. + * link:{uiVersion}/modules/guice[Guice]: Guice module. + +==== Validation + * link:{uiVersion}/modules/avaje-validator[Avaje Validator]: Avaje Validator module. + * link:{uiVersion}/modules/hibernate-validator[Hibernate Validator]: Hibernate Validator module. + +==== Development Tools + * link:{uiVersion}/#tooling-and-operations-development[Jooby Run]: Run and hot reload your application. + * link:{uiVersion}/modules/whoops[Whoops]: Pretty page stacktrace reporter. + * link:{uiVersion}/modules/metrics[Metrics]: Application metrics from the excellent metrics library. + +==== Event Bus * link:{uiVersion}/modules/camel[Camel]: Camel module for Jooby. * link:{uiVersion}/modules/vertx[Vertx]: Vertx module for Jooby. -=== JSON - * link:{uiVersion}/modules/gson[Gson]: Gson module for Jooby. - * link:{uiVersion}/modules/jackson[Jackson]: Jackson module for Jooby. - * link:{uiVersion}/modules/yasson[JSON-B]: JSON-B module for Jooby. - * link:{uiVersion}/modules/avaje-jsonb[Avaje-JsonB]: Avaje-JsonB module for Jooby. - -=== OpenAPI - * link:{uiVersion}/modules/openapi[OpenAPI]: OpenAPI supports. - -=== Template Engine - * link:{uiVersion}/modules/handlebars[Handlebars]: Handlebars template engine. - * link:{uiVersion}/modules/jstachio[JStachio]: JStachio template engine. - * link:{uiVersion}/modules/jte[jte]: jte template engine. - * link:{uiVersion}/modules/freemarker[Freemarker]: Freemarker template engine. - * link:{uiVersion}/modules/pebble[Pebble]: Pebble template engine. - * link:{uiVersion}/modules/rocker[Rocker]: Rocker template engine. - * link:{uiVersion}/modules/thymeleaf[Thymeleaf]: Thymeleaf template engine. - -=== Security - * link:{uiVersion}/modules/jasypt[Jasypt]: Encrypted configuration files. - * link:{uiVersion}/modules/pac4j[Pac4j]: Security engine module. - -=== Session Store - * link:{uiVersion}/modules/caffeine[Caffeine]: In-memory session store using Caffeine cache. - * link:{uiVersion}/modules/jwt-session-store[JWT]: JSON Web Token session store. - * link:{uiVersion}/modules/redis#redis-http-session[Redis]: Save session data on redis. - -=== Scheduler - * link:{uiVersion}/modules/db-scheduler[DbScheduler]: Db scheduler module. - * link:{uiVersion}/modules/quartz[Quartz]: Quartz scheduler module. +==== JSON + * link:{uiVersion}/modules/avaje-jsonb[Avaje-JsonB]: Avaje-JsonB module for Jooby. + * link:{uiVersion}/modules/gson[Gson]: Gson module for Jooby. + * link:{uiVersion}/modules/jackson2[Jackson2]: Jackson2 module for Jooby. + * link:{uiVersion}/modules/jackson3[Jackson3]: Jackson3 module for Jooby. + * link:{uiVersion}/modules/yasson[JSON-B]: JSON-B module for Jooby. + +==== OpenAPI + * link:{uiVersion}/modules/openapi[OpenAPI]: OpenAPI supports. + +==== Template Engine + * link:{uiVersion}/modules/handlebars[Handlebars]: Handlebars template engine. + * link:{uiVersion}/modules/jstachio[JStachio]: JStachio template engine. + * link:{uiVersion}/modules/jte[jte]: jte template engine. + * link:{uiVersion}/modules/freemarker[Freemarker]: Freemarker template engine. + * link:{uiVersion}/modules/pebble[Pebble]: Pebble template engine. + * link:{uiVersion}/modules/rocker[Rocker]: Rocker template engine. + * link:{uiVersion}/modules/thymeleaf[Thymeleaf]: Thymeleaf template engine. + +==== Security + * link:{uiVersion}/modules/jasypt[Jasypt]: Encrypted configuration files. + * link:{uiVersion}/modules/pac4j[Pac4j]: Security engine module. + +==== Session Store + * link:{uiVersion}/modules/caffeine[Caffeine]: In-memory session store using Caffeine cache. + * link:{uiVersion}/modules/jwt-session-store[JWT]: JSON Web Token session store. + * link:{uiVersion}/modules/redis#redis-http-session[Redis]: Save session data on redis. + +==== Scheduler + * link:{uiVersion}/modules/db-scheduler[DbScheduler]: Db scheduler module. + * link:{uiVersion}/modules/quartz[Quartz]: Quartz scheduler module. diff --git a/docs/asciidoc/modules/openapi-ascii.adoc b/docs/asciidoc/modules/openapi-ascii.adoc index a225bbbe1f..ce7c4c6815 100644 --- a/docs/asciidoc/modules/openapi-ascii.adoc +++ b/docs/asciidoc/modules/openapi-ascii.adoc @@ -105,43 +105,47 @@ Data generation follows a flexible pipeline architecture. You start with a sourc ===== 3. Data Sources (Lookups) These functions are your entry points to locate objects within the OpenAPI definition. -[cols="2m,3,3"] +[cols="2,4,5"] |=== -|Function |Description |Example +|Function |Example | Description |operation(method, path) -|Generic lookup for an API operation. |`{{ operation("GET", "/books") }}` +|Generic lookup for an API operation. |GET(path) -|Shorthand for `operation("GET", path)`. |`{{ GET("/books") }}` +|Shorthand for `operation("GET", path)`. |POST(path) -|Shorthand for `operation("POST", path)`. |`{{ POST("/books") }}` +|Shorthand for `operation("POST", path)`. |PUT / PATCH / DELETE -|Shorthand for respective HTTP methods. |`{{ DELETE("/books/{id}") }}` +|Shorthand for respective HTTP methods. |schema(name) -|Looks up a Schema/Model definition by name. |`{{ schema("User") }}` +|Looks up a Schema/Model definition by name. |tag(name) -|Selects a specific Tag group (containing name, description, and routes). |`{{ tag("Inventory") }}` +|Selects a specific Tag group (containing name, description, and routes). |routes() -|Returns a collection of all available routes in the API. |`{% for r in routes() %}...{% endfor %}` +|Returns a collection of all available routes in the API. |server(index) -|Selects a server definition from the OpenAPI spec by index. |`{{ server(0).url }}` +|Selects a server definition from the OpenAPI spec by index. |error(code) +|`{{ statusCode(200) }}` +`{{ statusCode([200, 400]) }}` + +`{{ statusCode( {200: "OK", 400: "Bad Syntax"} ) }}` |Generates an error response object. + **Default:** `{statusCode, reason, message}`. + **Custom:** Looks for a global `error` variable map and interpolates values. @@ -152,11 +156,6 @@ These functions are your entry points to locate objects within the OpenAPI defin 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"} ) }}` |=== diff --git a/docs/asciidoc/modules/openapi.adoc b/docs/asciidoc/modules/openapi.adoc index 8aa72cc96b..190a0fd36e 100644 --- a/docs/asciidoc/modules/openapi.adoc +++ b/docs/asciidoc/modules/openapi.adoc @@ -407,7 +407,7 @@ Whitespaces (including new lines) are ignored. To introduce a new line, you must ==== Supported OpenAPI tags -[cols="3,1,1,1,4"] +[cols="1,1,1,1,4"] |=== | Tag | Main | Controller | Method | Description @@ -600,7 +600,7 @@ Keep in mind that any section found here in the template overrides existing meta === Swagger Annotations -Optionally this plugin depends on some OpenAPI annotations. To use them, you need to add a dependency to your project: +Optionally, this plugin depends on some OpenAPI annotations. To use them, you need to add a dependency to your project: [dependency, artifactId="swagger-annotations"] . diff --git a/docs/asciidoc/modules/whoops.adoc b/docs/asciidoc/modules/whoops.adoc index 3c7f20e95a..36ea440664 100644 --- a/docs/asciidoc/modules/whoops.adoc +++ b/docs/asciidoc/modules/whoops.adoc @@ -75,7 +75,7 @@ If there is no source code to shows, whoops does nothing. If you have custom error handler just add them before whoops (or after). Remember whoops works -like any other link:../#error-handler[Error Handler]. +like any other link:../#core-error-handler[Error Handler]. === Production deploy diff --git a/docs/asciidoc/mvc-api.adoc b/docs/asciidoc/mvc-api.adoc index 7e1e0caff5..9e719311c5 100644 --- a/docs/asciidoc/mvc-api.adoc +++ b/docs/asciidoc/mvc-api.adoc @@ -1,12 +1,10 @@ -== MVC API +=== MVC API -MVC API is an alternative way to define routes in Jooby. It generates source code to define and execute routes. The generated class are suffixed with `_` (underscore). +The MVC API provides an annotation-driven alternative to the Script API for defining routes. Jooby uses an annotation processor to generate source code that defines and executes these routes. By default, the generated classes are suffixed with an underscore (`_`). -If you use Gradle 6.0 or a later version, you can leverage incremental annotation processing support, -which means that Gradle only compiles classes that changed since the last compilation, and only runs -annotation processing on those changed classes. +If you use Gradle 6.0 or later, or a modern Maven setup, Jooby leverages **incremental annotation processing**. This means the compiler only processes classes that have changed since the last build, significantly speeding up compilation times. -The annotation processor has two options allowing you to control incremental processing behavior: +You can control incremental processing via compiler arguments: .build.gradle [source, groovy, role = "primary", subs="verbatim,attributes"] @@ -19,7 +17,7 @@ tasks.withType(JavaCompile) { } ---- -.Kotlin +.Kotlin (kapt) [source, groovy, role = "secondary", subs="verbatim,attributes"] ---- kapt { @@ -29,12 +27,11 @@ kapt { } ---- -By setting `jooby.incremental` to `false` you can disable incremental processing entirely, which means -the regardless what's changed, the whole project is recompiled each time. Defaults to `true`. +By setting `jooby.incremental=false`, you disable incremental processing entirely, forcing a full recompilation of the project every time. (Defaults to `true`). -The package `annotation` contains all the annotations available for MVC routes. +The `io.jooby.annotation` package contains all the annotations available for MVC routes. -.MVC API: +.MVC API Example: [source,java,role="primary"] ---- import io.jooby.annotation.*; @@ -49,7 +46,6 @@ public class Controller { } public class App extends Jooby { - { mvc(new Controller_()); // <3> } @@ -63,14 +59,14 @@ public class App extends Jooby { .Kotlin [source,kotlin,role="secondary"] ---- - -import io.jooby.annotation.*; +import io.jooby.annotation.* +import io.jooby.kt.runApp @Path("/mvc") // <1> class Controller { @GET // <2> - fun sayHi() : String { + fun sayHi(): String { return "Hello Mvc!" } } @@ -82,25 +78,26 @@ fun main(args: Array) { } ---- -<1> Set a path pattern. The `@Path` annotation is enable at class or method level -<2> Add a HTTP method -<3> Register/install the generated controller in the main application +<1> Set a base path pattern. The `@Path` annotation can be applied at the class or method level. +<2> Define the HTTP method. +<3> Register the generated controller (`Controller_`) in the main application. -=== Getting Started +==== Getting Started -To create a new MVC project open the `jooby` console and type: +To quickly create a new MVC project, use the `jooby` console: - jooby create myapp --mvc +[source, bash] +---- +jooby create myapp --mvc +---- -The <> takes care of all configuration steps required by the -annotation processing tool. +The <> automatically configures the Maven/Gradle build and sets up the annotation processor for you. -=== Registration +==== Registration -Mvc routes need to be registered (no classpath scanning). Registration is done from your application -class: +Unlike some frameworks, Jooby **does not use classpath scanning**. MVC routes must be explicitly registered in your application configuration: -.Simple MVC route registration +.Simple MVC Route Registration [source, java, role = "primary"] ---- public class App extends Jooby { @@ -117,8 +114,7 @@ public class App extends Jooby { .Kotlin [source, kotlin, role = "secondary"] ---- - -import io.jooby.* +import io.jooby.kt.runApp fun main(args: Array) { runApp(args) { @@ -127,19 +123,17 @@ fun main(args: Array) { } ---- -The javadoc:Jooby[mvc, io.jooby.MvcExtension] install the mvc route. The generated controller instantiate the controller -or `requires` it when an `jakarta.inject.Inject` annotated controller is present. +The javadoc:Jooby[mvc, io.jooby.MvcExtension] method installs the MVC routes. You can pass an instance directly, or if the controller constructor is annotated with `@Inject` (e.g., `jakarta.inject.Inject`), the generated code will attempt to resolve the dependencies from the registry. -=== Parameters +==== Parameters -HTTP parameter provision is available via `*Param` annotations. +HTTP parameter extraction is handled via `@*Param` annotations. -There is also a javadoc:annotation.Param[] annotation which allows to retrieve parameters from -<>. +You can also use the generic javadoc:annotation.Param[] annotation to extract a parameter from <> with a specific fallback order. -==== Header +===== Header -Provisioning of headers is available via javadoc:annotation.HeaderParam[] annotation: +Extract headers using the javadoc:annotation.HeaderParam[] annotation: .Headers [source, java, role = "primary"] @@ -147,8 +141,8 @@ Provisioning of headers is available via javadoc:annotation.HeaderParam[] annota public class MyController { @GET - public Object provisioning(@HeaderParam String token) { // <1> - ... + public String handle(@HeaderParam String token) { // <1> + // ... } } ---- @@ -159,26 +153,24 @@ public class MyController { class MyController { @GET - fun provisioning(@HeaderParam token: String) : Any { // <1> - ... + fun handle(@HeaderParam token: String): String { // <1> + // ... } } ---- -<1> Access to HTTP header named `token` +<1> Accesses the HTTP header named `token`. -Compared to JAX-RS the parameter name on `@*Param` annotation is completely optional, but required for -non valid Java names: +Unlike JAX-RS, specifying the parameter name inside the annotation is optional. Jooby infers it from the variable name. However, you must provide the name explicitly if the HTTP header is not a valid Java identifier: - -.Non valid Java name +.Invalid Java Identifier [source, java, role = "primary"] ---- public class MyController { @GET - public Object provisioning(@HeaderParam("Last-Modified-Since") long lastModifiedSince) { - ... + public String handle(@HeaderParam("Last-Modified-Since") long lastModifiedSince) { + // ... } } ---- @@ -189,15 +181,15 @@ public class MyController { class MyController { @GET - fun provisioning(@HeaderParam("Last-Modified-Since") lastModifiedSince: Long) : Any { - ... + fun handle(@HeaderParam("Last-Modified-Since") lastModifiedSince: Long): String { + // ... } } ---- -==== Cookie +===== Cookie -Provisioning of cookies is available via javadoc:annotation.CookieParam[] annotation: +Extract cookies using the javadoc:annotation.CookieParam[] annotation: .Cookies [source, java, role = "primary"] @@ -205,8 +197,8 @@ Provisioning of cookies is available via javadoc:annotation.CookieParam[] annota public class MyController { @GET - public Object provisioning(@CookieParam String token) { // <1> - ... + public String handle(@CookieParam String token) { // <1> + // ... } } ---- @@ -217,45 +209,19 @@ public class MyController { class MyController { @GET - fun provisioning(@CookieParam token: String) : Any { // <1> - ... - } -} ----- - -<1> Access to cookie named `token` - -Compared to JAX-RS the parameter name on `@*Param` annotation is completely optional, but required for -non valid Java names: - - -.Non valid Java name -[source, java, role = "primary"] ----- -public class MyController { - - @GET - public Object provisioning(@CookieParam("token-id") String tokenId) { - ... + fun handle(@CookieParam token: String): String { // <1> + // ... } } ---- -.Kotlin -[source, kotlin, role = "secondary"] ----- -class MyController { +<1> Accesses the cookie named `token`. - @GET - fun provisioning(@CookieParam("token-id") tokenId: String) : Any { - ... - } -} ----- +As with headers, provide the explicit name if the cookie key contains dashes or invalid Java characters (e.g., `@CookieParam("token-id") String tokenId`). -==== Path +===== Path -For path parameters the javadoc:annotation.PathParam[] annotation is required: +Extract path variables using the javadoc:annotation.PathParam[] annotation: .PathParam [source, java, role = "primary"] @@ -263,8 +229,8 @@ For path parameters the javadoc:annotation.PathParam[] annotation is required: public class MyController { @Path("/{id}") - public Object provisioning(@PathParam String id) { - ... + public String handle(@PathParam String id) { + // ... } } ---- @@ -275,15 +241,15 @@ public class MyController { class MyController { @Path("/{id}") - fun provisioning(@PathParam id: String) : Any { - ... + fun handle(@PathParam id: String): String { + // ... } } ---- -==== Query +===== Query -For query parameters the javadoc:annotation.QueryParam[] annotation is required: +Extract query string variables using the javadoc:annotation.QueryParam[] annotation: .QueryParam [source, java, role = "primary"] @@ -291,8 +257,8 @@ For query parameters the javadoc:annotation.QueryParam[] annotation is required: public class MyController { @Path("/") - public Object provisioning(@QueryParam String q) { - ... + public String handle(@QueryParam String q) { + // ... } } ---- @@ -303,25 +269,24 @@ public class MyController { class MyController { @Path("/") - fun provisioning(@QueryParam q: String) : Any { - ... + fun handle(@QueryParam q: String): String { + // ... } } ---- -==== Formdata/Multipart +===== Formdata / Multipart -For formdata/multipart parameters the javadoc:annotation.FormParam[] annotation is required: +Extract form-data or multipart parameters using the javadoc:annotation.FormParam[] annotation: -.QueryParam +.FormParam [source, java, role = "primary"] ---- public class MyController { - @Path("/") - @POST - public Object provisioning(@FormParam String username) { - ... + @POST("/") + public String handle(@FormParam String username) { + // ... } } ---- @@ -331,27 +296,25 @@ public class MyController { ---- class MyController { - @Path("/") - @POST - fun provisioning(@FormParam username: String) : Any { - ... + @POST("/") + fun handle(@FormParam username: String): String { + // ... } } ---- -==== Body +===== Request Body -Body parameter doesn't require an annotation: +The HTTP request body does not require an explicit annotation. Simply define the POJO in the method signature: .HTTP Body [source, java, role = "primary"] ---- public class MyController { - @Path("/") - @POST - public Object provisioning(MyObject body) { - ... + @POST("/") + public String handle(MyObject body) { + // ... } } ---- @@ -361,19 +324,18 @@ public class MyController { ---- class MyController { - @Path("/") - @POST - fun provisioning(body: MyObject) : Any { - ... + @POST("/") + fun handle(body: MyObject): String { + // ... } } ---- -==== Bind +===== Bind -You can use the javadoc:annotation.BindParam[] annotation which allow custom mapping from HTTP request. +The javadoc:annotation.BindParam[] annotation allows for custom data binding from the HTTP request directly into an object. -.Use the annotation +.Using the annotation [source, java, role = "primary"] ---- public class Controller { @@ -395,13 +357,14 @@ class Controller { } ---- -.Write the mapping function +.Writing the mapping function [source, java, role = "primary"] ---- public record MyBean(String value) { public static MyBean of(Context ctx) { - // build MyBean from HTTP request + // Build MyBean entirely from the Context + return new MyBean(ctx.path("foo").value()); } } ---- @@ -409,38 +372,32 @@ public record MyBean(String value) { .Kotlin [source, kotlin, role = "secondary"] ---- -class MyBean constructor(value: String) { +class MyBean(val value: String) { companion object { @JvmStatic - fun of(ctx: Context) : Person { - // build MyBean from HTTP request + fun of(ctx: Context): MyBean { + // Build MyBean entirely from the Context + return MyBean(ctx.path("foo").value()) } } } ---- -It works as: +How `@BindParam` works: -- The javadoc:annotation.BindParam[] allow you to convert HTTP request to an Java Object in the way you wish -- The annotation looks for public method/function that takes a javadoc:Context[] as parameter and returns the same type required as parameter. -- It looks in the parameter type or fallback into the controller class - -Alternative you can specify the factory class: +* It looks for a public method/function on the target class that accepts a javadoc:Context[] and returns the target type. +* By default, it looks for this factory method on the parameter type itself (`MyBean`), but will fall back to searching the Controller class. +Alternatively, you can specify a distinct factory class and/or method name: ---- - @BindParam(MyFactoryClass.class) +@BindParam(MyFactoryClass.class) +@BindParam(value = MyFactoryClass.class, fn = "fromContext") ---- -And/or function name: - ----- - @BindParam(value = MyFactoryClass.class, fn = "fromContext") ----- +===== Flash -==== Flash - -Provisioning of flash attribute is available via javadoc:annotation.FlashParam[] annotation: +Extract flash attributes using the javadoc:annotation.FlashParam[] annotation: .Flash [source, java, role = "primary"] @@ -448,8 +405,8 @@ Provisioning of flash attribute is available via javadoc:annotation.FlashParam[] public class MyController { @GET - public Object provisioning(@FlashParam String success) { // <1> - ... + public String handle(@FlashParam String success) { // <1> + // ... } } ---- @@ -460,17 +417,17 @@ public class MyController { class MyController { @GET - fun provisioning(@FlashParam success: String) : Any { // <1> - ... + fun handle(@FlashParam success: String): String { // <1> + // ... } } ---- -<1> Access to flash named `success` +<1> Accesses the flash attribute named `success`. -==== Session +===== Session -Provisioning of session attribute is available via javadoc:annotation.SessionParam[] annotation: +Extract specific session attributes using the javadoc:annotation.SessionParam[] annotation: .Session Attribute [source, java, role = "primary"] @@ -478,8 +435,8 @@ Provisioning of session attribute is available via javadoc:annotation.SessionPar public class MyController { @GET - public Object provisioning(@SessionParam String userId) { // <1> - ... + public String handle(@SessionParam String userId) { // <1> + // ... } } ---- @@ -490,24 +447,24 @@ public class MyController { class MyController { @GET - fun provisioning(@SessionParam userId: String) : Any { // <1> - ... + fun handle(@SessionParam userId: String): String { // <1> + // ... } } ---- -<1> Access to session attribute named `userId` +<1> Accesses the session attribute named `userId`. -Provisioning of javadoc:Session[] is available too: +You can also request the entire javadoc:Session[] object: -.Session Attribute +.Session Object [source, java, role = "primary"] ---- public class MyController { @GET - public Object provisioning(Session session) { // <1> - ... + public String handle(Session session) { // <1> + // ... } } ---- @@ -518,19 +475,17 @@ public class MyController { class MyController { @GET - fun provisioning(session: Session) : Any { // <1> - ... + fun handle(session: Session): String { // <1> + // ... } } ---- -<1> If no session exists yet, new session will be created +<1> If no session exists yet, a **new session will be created**. To avoid this and only retrieve an existing session, use `Optional` as the parameter type. -To avoid this, just use `java.util.Optional` as type. +===== Context -==== Context - -Provisioning of context attributes is available via javadoc:annotation.ContextParam[] annotation: +Extract specific context attributes using the javadoc:annotation.ContextParam[] annotation: .Context Attribute [source, java, role = "primary"] @@ -538,8 +493,8 @@ Provisioning of context attributes is available via javadoc:annotation.ContextPa public class MyController { @GET - public Object provisioning(@ContextParam String userId) { // <1> - ... + public String handle(@ContextParam String userId) { // <1> + // ... } } ---- @@ -550,24 +505,24 @@ public class MyController { class MyController { @GET - fun provisioning(@ContextParam userId: String) : Any { // <1> - ... + fun handle(@ContextParam userId: String): String { // <1> + // ... } } ---- -<1> Access to context attribute named `userId` +<1> Accesses the context attribute named `userId`. -Provisioning of all javadoc:Context[getAttributes, text="attributes"] is available too: +You can also request all javadoc:Context[getAttributes, text="attributes"] at once: -.Context Attributes +.All Context Attributes [source, java, role = "primary"] ---- public class MyController { @GET - public Object provisioning(@ContextParam Map attributes) { // <1> - ... + public String handle(@ContextParam Map attributes) { // <1> + // ... } } ---- @@ -578,22 +533,24 @@ public class MyController { class MyController { @GET - fun provisioning(@ContextParam attributes: Map) : Any { // <1> - ... + fun handle(@ContextParam attributes: Map): String { // <1> + // ... } } ---- -<1> All context attributes must be set as arguments. They must be declared as `Map` +<1> To retrieve all context attributes, the parameter must be typed as a `Map` (or `Map` in Kotlin). -==== Multiple Sources +===== Multiple Sources -You can use the javadoc:annotation.Param[] annotation to search for a parameter in multiple sources. -The sources and their precedence can be specified as follows: +Use the javadoc:annotation.Param[] annotation to search for a parameter across multiple sources with an explicitly defined fallback order: .Multiple Sources [source, java, role = "primary"] ---- +import static io.jooby.annotation.ParamSource.QUERY; +import static io.jooby.annotation.ParamSource.PATH; + public class FooController { @GET("/{foo}") @@ -606,6 +563,9 @@ public class FooController { .Kotlin [source, kotlin, role = "secondary"] ---- +import io.jooby.annotation.ParamSource.QUERY +import io.jooby.annotation.ParamSource.PATH + class FooController { @GET("/{foo}") @@ -613,134 +573,114 @@ class FooController { } ---- -In case of a request like `/bar?foo=baz`, `foo is: baz` will be returned since the query parameter -takes precedence over the path parameter. - -=== Responses - -==== Status Code +If a request is made to `/bar?foo=baz`, the result will be `foo is: baz` because the `QUERY` parameter takes precedence over the `PATH` parameter in the annotation array. -The default status code is `Success(200)`, except for `void` methods with the `@DELETE` annotation which is set to `No Content(204)`. +==== Responses -There are two options if you need a different status code: +===== Projections -- Add a javadoc:Context[] parameter and set the javadoc:Context[setResponseCode, io.jooby.StatusCode] -- Returns a javadoc:StatusCode[] instance +The MVC module provides first-class support for Projections via annotations. This allows you to define the response view declaratively, keeping your controller logic clean and focused on data retrieval. -==== NonBlocking +====== Usage -Method returning a `CompletableFuture`, `Single`, `Maybe`, `Flowable`, `Mono` or `Flux` is -considered a non-blocking route. +There are two ways to define a projection in an MVC controller. -Kotlin suspend functions are supported too: +You can annotate your method with `@Project` and provide the selection DSL: -.Kotlin Coroutines -[source, kotlin] +.Via @Project Annotation +[source,java] ---- -class SuspendMvc { - @GET - @Path("/delay") - suspend fun delayed(ctx: Context): String { - delay(100) - return ctx.getRequestPath() - } +@GET +@Project("(id, name)") +public List listUsers() { + return service.findUsers(); } +---- -fun main(args: Array) { - runApp(args) { - use(SuspendMvc()) - } +Alternatively, you can define the projection directly within the HTTP method annotation (e.g., `@GET`, `@POST`) using the `projection` attribute: + +.Via HTTP Method Attribute +[source,java] +---- +@GET(value = "/users", projection = "(id, name, email)") +public List listUsers() { + return service.findUsers(); } ---- -A non-blocking route run on the event loop (by default) where *blocking is NOT allowed*. For more -details please checkout the <> section. +====== Automatic Wrapping -=== Execution model +The Jooby Annotation Processor automatically handles the conversion of your method's return type. You are **not forced** to return a `Projected` instance; you can simply return your POJO or Collection, and Jooby will wrap it for you at compile-time. -The MVC routes follows the execution model described in <>. To run application -logic in the javadoc:ExecutionMode[EVENT_LOOP]: +However, if you need manual control (for example, to dynamically toggle validation), you can still return a `Projected` instance explicitly: -.EventLoop MVC route -[source, java, role = "primary"] +[source,java] ---- - -public class App extends Jooby { - { - mvc(new MyController_()); - } - - public static void main(String[] args) { - runApp(args, EVENT_LOOP, App::new); <1> - } +@GET +public Projected getUser(String id) { + User user = service.findById(id); + return Projected.wrap(user) + .failOnMissingProperty(true) + .include("(id, status)"); } ---- -.Kotlin -[source, kotlin, role = "secondary"] ----- -import io.jooby.* +[NOTE] +==== +For more details on the Selection DSL syntax and available JSON engines, please refer to the <>. +==== -fun main(args: Array) { - runApp(args, EVENT_LOOP) { <1> - mvc(MyController_()) - } -} ----- +===== Status Code -<1> Start the application in the EVENT_LOOP execution mode +The default HTTP status code returned by an MVC route is `200 OK`, except for `void` methods annotated with `@DELETE`, which automatically return `204 No Content`. -Similarly, if you need to run all mvc routes in the javadoc:ExecutionMode[WORKER] execution mode: +If you need to return a different status code, you have two options: +1. Inject the javadoc:Context[] into your method and call javadoc:Context[setResponseCode, io.jooby.StatusCode]. +2. Return a javadoc:StatusCode[] instance directly from the method. -.Worker mode MVC route -[source, java, role = "primary"] ----- +===== NonBlocking -public class App extends Jooby { - { - dispatch(() -> { - mvc(new MyBlockingController_()); <1> - }); - } +Any MVC method returning a non-blocking type (`CompletableFuture`, `Single`, `Maybe`, `Flowable`, `Mono`, `Flux`) is automatically handled as a non-blocking route. - public static void main(String[] args) { - runApp(args, EVENT_LOOP, App::new); - } -} ----- +Kotlin `suspend` functions are also supported natively: -.Kotlin -[source, kotlin, role = "secondary"] +.Kotlin Coroutines +[source, kotlin] ---- -import io.jooby.* +class SuspendMvc { + @GET + @Path("/delay") + suspend fun delayed(ctx: Context): String { + delay(100) + return ctx.getRequestPath() + } +} fun main(args: Array) { - runApp(args, EVENT_LOOP) { - dispatch { - mvc(MyBlockingController_()) <1> - } + runApp(args) { + mvc(SuspendMvc_()) } } ---- -<1> Wrap the controller using the dispatch operator +A non-blocking route runs on the event loop by default, where **blocking is NOT allowed**. For more details, see the <> section. + +==== Execution Model + +MVC routes follow the standard Jooby <>. -One drawback with this approach is that the entire controller is now going to be executed in the worker or custom executor. -For more fine grain control use the javadoc:annotation.Dispatch[] annotation: +By default, if your route returns a blocking type (like a `String` or a POJO), Jooby automatically dispatches the execution to the **worker executor**. If it returns a non-blocking type (or is a `suspend` function), it runs on the **event loop**. -.Dispatch annotation +If you need explicit control over where a specific blocking MVC route executes, use the javadoc:annotation.Dispatch[] annotation: + +.Dispatch Annotation [source, java, role = "primary"] ---- - public class MyController { - @GET("/nonblocking") - public String nonblocking() { <1> - return "I'm nonblocking"; - } @GET("/blocking") - @Dispatch - public String blocking() { <2> + @Dispatch // <1> + public String blocking() { return "I'm blocking"; } } @@ -753,31 +693,25 @@ import io.jooby.annotation.* class MyController { - @GET("/nonblocking") - fun nonblocking() : String { <1> - return "I'm nonblocking"; - } - @GET("/blocking") - @Dispatch - fun blocking() : String { <2> - return "I'm blocking"; + @Dispatch // <1> + fun blocking(): String { + return "I'm blocking" } } ---- -<1> MVC route run in EVENT_LOOP mode. Blocking is NOT allowed it. -<2> MVC route run in WORKER mode. Blocking is allowed it. +<1> Forces the route to run in the `WORKER` executor, safely allowing blocking calls. -The javadoc:annotation.Dispatch[] annotation supports custom executor using an executor name. +The javadoc:annotation.Dispatch[] annotation also supports routing execution to a named, custom executor: .Dispatch to custom executor [source, java, role = "primary"] ---- - public class MyController { + @GET("/blocking") - @Dispatch("single") <1> + @Dispatch("single") // <1> public String blocking() { return "I'm blocking"; } @@ -792,16 +726,16 @@ import io.jooby.annotation.* class MyController { @GET("/blocking") - @Dispatch("single") <1> - fun blocking() : String { - return "I'm blocking"; + @Dispatch("single") // <1> + fun blocking(): String { + return "I'm blocking" } } ---- -<1> Dispatch to an executor named it `single` +<1> Dispatches execution to the executor registered under the name `single`. -Executor must be registered using via services or executor utility method: +The custom executor must be registered in the application **before** the MVC route is registered: .Custom executor registration [source, java, role = "primary"] @@ -823,16 +757,13 @@ Executor must be registered using via services or executor utility method: } ---- -The executor must be registered before the MVC route/controller. - -=== JAX-RS Annotations +==== JAX-RS Annotations -Alternative you can use JAX-RS annotations to define MVC routes. +Alternatively, you can use JAX-RS annotations to define MVC routes. .Resource [source, java, role="primary"] ---- - import javax.ws.rs.GET; import javax.ws.rs.Path; @@ -856,78 +787,72 @@ import javax.ws.rs.Path class Resource { @GET - fun getIt() : String { + fun getIt(): String { return "Got it!" } } ---- -Annotations work exactly like the Jooby MVC annotations, but keep in mind we don't implement the -JAX-RS specification and there is no immediate plan to do it. +These annotations work exactly like the Jooby native MVC annotations. -The main reason to support JAX-RS annotations is to let you plug-in third-party tools that rely -on them (mostly annotations processors). +*(Note: Jooby does **not** implement the full JAX-RS specification, nor is there a plan to do so. Support for these annotations exists primarily to allow integration with third-party tools, like Swagger/OpenAPI generators, that rely on them).* -=== Generated router +==== Generated Router -For each MVC/controller class a new class is generated. The new class ends with `_`, this class -mimics the constructors from the source class. If the constructor is annotated with `@Inject` a -default constructor will exists. +For each MVC controller class, a new class is generated ending with an underscore (`_`). This generated class mimics the constructors of the source class. (If the constructor is annotated with `@Inject`, a default constructor is automatically generated). -Any annotation found on the controller method will be persisted as route attributes, unless -`jooby.skipAttributeAnnotations` excludes it. +Any annotations found on the controller methods will be persisted as route attributes, unless explicitly excluded by the `jooby.skipAttributeAnnotations` compiler option. -Access to generated routes is allowed: +You can access the generated routes at runtime: [source, java] ---- { var routes = mvc(new MyController_()); routes.forEach(route -> { - // do something with route + // Modify or inspect the route }); } ---- -=== Annotation Processor Options +==== Annotation Processor Options -[cols="1,1,1,1"] +[cols="2,1,1,4"] |=== -| Option | Value | Default Value| Description - -|jooby.debug -|boolean -|true -|Run processor in debug mode - -|jooby.incremental -|boolean -|true -|Hints maven/gradle to do incremental compilation. Useful for development. - -|jooby.skipAttributeAnnotations -|array -|[] -|Skip annotation during byte code generation (i.e. don't generate them as route attributes) - -|jooby.mvcMethod -|boolean -|false -|Set the Route.mvcMethod when true. - -|jooby.routerPrefix -|string +| Option | Type | Default | Description + +| `jooby.debug` +| boolean +| true +| Runs the annotation processor in debug mode. + +| `jooby.incremental` +| boolean +| true +| Hints to Maven/Gradle to perform incremental compilation. Essential for fast development iteration. + +| `jooby.skipAttributeAnnotations` +| array +| [] +| A comma-separated list of annotations to skip during bytecode generation (i.e., do not attach them as route attributes). + +| `jooby.mvcMethod` +| boolean +| false +| Sets the `Route.mvcMethod` property on the generated route when true. + +| `jooby.routerPrefix` +| string | -|Prefix for generated class - -|jooby.routerSuffix -|string -|_ -|Suffix for generated class +| Adds a prefix to the generated class name. +| `jooby.routerSuffix` +| string +| _ +| Sets the suffix for the generated class name. |=== -==== Setting options +===== Setting Options .Maven [source, xml, role="primary", subs="verbatim,attributes"] @@ -936,8 +861,6 @@ Access to generated routes is allowed: maven-compiler-plugin - - io.jooby jooby-apt @@ -945,15 +868,9 @@ Access to generated routes is allowed: - - -Ajooby.debug=false - - - -Ajooby.incremental=true - - - -Ajooby.skipAttributeAnnotations=FooAnnotation,BarAnnotation - + -Ajooby.debug=false + -Ajooby.incremental=true + -Ajooby.skipAttributeAnnotations=FooAnnotation,BarAnnotation @@ -972,7 +889,7 @@ tasks.withType(JavaCompile) { } ---- -[NOTE] +[IMPORTANT] ==== -Please note that the order of annotation processors is important. For example, if you're using `lombok` and `avaje-inject`, the correct order should be: `lombok` -> `avaje-inject` -> `jooby-apt` +The execution order of annotation processors is critical. If you are using `Lombok` and `Avaje Inject` alongside Jooby, the configuration order must be: `lombok` -> `avaje-inject` -> `jooby-apt`. ==== diff --git a/docs/asciidoc/packaging/packaging.adoc b/docs/asciidoc/packaging/packaging.adoc index aff8034dd0..1dbd7f6b58 100644 --- a/docs/asciidoc/packaging/packaging.adoc +++ b/docs/asciidoc/packaging/packaging.adoc @@ -1,157 +1,129 @@ -== Packaging +=== Packaging -This section describes some packaging and distribution options. +This section describes the primary options for packaging and distributing your Jooby application. -=== Single jar +==== Single Jar (Fat/Uber Jar) -This is the default deployment option where you create a single jar (a.k.a fat/uber jar) for your -application. +The most common deployment option is creating a single executable "Fat Jar" that contains your application code along with all its dependencies. [TIP] ==== -The link:{uiVersion}/#getting-started[jooby-cli] takes care of configures everything for single jar -distribution. Next example shows how to do it in case you created your application manually. +The link:{uiVersion}/#getting-started[Jooby CLI] automatically configures your project for single jar distribution. The examples below show how to configure it manually if needed. ==== -.Maven +.Maven (Shade Plugin) [source, xml, role="primary", subs="verbatim,attributes"] ---- - ... - maven-shade-plugin - {mavenShadePluginVersion} - - - uber-jar - package - - shade - - - false - - - - ${application.class} - - - - - - - ... + org.apache.maven.plugins + maven-shade-plugin + {mavenShadePluginVersion} + + + uber-jar + package + + shade + + + false + + + + ${application.class} + + + + + + ---- -.Gradle -[source, groovy, role="secondary", subs="verbatim,attributes"] +.Gradle (Shadow Plugin) +[source, gradle, role="secondary", subs="verbatim,attributes"] ---- plugins { - ... - id "com.github.johnrengelman.shadow" version "5.2.0" - ... + id "com.github.johnrengelman.shadow" version "8.1.1" } -... - shadowJar { + // Required to merge ServiceLoader files from dependencies mergeServiceFiles() + + manifest { + attributes 'Main-Class': "${mainClassName}" + } } - ---- -Maven users: +To build the package: - mvn clean package +* **Maven:** `mvn clean package` +* **Gradle:** `./gradlew shadowJar` -Gradle users: +==== Stork - ./gradlew shadowJar - -=== Stork - -https://github.com/fizzed/stork[Stork] is packaging, launch and deploy tool for Java apps. +https://github.com/fizzed/stork[Stork] is a specialized packaging, launch, and deployment tool for Java applications. It generates platform-specific native launchers (shell scripts or batch files) and organizes your dependencies in a clean directory structure. [NOTE] ==== -Stork is only available for Maven projects +The Stork integration is currently only available for Maven projects. ==== -To configure stork: +To configure Stork: -1) Creates a `src/etc/stork/stork.yml` file (file location is important): +1. **Create the configuration:** Define a `src/etc/stork/stork.yml` file. .stork.yml [source, yaml] ---- -# Name of application (make sure it has no spaces) +# Application name (no spaces) name: "${project.artifactId}" - -# Display name of application (can have spaces) display_name: "${project.name}" # Type of launcher (CONSOLE or DAEMON) type: DAEMON - -# Java class to run main_class: "${application.class}" -domain: "${project.groupId}" - -short_description: "${project.artifactId}" - -# Platform launchers to generate (WINDOWS, LINUX, MAC_OSX) -# Linux launcher is suitable for Bourne shells (e.g. Linux/BSD) +# Platforms to generate (LINUX, WINDOWS, MAC_OSX) platforms: [ LINUX ] -# Working directory for app -# RETAIN will not change the working directory -# APP_HOME will change the working directory to the home of the app -# (where it was intalled) before running the main class +# Directory mode: RETAIN (current) or APP_HOME (switch to app root) working_dir_mode: RETAIN -# Minimum version of java required (system will be searched for acceptable jvm) -min_java_version: "1.8" - -# Min/max fixed memory (measured in MB) +# Runtime Requirements +min_java_version: "17" min_java_memory: 512 max_java_memory: 512 -# Min/max memory by percentage of system -#min_java_memory_pct: 10 -#max_java_memory_pct: 20 - -# Try to create a symbolic link to java executable in /run with -# the name of "-java" so that commands like "ps" will make it -# easier to find your app +# Create a symbolic link to java as "-java" for easier process tracking symlink_java: true ---- -2) Configure https://github.com/repaint-io/maven-tiles[Maven Tiles] plugin: +2. **Add the Maven Tiles plugin:** Use the Jooby Stork tile to automate the build. -.Maven +.pom.xml [source, xml, subs="verbatim,attributes"] ---- - io.repaint.maven - tiles-maven-plugin - {mavenTilesPluginVersion} - true - - - io.jooby:jooby-stork:{joobyVersion} - - + io.repaint.maven + tiles-maven-plugin + {mavenTilesPluginVersion} + true + + + io.jooby:jooby-stork:{joobyVersion} + + ---- -3) Run `mvn package` - -Stork zip file will be available in the `target` directory. +3. **Build the package:** Run `mvn package`. The resulting Stork `.zip` file will be located in the `target` directory. diff --git a/docs/asciidoc/problem-details.adoc b/docs/asciidoc/problem-details.adoc index 9b4984ee80..9a43edcff3 100644 --- a/docs/asciidoc/problem-details.adoc +++ b/docs/asciidoc/problem-details.adoc @@ -1,20 +1,14 @@ -=== Problem Details +==== Problem Details -Most APIs have a way to report problems and errors, helping the user understand when something went wrong and what the issue is. -The method used depends on the API’s style, technology, and design. -Handling error reporting is an important part of the overall API design process. +Most APIs need a structured way to report errors, helping users understand exactly what went wrong. While you could invent a custom error-reporting format, it requires effort to design and forces your clients to learn a non-standard schema. -You could create your own error-reporting system, but that takes time and effort, both for the designer and for users who need to learn the custom approach. -Thankfully, there’s a standard called https://www.rfc-editor.org/rfc/rfc7807[IETF RFC 7807] (later refined in https://www.rfc-editor.org/rfc/rfc9457[RFC 9457]) that can help. +Instead, you can adopt the standard defined in https://www.rfc-editor.org/rfc/rfc7807[IETF RFC 7807] (later refined by https://www.rfc-editor.org/rfc/rfc9457[RFC 9457]). Adopting this standard saves you time and benefits your users by providing a familiar, widely supported error format. -By adopting `RFC 7807`, API designers don’t have to spend time creating a custom solution, and users benefit by recognizing a familiar format across different APIs. -If it suits the API’s needs, using this standard benefits both designers and users alike. +Jooby provides built-in, native support for RFC 7807 Problem Details. -`Jooby` provides built-in support for `Problem Details`. +===== Setup -==== Set up ProblemDetails - -To enable the `ProblemDetails`, simply add the following line to your configuration: +To enable Problem Details, simply add the following line to your configuration: .application.conf [source, properties] @@ -22,10 +16,9 @@ To enable the `ProblemDetails`, simply add the following line to your configurat problem.details.enabled = true ---- -This is the bare minimal configuration you need. -It enables a global error handler that catches all exceptions, transforms them into Problem Details compliant format and renders the response based on the Accept header value. It also sets the appropriate content-type in response (e.g. application/problem+json, application/problem+xml) +This minimal configuration enables a global error handler that catches all exceptions, transforms them into the Problem Details format, and renders the response based on the `Accept` header. It also sets the appropriate content type (e.g., `application/problem+json`). -All supported settings include: +You can customize the behavior using these additional properties: .application.conf [source, properties] @@ -38,74 +31,76 @@ problem.details { } ---- +<1> By default, only server errors (`5xx`) are logged. You can enable logging for client errors (`4xx`) as well. (If your logger is set to `DEBUG`, the log will also include the stack trace). +<2> Mute logging entirely for specific HTTP status codes. +<3> Mute logging entirely for specific Exception classes. -<1> By default, only server errors (5xx) will be logged. You can optionally enable the logging of client errors (4xx). If `DEBUG` logging level is enabled, the log will contain a stacktrace as well. -<2> You can optionally mute some status codes completely. -<3> You can optionally mute some exceptions logging completely. - - -==== Creating problems +===== Creating Problems -javadoc:problem.HttpProblem[] class represents the `RFC 7807` model. It is the main entity you need to work with to produce the problem. +The javadoc:problem.HttpProblem[] class represents the RFC 7807 model. Because it extends `RuntimeException`, you can throw it naturally just like any other exception. -===== Static helpers +====== Static Helpers -There are several handy static methods to produce a simple javadoc:problem.HttpProblem[]: +There are several static methods to quickly produce an javadoc:problem.HttpProblem[]: -- javadoc:problem.HttpProblem[valueOf, io.jooby.StatusCode] - will pick the title by status code. -Don't overuse it, the problem should have meaningful `title` and `detail` when possible. -- javadoc:problem.HttpProblem[valueOf, io.jooby.StatusCode, java.lang.String] - with custom `title` -- javadoc:problem.HttpProblem[valueOf, io.jooby.StatusCode, java.lang.String, java.lang.String] - with `title` and `detail` +* javadoc:problem.HttpProblem[valueOf, io.jooby.StatusCode]: Derives the title from the status code. +* javadoc:problem.HttpProblem[valueOf, io.jooby.StatusCode, java.lang.String]: Specifies a custom `title`. +* javadoc:problem.HttpProblem[valueOf, io.jooby.StatusCode, java.lang.String, java.lang.String]: Specifies a custom `title` and `detail`. -and a couple of shorthands for the most common validation codes: +There are also shorthands for common HTTP errors: -- javadoc:problem.HttpProblem[badRequest, java.lang.String, java.lang.String] - for _400 Bad Request_ -- javadoc:problem.HttpProblem[notFound, java.lang.String, java.lang.String] - for _404 Not Found_ -- javadoc:problem.HttpProblem[unprocessableEntity, java.lang.String, java.lang.String] - for _422 Unprocessable Entity_ -- javadoc:problem.HttpProblem[internalServerError] - for _500 Internal Server Error_ +* `HttpProblem.badRequest(...)` (400) +* `HttpProblem.notFound(...)` (404) +* `HttpProblem.unprocessableEntity(...)` (422) +* `HttpProblem.internalServerError()` (500) -javadoc:problem.HttpProblem[] extends `RuntimeException` so you can naturally throw it (as you do with exceptions): - -.Java +.Throwing an HttpProblem [source,java,role="primary"] ---- import io.jooby.problem.HttpProblem; +import io.jooby.StatusCode; -get("/users/{userId}", ctx -> { - var userId = ctx.path("userId").value(); - User user = userRepository.findUser(userId); - - if (user == null) { - throw HttpProblem.valueOf(StatusCode.NOT_FOUND, - "User Not Found", - "User with ID %s was not found in the system.".formatted(userId) - ); - } - ... -}); +{ + get("/users/{userId}", ctx -> { + String userId = ctx.path("userId").value(); + User user = userRepository.findUser(userId); + + if (user == null) { + throw HttpProblem.valueOf( + StatusCode.NOT_FOUND, + "User Not Found", + "User with ID " + userId + " was not found in the system." + ); + } + // ... + }); +} ---- .Kotlin -[source,kt,role="secondary"] +[source,kotlin,role="secondary"] ---- import io.jooby.problem.HttpProblem +import io.jooby.StatusCode -get("/users/{userId}") { ctx -> - val userId = ctx.path("userId").value() - val user = userRepository.findUser(userId) - - if (user == null) { - throw HttpProblem.valueOf(StatusCode.NOT_FOUND, - "User Not Found", - "User with ID $userId was not found in the system." - ) +{ + get("/users/{userId}") { ctx -> + val userId = ctx.path("userId").value() + val user = userRepository.findUser(userId) + + if (user == null) { + throw HttpProblem.valueOf( + StatusCode.NOT_FOUND, + "User Not Found", + "User with ID $userId was not found in the system." + ) + } + // ... } - ... -}) +} ---- -Resulting response: - +**Resulting Response:** [source,json] ---- { @@ -118,11 +113,12 @@ Resulting response: } ---- -===== Builder +====== Builder -Use builder to create a rich problem instance with all properties: +For complex errors, use the builder to construct a rich problem instance with all standard properties: -[source,java] +.Using the Builder +[source,java,role="primary"] ---- throw HttpProblem.builder() .type(URI.create("http://example.com/invalid-params")) @@ -133,76 +129,123 @@ throw HttpProblem.builder() .build(); ---- -==== Adding extra parameters +.Kotlin +[source,kotlin,role="secondary"] +---- +throw HttpProblem.builder() + .type(URI.create("http://example.com/invalid-params")) + .title("Invalid input parameters") + .status(StatusCode.UNPROCESSABLE_ENTITY) + .detail("'Name' may not be empty") + .instance(URI.create("http://example.com/invalid-params/3325")) + .build() +---- -`RFC 7807` has a simple extension model: APIs are free to add any other properties to the problem details object, so all properties other than the five ones listed above are extensions. +====== Extra Parameters -However, variadic root level fields are usually not very convenient for (de)serialization (especially in statically typed languages). That's why javadoc:problem.HttpProblem[] implementation grabs all extensions under a single root field `parameters`. You can add parameters using builder like this: +RFC 7807 allows you to add custom properties to the problem details object. To make serialization easier (especially in statically typed languages), Jooby groups all extra properties under a single root field called `parameters`. -[source,java] +You can add parameters via the builder: + +.Adding Parameters +[source,java,role="primary"] ---- throw HttpProblem.builder() .title("Order not found") .status(StatusCode.NOT_FOUND) - .detail("Order with ID $orderId could not be processed because it is missing or invalid.") + .detail("Order with ID " + orderId + " could not be processed.") .param("reason", "Order ID format incorrect or order does not exist.") - .param("suggestion", "Please check the order ID and try again") + .param("suggestion", "Please check the order ID and try again.") .param("supportReference", "/support") .build(); ---- -Resulting response: +.Kotlin +[source,kotlin,role="secondary"] +---- +throw HttpProblem.builder() + .title("Order not found") + .status(StatusCode.NOT_FOUND) + .detail("Order with ID $orderId could not be processed.") + .param("reason", "Order ID format incorrect or order does not exist.") + .param("suggestion", "Please check the order ID and try again.") + .param("supportReference", "/support") + .build() +---- +**Resulting Response:** [source,json] ---- { - "timestamp": "2024-10-06T07:34:06.643235500Z", - "type": "about:blank", "title": "Order not found", "status": 404, - "detail": "Order with ID $orderId could not be processed because it is missing or invalid.", - "instance": null, + "detail": "Order with ID 123 could not be processed.", "parameters": { "reason": "Order ID format incorrect or order does not exist.", - "suggestion": "Please check the order ID and try again", + "suggestion": "Please check the order ID and try again.", "supportReference": "/support" } } ---- -==== Adding headers +====== Custom Headers -Some `HTTP` codes (like `413` or `426`) require additional response headers, or it may be required by third-party system/integration. javadoc:problem.HttpProblem[] support additional headers in response: +Some HTTP responses (like `413 Payload Too Large` or `426 Upgrade Required`) require specific response headers. You can append headers directly to your `HttpProblem`: -[source,java] +.Adding Headers +[source,java,role="primary"] ---- throw HttpProblem.builder() .title("Invalid input parameters") .status(StatusCode.UNPROCESSABLE_ENTITY) - .header("my-string-header", "string") + .header("my-string-header", "string-value") .header("my-int-header", 100) .build(); ---- -==== Respond with errors details +.Kotlin +[source,kotlin,role="secondary"] +---- +throw HttpProblem.builder() + .title("Invalid input parameters") + .status(StatusCode.UNPROCESSABLE_ENTITY) + .header("my-string-header", "string-value") + .header("my-int-header", 100) + .build() +---- + +====== Error Details (RFC 9457) -`RFC 9457` finally described how errors should be delivered in HTTP APIs. -It is basically another extension `errors` on a root level. Adding errors is straight-forward using `error()` or `errors()` for bulk addition in builder: +RFC 9457 introduced a standard way to deliver bulk validation errors via an `errors` array. You can add these using the `error()` or `errors()` methods in the builder: -[source,java] +.Adding Validation Errors +[source,java,role="primary"] ---- throw HttpProblem.builder() - ... + .title("Validation Failed") + .status(StatusCode.BAD_REQUEST) .error(new HttpProblem.Error("First name cannot be blank", "/firstName")) .error(new HttpProblem.Error("Last name is required", "/lastName")) .build(); ---- -In response: +.Kotlin +[source,kotlin,role="secondary"] +---- +throw HttpProblem.builder() + .title("Validation Failed") + .status(StatusCode.BAD_REQUEST) + .error(HttpProblem.Error("First name cannot be blank", "/firstName")) + .error(HttpProblem.Error("Last name is required", "/lastName")) + .build() +---- + +**Resulting Response:** [source,json] ---- { - ... + "title": "Validation Failed", + "status": 400, "errors": [ { "detail": "First name cannot be blank", @@ -218,75 +261,124 @@ In response: [TIP] ==== -If you need to enrich errors with more information feel free to extend javadoc:problem.HttpProblem.Error[] and make your custom errors model. +If you need to enrich validation errors with more information, you can extend javadoc:problem.HttpProblem.Error[] to create your own custom error model. ==== -==== Custom `Exception` to `HttpProblem` +====== Mapping Custom Exceptions -Apparently, you may already have many custom `Exception` classes in the codebase, and you want to make them `Problem Details` compliant without complete re-write. You can achieve this by implementing javadoc:problem.HttpProblemMappable[] interface. It allows you to control how exceptions should be transformed into javadoc:problem.HttpProblem if default behaviour doesn't suite your needs: +If your application already uses a suite of custom exception classes, you don't need to rewrite them. Make them Problem Details-compliant by implementing the javadoc:problem.HttpProblemMappable[] interface: -[source,java] +.Mapping Existing Exceptions +[source,java,role="primary"] ---- import io.jooby.problem.HttpProblemMappable; -public class MyException implements HttpProblemMappable { +public class MyBusinessException extends RuntimeException implements HttpProblemMappable { + @Override public HttpProblem toHttpProblem() { return HttpProblem.builder() - ... - build(); + .title("Business Logic Violation") + .status(StatusCode.CONFLICT) + .detail(this.getMessage()) + .build(); + } +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +import io.jooby.problem.HttpProblemMappable + +class MyBusinessException(message: String) : RuntimeException(message), HttpProblemMappable { + + override fun toHttpProblem(): HttpProblem { + return HttpProblem.builder() + .title("Business Logic Violation") + .status(StatusCode.CONFLICT) + .detail(this.message) + .build() } - } ---- -==== Custom Problems +====== Custom Problem Types -Extending javadoc:problem.HttpProblem[] and utilizing builder functionality makes it really easy: +You can easily define domain-specific problem types by extending `HttpProblem` and utilizing the builder: -[source,java] +.Custom Problem Class +[source,java,role="primary"] ---- public class OutOfStockProblem extends HttpProblem { private static final URI TYPE = URI.create("https://example.org/out-of-stock"); - public OutOfStockProblem(final String product) { + public OutOfStockProblem(String product) { super(builder() .type(TYPE) .title("Out of Stock") .status(StatusCode.BAD_REQUEST) - .detail(String.format("'%s' is no longer available", product)) - .param("suggestions", List.of("Coffee Grinder MX-17", "Coffee Grinder MX-25")) + .detail("The product '" + product + "' is no longer available.") + .param("suggestions", List.of("Grinder MX-17", "Grinder MX-25")) + .build() ); } } ---- -==== Custom Exception Handlers +.Kotlin +[source,kotlin,role="secondary"] +---- +import java.net.URI + +class OutOfStockProblem(product: String) : HttpProblem( + builder() + .type(URI.create("https://example.org/out-of-stock")) + .title("Out of Stock") + .status(StatusCode.BAD_REQUEST) + .detail("The product '$product' is no longer available.") + .param("suggestions", listOf("Grinder MX-17", "Grinder MX-25")) + .build() +) +---- + +====== Custom Exception Handlers -All the features described above should give you ability to rely solely on built-in global error handler. But, in case you still need custom exception handler for some reason, you still can do it: +The features above allow you to rely entirely on Jooby's built-in global error handler. However, if you have a niche use case that requires a custom exception handler, you can still catch the exception and manually delegate it to the Problem Details renderer: -[source,java] +.Custom Handler Delegation +[source,java,role="primary"] ---- { - ... - error(MyCustomException.class, (ctx, cause, code) -> { - MyCustomException ex = (MyCustomException) cause; - - HttpProblem problem = ... ; // <1> - - ctx.getRouter().getErrorHandler().apply(ctx, problem, code); // <2> - }); + error(MyCustomException.class, (ctx, cause, code) -> { + MyCustomException ex = (MyCustomException) cause; + + HttpProblem problem = HttpProblem.valueOf(code, ex.getMessage()); // <1> + + ctx.getRouter().getErrorHandler().apply(ctx, problem, code); // <2> + }); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +{ + error(MyCustomException::class) { ctx, cause, code -> + val ex = cause as MyCustomException + + val problem = HttpProblem.valueOf(code, ex.message) // <1> + + ctx.router.errorHandler.apply(ctx, problem, code) // <2> + } } ---- -<1> Transform exception to `HttpProblem` -<2> Propagate the problem to `ProblemDetailsHandler`. It will handle the rest. +<1> Transform the custom exception into an `HttpProblem`. +<2> Propagate the problem back to the global `ProblemDetailsHandler` to ensure standard rendering. [IMPORTANT] ==== -Do not attempt to render `HttpProblem` manually, it is strongly discouraged. -`HttpProblem` is derived from the `RuntimeException` to enable ease of `HttpProblem` throwing. -Thus, thrown `HttpProblem` will also contain a stacktrace, if you render `HttpProblem` as is - -it will be rendered together with stacktrace. It is strongly advised not to expose the stacktrace to the client system. Propagate the problem to global error handler and let him take care of the rest. +**Do not attempt to render `HttpProblem` manually.** Because `HttpProblem` derives from `RuntimeException`, it contains a stack trace. If you render it manually without passing it to the global error handler, you risk accidentally exposing the raw stack trace to the client. Always propagate the problem back to the router's error handler so it can be safely sanitized and formatted. ==== diff --git a/docs/asciidoc/quick-start.adoc b/docs/asciidoc/quick-start.adoc new file mode 100644 index 0000000000..98c8dd5299 --- /dev/null +++ b/docs/asciidoc/quick-start.adoc @@ -0,0 +1,133 @@ +=== Quick Start + +The fastest way to start building is with the `jooby` console. This lightweight CLI generates configured Jooby projects instantly. + +**CLI Features:** + +* Scaffolds Maven or Gradle builds. +* Generates Java or Kotlin applications. +* Configures Script or MVC routes. +* Selects Jetty, Netty, or Undertow as the underlying server. +* Prepares Uber/Fat jars or https://github.com/fizzed/stork[Stork native launchers]. +* Generates a ready-to-use Dockerfile. + +==== Installing the CLI + +1. Download https://repo1.maven.org/maven2/io/jooby/jooby-cli/{joobyVersion}/jooby-cli-{joobyVersion}.zip[jooby-cli.zip]. +2. Unzip the file into a preferred directory. +3. Locate the native launchers in the `bin` folder. + +[TIP] +==== +Add the launcher (`bin/jooby` on Linux/macOS, or `bin/jooby.bat` on Windows) to your system's PATH to run it from any directory. +==== + +[NOTE] +==== +The `jooby` CLI requires Java 17 or higher. Windows users should use `jooby.bat`. +==== + +==== Creating a Project + +First, set your workspace directory where new projects will be saved: + +[source, bash] +---- +jooby set -w ~/Source +---- + +Next, open the console by typing `jooby` and pressing ENTER. + +To see all available options, type `help create`: + +[source, bash] +---- +jooby> help create +Usage: jooby create [-dgikms] [--server=] +Creates a new application + Application name or coordinates (groupId:artifactId:version) + -d, --docker Generates a Dockerfile + -g, --gradle Generates a Gradle project + -i Start interactive mode + -k, --kotlin Generates a Kotlin application + -m, --mvc Generates an MVC application + -s, --stork Add Stork Maven plugin to build (Maven only) + --server= Choose a server: jetty, netty, or undertow +---- + +**Examples:** + +.Create a default Maven Java project: +[source, bash] +---- +jooby> create myapp +---- + +.Create a Kotlin project: +[source, bash] +---- +jooby> create myapp --kotlin +---- + +[NOTE] +.Kotlin Dependency +==== +Since version 3.x, Kotlin is no longer included in the core. The CLI will automatically add the required dependency: +[dependency, artifactId="jooby-kotlin"] +==== + +.Create a Gradle project (Java or Kotlin): +[source, bash] +---- +jooby> create myapp --gradle +jooby> create myapp --gradle --kotlin +---- + +.Create an MVC-based project: +[source, bash] +---- +jooby> create myapp --mvc +---- + +.Specify the server (default is Netty): +[source, bash] +---- +jooby> create myapp --server undertow +---- + +.Include Dockerfile and Stork launcher configurations: +[source, bash] +---- +jooby> create myapp --docker --stork +---- + +For full control over the `groupId`, package structure, and versioning, use interactive mode: + +[source, bash] +---- +jooby> create myapp -i +---- + +==== Understanding Code Snippets + +Throughout this documentation, we prioritize brevity. Unless strictly necessary, code examples will omit the `main` method and class definitions. + +When you see a snippet like this: + +.Snippet +[source, java, role = "primary"] +---- +{ + get("/", ctx -> "Snippet"); +} +---- + +.Kotlin +[source, kotlin, role = "secondary"] +---- +{ + get("/") { "Snippet" } +} +---- + +Assume it is taking place inside the `Jooby` initializer block or the `runApp` function. diff --git a/docs/asciidoc/responses.adoc b/docs/asciidoc/responses.adoc index 77855e6c0c..baa4258c51 100644 --- a/docs/asciidoc/responses.adoc +++ b/docs/asciidoc/responses.adoc @@ -1,17 +1,17 @@ -== Responses +=== Responses -This chapter covers some special response types, like `raw responses`, `streaming`, `file download`, `non-blocking`, etc... +This chapter covers special response types, including raw responses, streaming, file downloads, and non-blocking responses. -=== Raw +==== Raw -Raw responses are NOT processed by a <>. These response types are considered `raw`: +Raw responses are **not** processed by a <>. The following types are considered `raw`: -- String/CharSequence -- byte[] -- java.nio.ByteBuffer/io.netty.buffer.ByteBuf -- java.io.File/java.io.InputStream/java.nio.file.Path/java.nio.channels.FileChannel +* `String` / `CharSequence` +* `byte[]` +* `java.nio.ByteBuffer` / `io.netty.buffer.ByteBuf` +* `java.io.File` / `java.io.InputStream` / `java.nio.file.Path` / `java.nio.channels.FileChannel` -.Generate a JSON String from handler +.Generate a JSON String from a handler [source,java,role="primary"] ---- { @@ -35,20 +35,85 @@ Raw responses are NOT processed by a < { + User user = repository.findById(ctx.path("id").value()); + + return Projected.wrap(user) + .include("(id, name, email)"); +}); +---- + +===== Comparison with @JsonView + +If you have used Jackson's `@JsonView`, you will find Projections far more capable: + +* **Dynamic**: Unlike `@JsonView`, which requires static class markers defined at compile-time, Projections are defined at runtime. +* **Ad-hoc**: You can create any combination of fields on the fly without adding new Java interfaces or classes. +* **Deep Nesting**: Projections easily handle deeply nested object graphs, whereas `@JsonView` can become difficult to manage with complex relationships. + +===== Projection DSL + +The `include` method accepts a string using a simple, nested syntax: + +* **Field Selection**: `(id, name)` returns only those two fields. +* **Nested Selection**: `(id, address(city, country))` selects the `id` and specific fields from the nested `address` object. +* **Wildcards**: `(id, address(*))` selects the `id` and all available fields within the `address` object. +* **Deep Nesting**: `(id, orders(id, items(name, price)))` allows for recursion into the object graph. + +===== Validation + +By default, the projection engine **does not validate** that requested fields exist on the target class (`failOnMissingProperty` is `false`). This allows for maximum flexibility, especially when working with polymorphic types or dynamic data where certain fields may only exist on specific subclasses. + +If you prefer strict enforcement to prevent API consumers from requesting non-existent fields, you can enable validation: + +[source,java] +---- +return Projected.wrap(data) + .failOnMissingProperty(true) + .include("(id, name, strictFieldOnly)"); +---- + +===== More Information + +Support for Projections extends beyond core scripting to include high-level annotations and documentation generation. + +* **MVC Support**: Projections can be applied to controller methods using the `@Project` annotation. See the <> for details. +* **OpenAPI Support**: Jooby automatically generates pruned schemas for your Swagger documentation. See the link:modules/openapi[OpenAPI documentation] for details. + +[NOTE] +==== +**Implementation Note:** +The `Projection` core API defines the structure and the DSL. The actual runtime filtering is performed by your chosen JSON module: + +1. link:modules/avaje-jsonb[Avaje Jsonb] +2. link:modules/jackson2[Jackson 2]/link:modules/jackson3[Jackson 3] +==== + +==== Streaming / Chunked + +The Streaming/Chunked API is available via: + +* javadoc:Context[responseStream]: A blocking API that provides an `OutputStream`. +* javadoc:Context[responseWriter]: A blocking API that provides a `PrintWriter`. +* javadoc:Context[responseSender]: A non-blocking API that provides a javadoc:Sender[]. + +You can only call **one** of these methods per request. When you call one of them, Jooby automatically adds the `Transfer-Encoding: chunked` header if the `Content-Length` is missing. + +All three APIs have a `close` method, which you must call when finished. .Writer example [source,java,role="primary"] @@ -57,7 +122,7 @@ All the three APIs have a `close` method. You must call it once you finish. get("/chunk", ctx -> { try(Writer writer = ctx.responseWriter()) { // <1> writer.write("chunk1"); // <2> - ... + // ... writer.write("chunkN"); } @@ -73,7 +138,7 @@ All the three APIs have a `close` method. You must call it once you finish. get("/chunk") { ctx.responseWriter().use { // <1> writer.write("chunk1") // <2> - ... + // ... writer.write("chunkN") } ctx // <3> @@ -81,21 +146,20 @@ All the three APIs have a `close` method. You must call it once you finish. } ---- -<1> Get the `Writer` inside a try-with-resources statement, so close it automatically. -<2> Write chunks -<3> Return the `Context` +<1> Get the `Writer` inside a try-with-resources (or `use` in Kotlin) block so it closes automatically. +<2> Write chunks of data. +<3> Return the `Context`. -There is an overloaded version (for Java mainly) that let you skip the try-with-resources and -automatically close the writer/stream: +There is an overloaded version (primarily for Java) that lets you skip the try-with-resources block and automatically closes the writer/stream for you: -.Writer example +.Auto-closing Writer example [source,java,role="primary"] ---- { get("/chunk", ctx -> { return ctx.responseWriter(writer -> { // <1> writer.write("chunk1"); // <2> - ... + // ... writer.write("chunkN"); }); }); @@ -109,18 +173,16 @@ automatically close the writer/stream: get("/chunk") { ctx.responseWriter { // <1> writer.write("chunk1") // <2> - ... + // ... writer.write("chunkN") } } } ---- -=== File download +==== File Download -The javadoc:FileDownload[] is used to generate file downloads, i.e. responses with -`Content-Disposition` header. You can use the convenience subclasses javadoc:AttachedFile[] -or javadoc:InlineFile[] to set the header value to `attachment` or `inline` respectively. +Use javadoc:FileDownload[] to generate file downloads (responses with a `Content-Disposition` header). You can use the convenience subclasses javadoc:AttachedFile[] or javadoc:InlineFile[] to set the header value to `attachment` or `inline`, respectively. .File download example [source,java,role="primary"] @@ -128,7 +190,7 @@ or javadoc:InlineFile[] to set the header value to `attachment` or `inline` resp { get("/download-file", ctx -> { Path source = Paths.get("logo.png"); - return new AttachedFile(source); // <1> + return new AttachedFile(source); // <1> }); get("/view-stream", ctx -> { @@ -149,16 +211,15 @@ or javadoc:InlineFile[] to set the header value to `attachment` or `inline` resp get("/view-stream") { val source = ... - InlineFile("myfile.txt", source) // <2> + InlineFile("myfile.txt", source) // <2> } } ---- -<1> Send a download from an `InputStream` -<2> Send a download from a `File` +<1> Send a download from a `Path` as an attachment. +<2> Send a download from an `InputStream` inline. -Another possibility is to use one of the static builder methods of `FileDownload` and specify -the download type (attachment or inline) later. +Alternatively, you can use the static builder methods on `FileDownload` and specify the download type later. .File download with builder method [source,java,role="primary"] @@ -190,31 +251,23 @@ fun produceDownload(ctx: Context) = FileDownload.build(...) } ---- -=== NonBlocking - -Non-blocking responses are a new feature of Jooby 2.x. +==== NonBlocking -From user point of view there is nothing special about them, you just write your route handler as -usually do with blocking types. +From the user's perspective, there is nothing special about writing non-blocking responses—you write your route handler the same way you usually do. However, it's important to understand how they execute in the pipeline based on your application's mode. -Before we jump to each of the supported types, we need to learn what occurs in the pipeline when -there is a non-blocking route handler. - -.In event loop +.In event loop mode [source,java,role="primary"] ---- { mode(EVENT_LOOP); // <1> - use(ReactiveSupport.concurrent()); // <2> get("/non-blocking", ctx -> { - return CompletableFuture // <3> .supplyAsync(() -> { - ... // <4> + // ... // <4> }); - }) + }); } ---- @@ -223,41 +276,37 @@ there is a non-blocking route handler. ---- { mode(EVENT_LOOP) // <1> - - use(ReactiveSupport.concurrent()); // <2> + use(ReactiveSupport.concurrent()) // <2> get("/non-blocking") { - CompletableFuture // <3> .supplyAsync { - ... // <4> + // ... // <4> } } } ---- -<1> App run in *event loop* +<1> The application runs in the **event loop**. <2> Indicates we want to go non-blocking and handle CompletableFuture responses. -<3> Value is provided from *event loop*. No blocking code is permitted -<4> Value is computed/produces from completable future context +<3> The value is provided from the event loop. **No blocking code is permitted.** +<4> The value is computed asynchronously. -Running your `App3508` in *worker* mode works identically, except for we are able to do blocking calls: +Running your application in **worker** mode works identically, except you are allowed to make blocking calls: .In worker mode [source,java,role="primary"] ---- { mode(WORKER); // <1> - use(ReactiveSupport.concurrent()); // <2> get("/blocking", ctx -> { - return CompletableFuture // <3> .supplyAsync(() -> { - ... // <4> + // ... // <4> }); - }) + }); } ---- @@ -266,41 +315,37 @@ Running your `App3508` in *worker* mode works identically, except for we are abl ---- { mode(WORKER) // <1> - use(ReactiveSupport.concurrent()) // <2> get("/blocking") { - CompletableFuture // <3> .supplyAsync { - ... // <4> + // ... // <4> } } } ---- -<1> App run in *worker mode* -<2> Indicates we want to go non-blocking and handle CompletableFuture responses. -<3> Value is provided from *worker mode*. Blocking code is permitted -<4> Value is computed/produces from completable future context +<1> The application runs in **worker mode**. +<2> Indicates we want to go non-blocking. +<3> The value is provided from the worker thread. **Blocking code is permitted.** +<4> The value is computed asynchronously. -Running your `App3508` in *default* mode works identically to running in the *event loop* mode: +The **default** mode mimics the event loop mode when a route produces a non-blocking type: .In default mode [source,java,role="primary"] ---- { mode(DEFAULT); // <1> - use(ReactiveSupport.concurrent()); // <2> get("/non-blocking", ctx -> { - return CompletableFuture // <3> .supplyAsync(() -> { - ... // <4> + // ... // <4> }); - }) + }); } ---- @@ -311,27 +356,23 @@ Running your `App3508` in *default* mode works identically to running in the *ev mode(DEFAULT) // <1> get("/non-blocking") { - - ... // <2> - + // ... // <2> CompletableFuture // <3> .supplyAsync { - ... // <4> + // ... // <4> } } } ---- -<1> App run in *worker mode* -<2> Indicates we want to go non-blocking and handle CompletableFuture responses. -<3> Value is provided from *worker mode*. Blocking code is permitted -<4> Value is computed/produces from completable future context - -The *default* mode mimics the *event loop* mode execution when route produces a *non-blocking* type. +[NOTE] +==== +For all reactive frameworks below, explicit handler setup (e.g., `use(Reactivex.rx())`) is **only** required for Script/Lambda routes. For MVC routes, Jooby automatically configures the handler based on the route's return type. +==== -==== CompletableFuture +===== CompletableFuture -CompletableFuture is considered a non-blocking type which is able to produces a single result: +CompletableFuture is a non-blocking type that produces a single result: .Java [source,java, role="primary"] @@ -343,7 +384,7 @@ CompletableFuture is considered a non-blocking type which is able to produces a return CompletableFuture .supplyAsync(() -> "Completable Future!") .thenApply(it -> "Hello " + it); - }) + }); } ---- @@ -361,38 +402,27 @@ CompletableFuture is considered a non-blocking type which is able to produces a } ---- -[NOTE] -===== -Completable future responses require explicit handler setup ONLY in script/lambda routes. For MVC -routes you don't need to setup any handler. It is done automatically based on route response type. -===== - -==== Mutiny +===== Mutiny 1) Add the https://smallrye.io/smallrye-mutiny[SmallRye Mutiny] dependency: - [dependency, artifactId="jooby-mutiny"] -. - -2) Write code: -===== Uni +2) Write the code: -.Java +.Uni (Single Value) [source,java, role="primary"] ---- import io.jooby.mutiny; import io.smallrye.mutiny.Uni; { - // Add response handler: use(Mutiny.mutiny()); get("/non-blocking", ctx -> { return Uni.createFrom() .completionStage(supplyAsync(() -> "Uni")) .map(it -> "Hello " + it); - }) + }); } ---- @@ -403,33 +433,29 @@ import io.jooby.mutiny import io.smallrye.mutiny.Uni { - // Add response handler: use(Mutiny.mutiny()) get("/non-blocking") { Uni.createFrom() - .completionStage{ supplyAsync { "Uni" }} - .map{"Hello $it"} + .completionStage { supplyAsync { "Uni" } } + .map { "Hello $it" } } } ---- -===== Multi - -.Java +.Multi (Chunked Stream) [source,java, role="primary"] ---- import io.jooby.mutiny; import io.smallrye.mutiny.Multi; { - // Add response handler: use(Mutiny.mutiny()); get("/non-blocking", ctx -> { return Multi.createFrom().range(1, 11) .map(it -> it + ", "); - }) + }); } ---- @@ -440,36 +466,25 @@ import io.jooby.mutiny import io.smallrye.mutiny.Multi { + use(Mutiny.mutiny()) + get("/non-blocking") { Multi.createFrom().range(1, 11) - .map{ "$it, " } + .map { "$it, " } } } ---- -For Multi, Jooby builds a `chunked` response. That: +For `Multi`, Jooby automatically builds a chunked response. Each item in the stream is sent to the client as a new HTTP chunk. -. Set the `Transfer-Encoding: chunked` header -. Each item means new `chunk` send it to client - -[NOTE] -===== -Mutiny responses require explicit handler setup ONLY in script/lambda routes. For MVC -routes you don't need to setup any handler. It is done automatically based on route response type. -===== - -==== RxJava +===== RxJava 1) Add the https://github.com/ReactiveX/RxJava[RxJava] dependency: - [dependency, artifactId="jooby-rxjava3"] -. - -2) Write code: -===== Single +2) Write the code: -.Java +.Single [source,java, role="primary"] ---- import io.jooby.rxjava3.Reactivex; @@ -481,7 +496,7 @@ import io.jooby.rxjava3.Reactivex; return Single .fromCallable(() -> "Single") .map(it -> "Hello " + it); - }) + }); } ---- @@ -501,9 +516,7 @@ import io.jooby.rxjava3.Reactivex } ---- -===== Flowable - -.Java +.Flowable (Chunked Stream) [source,java, role="primary"] ---- import io.jooby.rxjava3.Reactivex; @@ -514,7 +527,7 @@ import io.jooby.rxjava3.Reactivex; get("/non-blocking", ctx -> { return Flowable.range(1, 10) .map(it -> it + ", "); - }) + }); } ---- @@ -528,34 +541,21 @@ import io.jooby.rxjava3.Reactivex get("/non-blocking") { Flowable.range(1, 10) - .map{ "$it, " } + .map { "$it, " } } } ---- -For Flowable, Jooby builds a `chunked` response. That: +For `Flowable`, Jooby builds a chunked response, sending each item as a separate chunk. -. Set the `Transfer-Encoding: chunked` header -. Each item means new `chunk` send it to client - -[NOTE] -===== -Rx responses require explicit handler setup ONLY in script/lambda routes. For MVC -routes you don't need to setup any handler. It is done automatically based on route response type. -===== - -==== Reactor +===== Reactor 1) Add the https://projectreactor.io/[Reactor] dependency: - [dependency, artifactId="jooby-reactor"] -. -2) Write code: +2) Write the code: -===== Mono - -.Java +.Mono (Single Value) [source,java, role="primary"] ---- import io.jooby.Reactor; @@ -567,7 +567,7 @@ import io.jooby.Reactor; return Mono .fromCallable(() -> "Mono") .map(it -> "Hello " + it); - }) + }); } ---- @@ -587,20 +587,18 @@ import io.jooby.Reactor } ---- -===== Flux - -.Java +.Flux (Chunked Stream) [source,java, role="primary"] ---- import io.jooby.Reactor; { - use(Reactor.reactor()) + use(Reactor.reactor()); get("/non-blocking", ctx -> { return Flux.range(1, 10) .map(it -> it + ", "); - }) + }); } ---- @@ -614,23 +612,16 @@ import io.jooby.Reactor get("/non-blocking") { Flux.range(1, 10) - .map{ "$it, " } + .map { "$it, " } } } ---- -For Flux, Jooby builds a `chunked` response. That: +For `Flux`, Jooby builds a chunked response, sending each item as a separate chunk. -. Set the `Transfer-Encoding: chunked` header -. Each item means new `chunk` send it to client +===== Kotlin Coroutines -[NOTE] -===== -Reactor responses require explicit handler setup ONLY in script/lambda routes. For MVC -routes you don't need to setup any handler. It is done automatically based on route response type. -===== - -==== Kotlin Coroutines +*(Note: Coroutines are exclusive to Kotlin, so there is no Java equivalent for this section).* .Coroutine handler: [source, kotlin] @@ -645,10 +636,10 @@ routes you don't need to setup any handler. It is done automatically based on ro } ---- -<1> Call a suspending function -<2> Send response to client +<1> Call a suspending function. +<2> Send the response to the client. -.Here is another example with an extension and suspending function: +.Using an extension and suspending function: [source, kotlin] ---- { @@ -665,17 +656,15 @@ suspend fun Context.doSomething(): String { } ---- -<1> Call extension suspending function -<2> Call a suspending function or do a blocking call -<3> Send response to client +<1> Call an extension suspending function. +<2> Safely perform a suspending or blocking call. +<3> Send the response to the client. -A coroutine works like any of the other non-blocking types. You start Jooby using the **event loop** -or **default mode**, Jooby creates a coroutine context to execute it. +Coroutines work like any other non-blocking type. If you start Jooby using the **event loop** or **default mode**, Jooby creates a coroutine context to execute it. -Jooby uses the *worker executor* to creates a coroutine context. As described in <> -this is provided by the web server implementation unless you provided your own. +Jooby uses the **worker executor** to create a coroutine context. This executor is provided by the web server implementation, unless you provide a custom one: -.Coroutines with custom executor: +.Coroutines with a custom executor: [source, kotlin] ---- { @@ -691,16 +680,13 @@ this is provided by the web server implementation unless you provided your own. } ---- -<1> Statement run in the *worker executor* (cached thread pool) -<2> Call a suspending function -<3> Produces a response - -Coroutines always run in the <>. There is an experimental API where -coroutines run in the *caller thread*(event loop in this case) until a suspending function is found. +<1> Statement runs in the worker executor (cached thread pool). +<2> Calls a suspending function. +<3> Produces a response. -Jooby allows you to use this experimental API by setting the `coroutineStart` option: +By default, Coroutines always run in the worker executor. However, Jooby provides an experimental API where coroutines run in the **caller thread** (the event loop) until a suspending function is found. You can enable this by setting the `coroutineStart` option: -.UNDISPATCHED +.UNDISPATCHED Start [source, kotlin] ---- { @@ -714,13 +700,13 @@ Jooby allows you to use this experimental API by setting the `coroutineStart` op } ---- -<1> Statement run in the *event loop* (caller thread) -<2> Call a suspending function and dispatch to *worker executor* -<3> Produces a response from *worker executor* +<1> Statement runs in the event loop (caller thread). +<2> Calls a suspending function and dispatches to the worker executor. +<3> Produces a response from the worker executor. -You can also extend the `CoroutineContext` in which the coroutine routes run: +You can also extend the `CoroutineContext` in which the routes run: -.launchContext +.Customizing the Context [source, kotlin] ---- { @@ -728,20 +714,17 @@ You can also extend the `CoroutineContext` in which the coroutine routes run: launchContext { MDCContext() } // <1> get("/") { - ... + // ... } } } ---- -<1> The lambda is run before launching each coroutine, so it can customize the `CoroutineContext` for -the request, e.g. store/restore MDC, transaction, or anything else that your handlers need. +<1> The `launchContext` lambda runs before launching each coroutine. It allows you to customize the `CoroutineContext` for the request (e.g., to store/restore MDC, transactions, or other request-scoped data). -{love} {love}! +==== Send Methods -=== Send methods - -Jooby provides a family of `send()` methods that produces a response via side effects. +Jooby provides a family of `send()` methods that produce a response via side-effects. .send text [source,java,role="primary"] @@ -763,24 +746,20 @@ Jooby provides a family of `send()` methods that produces a response via side ef } ---- -Beside we operate via side effects, the route still returns something. This is required because a route -handler is a function which always produces a result. - -All the send methods returns the current `Context`, this signal Jooby that we want to operate via -side effects ignoring the output of the route handler. +Although these methods operate via side-effects, the route handler must still return a value. All `send` methods return the current `Context`. Returning the context signals to Jooby that the response was already handled and the standard route output should be ignored. -Family of send methods include: +The family of send methods includes: -- javadoc:Context[send, byte[\]] -- javadoc:Context[send, byte[\]...] -- javadoc:Context[send, io.jooby.buffer.DataBuffer] -- javadoc:Context[send, io.jooby.FileDownload] -- javadoc:Context[send, io.jooby.StatusCode] -- javadoc:Context[send, java.io.InputStream] -- javadoc:Context[send, java.lang.String] -- javadoc:Context[send, java.lang.String, java.nio.charset.Charset] -- javadoc:Context[send, java.nio.ByteBuffer] -- javadoc:Context[send, java.nio.ByteBuffer[\]] -- javadoc:Context[send, java.nio.channels.FileChannel] -- javadoc:Context[send, java.nio.channels.ReadableByteChannel] -- javadoc:Context[send, java.nio.file.Path] +* javadoc:Context[send, byte[\]] +* javadoc:Context[send, byte[\]...] +* javadoc:Context[send, io.jooby.buffer.DataBuffer] +* javadoc:Context[send, io.jooby.FileDownload] +* javadoc:Context[send, io.jooby.StatusCode] +* javadoc:Context[send, java.io.InputStream] +* javadoc:Context[send, java.lang.String] +* javadoc:Context[send, java.lang.String, java.nio.charset.Charset] +* javadoc:Context[send, java.nio.ByteBuffer] +* javadoc:Context[send, java.nio.ByteBuffer[\]] +* javadoc:Context[send, java.nio.channels.FileChannel] +* javadoc:Context[send, java.nio.channels.ReadableByteChannel] +* javadoc:Context[send, java.nio.file.Path] diff --git a/docs/asciidoc/router-hidden-method.adoc b/docs/asciidoc/router-hidden-method.adoc index 6bcaa720b5..46cdc4d0b7 100644 --- a/docs/asciidoc/router-hidden-method.adoc +++ b/docs/asciidoc/router-hidden-method.adoc @@ -1,7 +1,6 @@ -==== Hidden Method +===== Hidden Method -The javadoc:Router[setHiddenMethod, java.lang.String] option allow clients to override the HTTP -method using a hidden form field. +The javadoc:Router[setHiddenMethod, java.lang.String] option allows clients to override the HTTP method. This is especially useful for HTML forms, which natively only support `GET` and `POST`. .Client [source, html] @@ -17,11 +16,10 @@ method using a hidden form field. import io.jooby.Jooby; ... { + setHiddenMethod("_method"); // <1> - setHiddenMethod("_method"); <1> - - put("/form", ctx -> { <2> - + put("/form", ctx -> { // <2> + return "Updated!"; }); } ---- @@ -32,28 +30,28 @@ import io.jooby.Jooby; import io.jooby.Jooby ... { - setHiddenMethod("_method") <1> + setHiddenMethod("_method") // <1> - put("/form", ctx -> { <2> - - }); + put("/form") { // <2> + "Updated!" + } } ---- -<1> Configure hidden method property to read the form field: `_method` -<2> Execute the put version of `/form` +<1> Configures the router to look for a form field named `_method`. +<2> Executes the `PUT` handler for `/form` instead of the standard `POST`. -The default implementation looks for a form field from POST form/multipart request. You can provide -a different strategy. +*(Note: I fixed a small bug in your Kotlin snippet where you were using Java lambda syntax inside the `put` route).* -.HTTP Header +The default implementation looks for the specified hidden field in `POST` forms or multipart requests. Alternatively, you can provide a custom strategy, such as reading an HTTP header: + +.HTTP Header Strategy [source, java, role = "primary"] ---- import io.jooby.Jooby; ... { - - setHiddenMethod(ctx -> ctx.header("X-HTTP-Method-Override").toOptional()); <1> + setHiddenMethod(ctx -> ctx.header("X-HTTP-Method-Override").toOptional()); // <1> } ---- @@ -63,8 +61,8 @@ import io.jooby.Jooby; import io.jooby.Jooby ... { - setHiddenMethod {ctx -> ctx.header("X-HTTP-Method-Override").toOptional()} <1> + setHiddenMethod { ctx -> ctx.header("X-HTTP-Method-Override").toOptional() } // <1> } ---- -<1> Look for a request header: `X-HTTP-Method-Override` +<1> Overrides the HTTP method by extracting it from the `X-HTTP-Method-Override` request header. diff --git a/docs/asciidoc/router-options.adoc b/docs/asciidoc/router-options.adoc index 971d1367fa..eb225c6169 100644 --- a/docs/asciidoc/router-options.adoc +++ b/docs/asciidoc/router-options.adoc @@ -1,6 +1,6 @@ -==== Routing +===== Routing -The javadoc:Router[setRouterOptions, io.jooby.RouterOptions] control routing options. +The javadoc:Router[setRouterOptions, io.jooby.RouterOptions] method controls routing behavior. .Usage [source, java, role = "primary"] @@ -8,15 +8,14 @@ The javadoc:Router[setRouterOptions, io.jooby.RouterOptions] control routing opt import io.jooby.Jooby; ... { - setRouterOptions(new RouterOptions() - .setIgnoreCase(true) - .setFailOnDuplicateRoutes(true) - .setIgnoreTrailingSlash(true) - .setNormalizeSlash(true) - .setResetHeadersOnError(false) - .setTrustProxy(true) - .setContextAsService(true) + .setIgnoreCase(true) + .setFailOnDuplicateRoutes(true) + .setIgnoreTrailingSlash(true) + .setNormalizeSlash(true) + .setResetHeadersOnError(false) + .setTrustProxy(true) + .setContextAsService(true) ); } ---- @@ -29,8 +28,8 @@ import io.jooby.Jooby { routerOptions = RouterOptions().apply { ignoreCase = true - ignoreTrailingSlash = true failOnDuplicateRoutes = true + ignoreTrailingSlash = true normalizeSlash = true resetHeadersOnError = false trustProxy = true @@ -39,50 +38,47 @@ import io.jooby.Jooby } ---- -[cols="1,1,1,1"] +[cols="1,1,1,4"] |=== -| Option | Value | Default Value| Description - -|contextAsService -|boolean -|false -|If enabled, allows to retrieve the javadoc:Context[] object associated with the current request via the service registry while the request is being processed. +| Option | Type | Default | Description -|ignoreCase -|boolean -|false -|Indicates whenever routing algorithm does case-sensitive matching on an incoming request path. +| `contextAsService` +| boolean +| false +| If enabled, allows you to retrieve the javadoc:Context[] object associated with the current request via the service registry while the request is being processed. -|ignoreTrailingSlash -|boolean -|false -|Indicates whenever a trailing slash is ignored on an incoming request path. +| `ignoreCase` +| boolean +| false +| Indicates whether the routing algorithm uses case-sensitive matching for incoming request paths. -|failOnDuplicateRoutes -|boolean -|false -|Indicates whenever a trailing slash is ignored on an incoming request path. +| `ignoreTrailingSlash` +| boolean +| false +| Indicates whether trailing slashes are ignored on incoming request paths. -|normalizeSlash -|boolean -|false -|Normalize an incoming request path by removing multiple slash sequences. +| `failOnDuplicateRoutes` +| boolean +| false +| Throws an exception if multiple routes are registered with the same HTTP method and path pattern. -|resetHeadersOnError -|boolean -|true -|Indicates whenever response headers are clear/reset in case of exception. +| `normalizeSlash` +| boolean +| false +| Normalizes incoming request paths by replacing multiple consecutive slashes with a single slash. -|trustProxy -|boolean -|false -|When true handles `X-Forwarded-*` headers by updating the values on the current context to match what was sent in the header(s). +| `resetHeadersOnError` +| boolean +| true +| Indicates whether response headers should be cleared/reset when an exception occurs. +| `trustProxy` +| boolean +| false +| When true, parses `X-Forwarded-*` headers and updates the current context to reflect the values sent by the proxy. |=== [IMPORTANT] ==== -trustProxy: This should only be installed behind a reverse proxy that has been configured to send the -`X-Forwarded-*` header, otherwise a remote user can spoof their address by sending a header with -bogus values. +**trustProxy:** This should only be enabled if your application is running behind a reverse proxy configured to send `X-Forwarded-*` headers. Otherwise, remote users can spoof their IP addresses and protocols by sending malicious headers. ==== diff --git a/docs/asciidoc/routing.adoc b/docs/asciidoc/routing.adoc index 45e4ce5828..347c94437f 100644 --- a/docs/asciidoc/routing.adoc +++ b/docs/asciidoc/routing.adoc @@ -1,21 +1,20 @@ -== Router +=== Router -The javadoc:Router[] is the heart of Jooby and consist of: +The javadoc:Router[] is the heart of Jooby and consists of: -- Routing algorithm (radix tree) -- One or more javadoc:Route[text="routes"] -- Collection of operator over javadoc:Route[text="routes"] +* A routing algorithm (radix tree) +* One or more javadoc:Route[text="routes"] +* A collection of operators over javadoc:Route[text="routes"] -=== Route +==== Route -A javadoc:Route[] consists of three part: +A javadoc:Route[] consists of three parts: .Routes: [source, java, role="primary"] ---- { - - // <1> <2> + // <1> <2> get("/foo", ctx -> { return "foo"; // <3> }); @@ -35,9 +34,8 @@ A javadoc:Route[] consists of three part: .Kotlin [source, kotlin, role="secondary"] ---- -{ - - // <1> <2> +{ + // <1> <2> get("/foo") { "foo" // <3> } @@ -54,16 +52,15 @@ A javadoc:Route[] consists of three part: } ---- -<1> HTTP method/verb, like: `GET`, `POST`, etc... -<2> Path pattern, like: `/foo`, `/foo/{id}`, etc... +<1> HTTP method/verb (e.g., `GET`, `POST`) +<2> Path pattern (e.g., `/foo`, `/foo/{id}`) <3> Handler function -The javadoc:Route.Handler[text="handler"] function always produces a result, which is send it back -to the client. +The javadoc:Route.Handler[text="handler"] function always produces a result, which is sent back to the client. -==== Attributes +===== Attributes -Attributes let you annotate a route at application bootstrap time. It functions like static metadata available at runtime: +Attributes allow you to annotate a route at application bootstrap time. They function as static metadata available at runtime: .Java [source, java, role="primary"] @@ -84,8 +81,7 @@ Attributes let you annotate a route at application bootstrap time. It functions } ---- -An attribute consist of a name and value. Values can be any object. -Attributes can be accessed at runtime in a request/response cycle. For example, a security module might check for a role attribute. +An attribute consists of a name and a value. Values can be any object. Attributes can be accessed during the request/response cycle. For example, a security module might check for a role attribute. .Java [source, java, role="primary"] @@ -108,20 +104,20 @@ Attributes can be accessed at runtime in a request/response cycle. For example, [source, kotlin, role="secondary"] ---- { -use( - val user = ... - val role = ctx.route.getAttribute("Role") - - if (user.hasRole(role)) { - return next.apply(ctx) - } else { - throw StatusCodeException(StatusCode.FORBIDDEN) + use { + val user = ... + val role = ctx.route.getAttribute("Role") + + if (user.hasRole(role)) { + next.apply(ctx) + } else { + throw StatusCodeException(StatusCode.FORBIDDEN) + } } } ---- - -In MVC routes you can set attributes via annotations: +In MVC routes, you can set attributes via annotations: .Java [source, java, role="primary"] @@ -173,17 +169,16 @@ class AdminResource { } ---- -The previous example will print: admin. -You can retrieve all the attributes of the route by calling `ctx.getRoute().getAttributes()`. +The previous example prints `admin`. You can retrieve all attributes of a route by calling `ctx.getRoute().getAttributes()`. -Any runtime annotation is automatically added as route attributes following these rules: -- If the annotation has a value method, then we use the annotation’s name as the attribute name. -- Otherwise, we use the method name as the attribute name. +Any runtime annotation is automatically added as a route attribute following these rules: +* If the annotation has a `value` method, the annotation’s name becomes the attribute name. +* Otherwise, the method name is used as the attribute name. -=== Path Pattern +==== Path Pattern -==== Static +===== Static .Java [source, java, role="primary"] @@ -201,7 +196,7 @@ Any runtime annotation is automatically added as route attributes following thes } ---- -==== Variable +===== Variable .Single path variable: [source, java, role="primary"] @@ -227,8 +222,8 @@ Any runtime annotation is automatically added as route attributes following thes } ---- -<1> Defines a path variable `id` -<2> Retrieve the variable `id` as `int` +<1> Defines a path variable `id`. +<2> Retrieves the variable `id` as an `int`. .Multiple path variables: [source, java,role="primary"] @@ -256,9 +251,9 @@ Any runtime annotation is automatically added as route attributes following thes } ---- -<1> Defines two path variables: `file` and `ext` -<2> Retrieve string variable: `file` -<3> Retrieve string variable: `ext` +<1> Defines two path variables: `file` and `ext`. +<2> Retrieves the string variable `file`. +<3> Retrieves the string variable `ext`. .Optional path variable: [source, java, role="primary"] @@ -284,15 +279,15 @@ Any runtime annotation is automatically added as route attributes following thes } ---- -<1> Defines an optional path variable `id`. The trailing `?` make it optional. -<2> Retrieve the variable `id` as `String` when present or use a default value: `self`. +<1> Defines an optional path variable `id`. The trailing `?` makes it optional. +<2> Retrieves the variable `id` as a `String` if present, or uses the default value: `self`. -The trailing `?` makes the path variable optional. The route matches: +The trailing `?` makes the path variable optional. The route matches both: -- `/profile` -- `/profile/eespina` +* `/profile` +* `/profile/eespina` -==== Regex +===== Regex .Regex path variable: [source, java, role="primary"] @@ -318,17 +313,17 @@ The trailing `?` makes the path variable optional. The route matches: }` ---- -<1> Defines a path variable: `id`. Regex expression is everything after the first `:`, like: `[0-9]+` -<2> Retrieve an int value +<1> Defines a path variable `id`. The regex expression is everything after the first `:` (e.g., `[0-9]+`). +<2> Retrieves the `int` value. -Optional syntax is also supported for regex path variable: `/user/{id:[0-9]+}?`: +The optional syntax is also supported for regex path variables (e.g., `/user/{id:[0-9]+}?`). This matches: -- matches `/user` -- matches `/user/123` +* `/user` +* `/user/123` -==== * Catchall +===== * Catchall -.catchall +.Catchall: [source, java, role="primary"] ---- { @@ -362,29 +357,24 @@ Optional syntax is also supported for regex path variable: `/user/{id:[0-9]+}?`: } ---- -<1> The trailing `*` defines a `catchall` pattern -<2> We access to the `catchall` value using the `*` character -<3> Same example, but this time we named the `catchall` pattern and we access to it using `path` -variable name. +<1> The trailing `*` defines a `catchall` pattern. +<2> We access the `catchall` value using the `*` character. +<3> In this example, we named the `catchall` pattern and access it using the `path` variable name. [NOTE] ==== A `catchall` pattern must be defined at the end of the path pattern. ==== - -=== Handler -Application logic goes inside a javadoc:Route.Handler[text=handler]. A -javadoc:Route.Handler[text=handler] is a function that accepts a javadoc:Context[text=context] -object and produces a `result`. +==== Handler + +Application logic belongs inside a javadoc:Route.Handler[text=handler]. A javadoc:Route.Handler[text=handler] is a function that accepts a javadoc:Context[text=context] object and produces a result. -A javadoc:Context[text=context] allows you to interact with the `HTTP Request` and manipulate the -`HTTP Response`. +A javadoc:Context[text=context] allows you to interact with the HTTP request and manipulate the HTTP response. [NOTE] ==== -Incoming request matches exactly **ONE** route handler. If there is no handler, produces a `404` -response. +An incoming request matches exactly **ONE** route handler. If no handler matches, it produces a `404` response. ==== .Java @@ -419,16 +409,14 @@ Output: <1> `GET /user/ppicapiedra` => `ppicapiedra` <2> `GET /user/me` => `my profile` -<3> Unreachable => override it by next route -<4> `GET /users` => `new users` not `users` +<3> Unreachable => overridden by the next route. +<4> `GET /users` => `new users` (not `users`). -Routes with most specific path pattern (`2` vs `1`) has more precedence. Also, is one or more routes -result in the same path pattern, like `3` and `4`, last registered route hides/overrides previous route. +Routes with a more specific path pattern (`2` vs `1`) have higher precedence. Also, if multiple routes share the same path pattern (like `3` and `4`), the last registered route overrides the previous ones. -==== Filter +===== Filter -Cross cutting concerns such as response modification, verification, security, tracing, etc. is available -via javadoc:Route.Filter[]. +Cross-cutting concerns such as response modification, verification, security, and tracing are available via javadoc:Route.Filter[]. A `filter` takes the `next` handler in the pipeline and produces a `new` handler: @@ -486,22 +474,21 @@ interface Filter { } ---- -<1> Saves start time -<2> Proceed with execution (pipeline) -<3> Compute and print latency -<4> Returns a response +<1> Saves the start time. +<2> Proceeds with execution (the pipeline). +<3> Computes and prints latency. +<4> Returns a response. [NOTE] ==== -One or more filter on top of a handler produces a new handler. +One or more filters applied on top of a handler produce a new handler. ==== -==== Before +===== Before The javadoc:Route.Before[text=before] filter runs before a `handler`. -A `before` filter takes a `context` as argument and don't produces a response. It expected to operates -via side effects (usually modifying the HTTP response). +A `before` filter takes a `context` as an argument and does not produce a response. It is expected to operate via side effects (usually modifying the HTTP request/response). [source,java] ---- @@ -538,16 +525,13 @@ interface Before { } ---- -==== After +===== After The javadoc:Route.After[text=after] filter runs after a `handler`. -An `after` filter takes three arguments. The first argument is the `HTTP context`, the second -argument is the result/response from a **functional handler** or `null` for **side-effects** handler, -the third and last argument is an exception generates from handler. +An `after` filter takes three arguments. The first is the `HTTP context`, the second is the result from a **functional handler** (or `null` for a **side-effect** handler), and the third is any exception generated by the handler. -It expected to operates via side effects, usually modifying the HTTP response (if possible) or -for cleaning/trace execution. +It is expected to operate via side effects, usually modifying the HTTP response (if possible) or cleaning up/tracing execution. [source,java] ---- @@ -561,8 +545,8 @@ interface After { ---- { after((ctx, result, failure) -> { - System.out.println(result); <1> - ctx.setResponseHeader("foo", "bar"); <2> + System.out.println(result); // <1> + ctx.setResponseHeader("foo", "bar"); // <2> }); get("/", ctx -> { @@ -576,8 +560,8 @@ interface After { ---- { after { - println("Hello $result") <1> - ctx.setResponseHeader("foo", "bar") <2> + println("Hello $result") // <1> + ctx.setResponseHeader("foo", "bar") // <2> } get("/") { @@ -586,20 +570,20 @@ interface After { } ---- -<1> Prints `Jooby` -<2> Add a response header (modifies the HTTP response) +<1> Prints `Jooby`. +<2> Adds a response header (modifies the HTTP response). -If the target handler is a **functional** handler modification of HTTP response is allowed it. +If the target handler is a **functional** handler, modifying the HTTP response is allowed. -For **side effects** handler the after filter is invoked with a `null` value and isn't allowed to modify the HTTP response. +For **side-effect** handlers, the after filter is invoked with a `null` result and is not allowed to modify the HTTP response. .Side-Effect Handler: [source,java,role="primary"] ---- { after((ctx, result, failure) -> { - System.out.println(result); <1> - ctx.setResponseHeader("foo", "bar"); <2> + System.out.println(result); // <1> + ctx.setResponseHeader("foo", "bar"); // <2> }); get("/", ctx -> { @@ -613,8 +597,8 @@ For **side effects** handler the after filter is invoked with a `null` value and ---- { after { - println("Hello $result") <1> - ctx.setResponseHeader("foo", "bar") <2> + println("Hello $result") // <1> + ctx.setResponseHeader("foo", "bar") // <2> } get("/") { @@ -623,14 +607,12 @@ For **side effects** handler the after filter is invoked with a `null` value and } ---- -<1> Prints `null` (no value) -<2> Produces an error/exception - -Exception occurs because response was already started and its impossible to alter/modify it. +<1> Prints `null` (no value). +<2> Produces an error/exception. -Side-effects handler are all that make use of family of send methods, responseOutputStream and responseWriter. +An exception occurs here because the response was already started, and it is impossible to alter it. Side-effect handlers are those that use the `send` methods, `responseOutputStream`, or `responseWriter`. -You can check whenever you can modify the response by checking the state of javadoc:Context[isResponseStarted]: +You can check whether you can modify the response by checking the state of javadoc:Context[isResponseStarted]: .Safe After: [source,java,role="primary"] @@ -638,7 +620,7 @@ You can check whenever you can modify the response by checking the state of java { after((ctx, result, failure) -> { if (ctx.isResponseStarted()) { - // Don't modify response + // Do not modify response } else { // Safe to modify response } @@ -651,8 +633,8 @@ You can check whenever you can modify the response by checking the state of java ---- { after { - if (ctx.responseStarted) { - // Don't modify response + if (ctx.isResponseStarted) { + // Do not modify response } else { // Safe to modify response } @@ -662,22 +644,20 @@ You can check whenever you can modify the response by checking the state of java [NOTE] ==== -An after handler is always invoked. +An `after` handler is always invoked. ==== -The next examples demonstrate some use cases for dealing with errored responses, but keep in mind -that an after handler is not a mechanism for handling and reporting exceptions that's is a task -for an <>. +The next examples demonstrate some use cases for dealing with errored responses. Keep in mind that an `after` handler is not a mechanism for handling and reporting exceptions; that is the task of an <>. -.Run code depending of success or failure responses: +.Run code depending on success or failure responses: [source,java,role="primary"] ---- { after((ctx, result, failure) -> { if (failure == null) { - db.commit(); <1> + db.commit(); // <1> } else { - db.rollback(); <2> + db.rollback(); // <2> } }); } @@ -689,23 +669,23 @@ for an <>. { after { if (failure == null) { - db.commit() <1> + db.commit() // <1> } else { - db.rollback() <2> + db.rollback() // <2> } } } ---- -Here the exception is still propagated given the chance to the <> to jump in. +Here, the exception is still propagated, giving the <> a chance to jump in. -.Recover fom exception and produces an alternative output: +.Recover from an exception and produce an alternative output: [source,java,role="primary"] ---- { after((ctx, result, failure) -> { - if (failure instanceOf MyBusinessException) { - ctx.send("Recovering from something"); <1> + if (failure instanceof MyBusinessException) { + ctx.send("Recovering from something"); // <1> } }); } @@ -717,35 +697,35 @@ Here the exception is still propagated given the chance to the < + ctx.send("Recovering from something") // <1> } } } ---- -<1> Recover and produces an alternative output +<1> Recovers and produces an alternative output. -Here the exception wont be propagated due we produces a response, so error handler won't be execute it. +Here, the exception won't be propagated because we produce a response, meaning the error handler will not execute. -In case where the after handler produces a new exception, that exception will be add to the original exception as suppressed exception. +If the `after` handler produces a new exception, that exception will be added to the original exception as a suppressed exception. .Suppressed exceptions: [source,java,role="primary"] ---- { after((ctx, result, failure) -> { - ... + // ... throw new AnotherException(); }); get("/", ctx -> { - ... + // ... throw new OriginalException(); }); error((ctx, failure, code) -> { - Throwable originalException = failure; <1> - Throwable anotherException = failure.getSuppressed()[0]; <2> + Throwable originalException = failure; // <1> + Throwable anotherException = failure.getSuppressed()[0]; // <2> }); } ---- @@ -755,43 +735,42 @@ In case where the after handler produces a new exception, that exception will be ---- { after { - ... - throw AnotherException(); + // ... + throw AnotherException() } get("/") { ctx -> - ... + // ... throw OriginalException() } - error { ctx, failure, code) -> - val originalException = failure <1> - val anotherException = failure.getSuppressed()[0] <2> + error { ctx, failure, code -> + val originalException = failure // <1> + val anotherException = failure.suppressed[0] // <2> } } ---- -<1> Will be `OriginalException` -<2> Will be `AnotherException` +<1> Will be `OriginalException`. +<2> Will be `AnotherException`. -==== Complete +===== Complete -The javadoc:Route.Complete[text=complete] listener run at the completion of a request/response cycle -(i.e. when the request has been completely read, and the response has been fully written). +The javadoc:Route.Complete[text=complete] listener runs at the completion of a request/response cycle (i.e., when the request has been completely read and the response fully written). -At this point it is too late to modify the exchange further. They are attached to a running context -(not like a filter/before/after filters). +At this point, it is too late to modify the exchange. They are attached to a running context (unlike `before`/`after` filters). .Example [source, java, role="primary"] ---- { use(next -> ctx -> { - long start = System.currentTimeInMillis(); - ctx.onComplete(context -> { <1> - long end = System.currentTimeInMillis(); <2> + long start = System.currentTimeMillis(); + ctx.onComplete(context -> { // <1> + long end = System.currentTimeMillis(); // <2> System.out.println("Took: " + (end - start)); }); + return next.apply(ctx); }); } ---- @@ -802,22 +781,23 @@ At this point it is too late to modify the exchange further. They are attached t { use { val start = System.currentTimeMillis() - ctx.onComplete { <1> - val end = System.currentTimeMillis() <2> + ctx.onComplete { // <1> + val end = System.currentTimeMillis() // <2> println("Took: " + (end - start)) } + next.apply(ctx) } } ---- -<1> Attach a completion listener -<2> Run after response has been fully written +<1> Attaches a completion listener. +<2> Runs after the response has been fully written. Completion listeners are invoked in reverse order. -=== Pipeline +==== Pipeline -Route pipeline (a.k.a route stack) is a composition of one or more use(s) tied to a single `handler`: +The route pipeline (a.k.a. route stack) is a composition of one or more `use` statements tied to a single `handler`: .Java [source, java, role="primary"] @@ -868,7 +848,7 @@ Output: <1> `/1` => `3` <2> `/2` => `4` -Behind the scene, Jooby builds something like: +Behind the scenes, Jooby builds something like this: [source, java] ---- @@ -892,17 +872,14 @@ Behind the scene, Jooby builds something like: } ---- -Any `filter` defined on top of the handler will be stacked/chained into a new handler. +Any `filter` defined on top of the handler will be chained into a new handler. [NOTE] .Filter without path pattern ==== +This was a hard decision, but it is the right one. Jooby 1.x used a path pattern to define a `filter`. -This was a hard decision to make, but we know is the right one. Jooby 1.x uses a path pattern to -define `filter`. - -The `pipeline` in Jooby 1.x consists of multiple filters and handlers. They are match -sequentially one by one. The following `filter` is always executed in Jooby 1.x +In Jooby 1.x, the `pipeline` consisted of multiple filters and handlers matched sequentially. The following `filter` was always executed: .Jooby 1.x [source, java] @@ -916,18 +893,14 @@ sequentially one by one. The following `filter` is always executed in Jooby 1.x } ---- -Suppose there is a bot trying to access and causing lot of `404` responses (path doesn't exist). -In Jooby 1.x the `filter` is executed for every single request sent by the bot just to realize -there is NO matching route and all we need is a `404`. - -In Jooby 2.x this won't happen anymore. If there is a matching handler, the `pipeline` will be -executed. Otherwise, nothing will do! +If a bot tried to access missing pages (causing `404`s), Jooby 1.x executed the filter for every single request before realizing there was no matching route. +In Jooby 2.x+, this no longer happens. The `pipeline` is only executed if there is a matching handler. Otherwise, nothing is executed! ==== -==== Order +===== Order -Order follows the **what you see is what you get** approach. Routes are stacked in the way they were added/defined. +Order follows a **what you see is what you get** approach. Routes are stacked in the order they are defined. .Order example: [source, java, role="primary"] @@ -976,14 +949,13 @@ Order follows the **what you see is what you get** approach. Routes are stacked Output: <1> `/1` => `2` -<2> `/2` => `4` +<2> `/2` => `4` -==== Scoped Filter +===== Scoped Filter -The javadoc:Router[routes, java.lang.Runnable] and javadoc:Router[path, java.lang.String, java.lang.Runnable] operators -are used to group one or more routes. - -A `scoped filter` looks like: +The javadoc:Router[routes, java.lang.Runnable] and javadoc:Router[path, java.lang.String, java.lang.Runnable] operators group one or more routes. + +A `scoped filter` looks like this: .Scoped filter: [source, java, role="primary"] @@ -1035,25 +1007,22 @@ A `scoped filter` looks like: Output: -<1> Introduce a new scope via `routes` operator +<1> Introduces a new scope via the `routes` operator. <2> `/4` => `9` <3> `/1` => `2` -It is a normal `filter` inside of one of the group operators. +It functions as a normal `filter` inside a group operator. -=== Grouping routes +==== Grouping Routes -As showed previously, the javadoc:Router[routes, java.lang.Runnable] operator push a new route `scope` -and allows you to selectively apply one or more routes. +As shown previously, the javadoc:Router[routes, java.lang.Runnable] operator pushes a new route `scope` and allows you to selectively apply logic to one or more routes. .Route operator [source,java,role="primary"] ---- { routes(() -> { - get("/", ctx -> "Hello"); - }); } ---- @@ -1063,33 +1032,26 @@ and allows you to selectively apply one or more routes. ---- { routes { - get("/") { "Hello" } - } } ---- -Route operator is for grouping one or more routes and apply cross cutting concerns to all them. +The `routes` operator is for grouping routes and applying cross-cutting concerns to all of them. -In similar fashion the javadoc:Router[path, java.lang.String, java.lang.Runnable] operator groups -one or more routes under a common path pattern. +Similarly, the javadoc:Router[path, java.lang.String, java.lang.Runnable] operator groups routes under a common path pattern. .Routes with path prefix: [source,java,role="primary"] ---- { path("/api/user", () -> { // <1> - get("/{id}", ctx -> ...); // <2> - get("/", ctx -> ...); // <3> - post("/", ctx -> ...); // <4> - - ... + // ... }); } ---- @@ -1099,31 +1061,24 @@ one or more routes under a common path pattern. ---- { path("/api/user") { // <1> - get("/{id}") { ...} // <2> - get("/") { ...} // <3> - post("/") { ...} // <4> - - ... + // ... }); } ---- -<1> Set common prefix `/api/user` +<1> Sets the common prefix `/api/user`. <2> `GET /api/user/{id}` <3> `GET /api/user` <4> `POST /api/user` -=== Composing +==== Composing -==== Mount +===== Mount -Composition is a technique for building modular applications. You can compose one or more -router into a new one. - -Composition is available through the javadoc:Router[mount, io.jooby.Router] operator: +Composition is a technique for building modular applications. You can compose one or more routers into a new one using the javadoc:Router[mount, io.jooby.Router] operator: .Composing [source, java, role="primary"] @@ -1143,9 +1098,7 @@ public class Bar extends Jooby { public class App extends Jooby { { mount(new Foo()); // <1> - mount(new Bar()); // <2> - get("/app", Context::getRequestPath); // <3> } } @@ -1155,32 +1108,23 @@ public class App extends Jooby { [source, kotlin,role="secondary"] ---- class Foo: Kooby({ - get("/foo") { ctx.getRequestPath() } - }) class Bar: Kooby({ - get("/bar") { ctx.getRequestPath() } - }) class App: Kooby({ mount(Foo()) // <1> - mount(Bar()) // <2> - get("/app") { ctx.getRequestPath() } // <3> }) ---- <1> Imports all routes from `Foo`. Output: `/foo` => `/foo` - <2> Imports all routes from `Bar`. Output: `/bar` => `/bar` - -<3> Add more routes . Output `/app` => `/app` - +<3> Adds more routes. Output: `/app` => `/app` .Composing with path prefix [source,java,role="primary"] @@ -1202,27 +1146,21 @@ public class App extends Jooby { [source,kotlin,role="secondary"] ---- class Foo: Kooby({ - get("/foo") { ctx.getRequestPath() } - }) class App: Kooby({ - mount("/prefix", Foo()) // <1> - }) ---- <1> Now all routes from `Foo` will be prefixed with `/prefix`. Output: `/prefix/foo` => `/prefix/foo` -The mount operator only import routes. Services, callbacks, etc... are not imported. Main application -is responsible for assembly all the resources and services required by imported applications. +The `mount` operator only imports routes. Services and callbacks are not imported. The main application is responsible for assembling all resources and services required by the imported applications. -==== Install +===== Install -Alternatively, you can install a standalone application into another one using the -javadoc:Jooby[install, io.jooby.SneakyThrows.Supplier] operator: +Alternatively, you can install a standalone application into another one using the javadoc:Jooby[install, io.jooby.SneakyThrows.Supplier] operator: .Installing [source, java, role="primary"] @@ -1242,7 +1180,6 @@ public class Bar extends Jooby { public class App extends Jooby { { install(Foo::new); // <1> - install(Bar::new); // <2> } } @@ -1252,31 +1189,25 @@ public class App extends Jooby { [source, kotlin,role="secondary"] ---- class Foo: Kooby({ - get("/foo") { ... } - }) class Bar: Kooby({ - get("/bar") { ... } - }) class App: Kooby({ install(::Foo) // <1> - install(::Bar) // <2> }) ---- -<1> Imports all routes, services, callbacks, etc... from `Foo`. Output: `/foo` => `/foo` +<1> Imports all routes, services, callbacks, etc. from `Foo`. Output: `/foo` => `/foo` +<2> Imports all routes, services, callbacks, etc. from `Bar`. Output: `/bar` => `/bar` -<2> Imports all routes, services, callbacks, etc... from `Bar`. Output: `/bar` => `/bar` +This operator lets you, for example, deploy `Foo` as a standalone application or integrate it into a main application. -This operator lets you for example to deploy `Foo` as a standalone application or integrate it into a main one called `App3508`. -The install operator shares the state of the main application, so lazy initialization (and therefore _instantiation_) of -any child applications is *mandatory*. +The `install` operator shares the state of the main application, so lazy initialization (and therefore _instantiation_) of any child applications is **mandatory**. For example, this won't work: @@ -1289,7 +1220,7 @@ For example, this won't work: } ---- -The `Foo` application must be lazy initialized: +The `Foo` application must be lazily initialized: .Java [source, java] @@ -1299,13 +1230,11 @@ The `Foo` application must be lazy initialized: } ---- +==== Dynamic Routing -=== Dynamic Routing - -Dynamic routing looks similar to <> but enables/disables routes at runtime -using a `predicate`. +Dynamic routing looks similar to <> but enables/disables routes at runtime using a `predicate`. -Suppose you own two versions of an `API` and for some time you need to support both the `old` and `new` APIs: +Suppose you own two versions of an `API` and need to support both the `old` and `new` versions concurrently based on a header: .Dynamic Routing [source,java,role="primary"] @@ -1325,7 +1254,6 @@ public class V2 extends Jooby { public class App extends Jooby { { mount(ctx -> ctx.header("version").value().equals("v1"), new V1()); // <1> - mount(ctx -> ctx.header("version").value().equals("v2"), new V2()); // <2> } } @@ -1335,23 +1263,16 @@ public class App extends Jooby { [source,kotlin,role="secondary"] ---- class V1: Kooby({ - get("/api") { "v1" } - }) class V2: Kooby({ - get("/api") { "v2" } - }) class App: Kooby({ - mount(ctx -> ctx.header("version").value().equals("v1"), V1()); // <1> - mount(ctx -> ctx.header("version").value().equals("v2"), V2()); // <2> - }) ---- @@ -1360,12 +1281,11 @@ Output: <1> `/api` => `v1`; when `version` header is `v1` <2> `/api` => `v2`; when `version` header is `v2` -Done {love}! +Done! {love} -=== Multiple routers +==== Multiple Routers -This model let you `run` multiple applications on single server instance. Each application -works like a standalone application, they don't share any kind of services. +This model lets you run multiple applications on a single server instance. Each application works like a standalone application; they do not share any services. .Multiple routers [source, java,role="primary"] @@ -1396,7 +1316,6 @@ public class MultiApp { .Kotlin [source, kotlin,role="secondary"] ---- - import io.jooby.kt.Kooby.runApp fun main(args: Array) { @@ -1404,15 +1323,14 @@ fun main(args: Array) { } ---- -You write your application as always and them you deploy them using the `runApp` method. +You write your application as usual and then deploy them using the `runApp` method. [IMPORTANT] ==== -Due to nature of logging framework (static loading and initialization) the logging bootstrap -might not work as you expected. It is recommend to use just the `logback.xml` or `log4j.xml` file. +Due to the nature of logging frameworks (static loading and initialization), logging bootstrap might not work as expected. It is recommended to just use the `logback.xml` or `log4j2.xml` file. ==== -=== Options +==== Options include::router-options.adoc[] diff --git a/docs/asciidoc/server-sent-event.adoc b/docs/asciidoc/server-sent-event.adoc index 2a1946731f..d9ec23a4fb 100644 --- a/docs/asciidoc/server-sent-event.adoc +++ b/docs/asciidoc/server-sent-event.adoc @@ -1,10 +1,6 @@ -== Server-Sent Events +=== Server-Sent Events -https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events[Server-sent Events] (SSE) is a -mechanism that allows the server to push data to the client once the client-server connection -is established. After the connection has been established by the client, the server can send to -the client whenever a new chunk of data is available. In contrast with websockets, SSE can only -be used to send from the server to the client and not the other way round. +https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events[Server-Sent Events] (SSE) is a mechanism that allows the server to push data to the client once a connection is established. Unlike WebSockets, SSE is strictly unidirectional: the server can send data to the client, but not the other way around. .Server-Sent Events [source,java,role="primary"] @@ -26,14 +22,14 @@ be used to send from the server to the client and not the other way round. } ---- -<1> Connection established -<2> Send a message +<1> Connection established. +<2> Send a message to the client. -=== Message Options +==== Message Options -Additional message options are available via javadoc:ServerSentMessage[]: +Additional message properties (like custom events, IDs, and retry timeouts) are available via the javadoc:ServerSentMessage[] class: -.Server-Sent Events +.Server-Sent Message [source,java,role="primary"] ---- { @@ -41,7 +37,7 @@ Additional message options are available via javadoc:ServerSentMessage[]: sse.send( new ServerSentMessage("...") .setEvent("myevent") - .setId(myId) + .setId("myId") .setRetry(1000) ); }); @@ -53,22 +49,20 @@ Additional message options are available via javadoc:ServerSentMessage[]: ---- { sse("/sse") { - sse.send(ServerSentMessaage("...").apply { + sse.send(ServerSentMessage("...").apply { event = "myevent" - id = myId + id = "myId" retry = 1000 }) } } ---- -Options are documented at https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format[Event stream format]. +For details on how these options are interpreted by the browser, see the MDN documentation on the https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format[Event stream format]. -=== Connection Lost +==== Connection Lost -The `sse.onClose(Runnable)` callback allows you to clean and release resources on connection close. -A connection is closed when you call the sse.close() method or when the remote client closes the -connection. +The `sse.onClose(Runnable)` callback allows you to clean up and release resources when the connection ends. A connection is considered closed when you explicitly call `sse.close()` or when the remote client disconnects. .Connection Lost [source,java,role="primary"] @@ -76,7 +70,7 @@ connection. { sse("/sse", sse -> { sse.onClose(() -> { - // clean up + // Clean up resources }); }); } @@ -88,22 +82,22 @@ connection. { sse("/sse") { sse.onClose { - // clean up + // Clean up resources } } } ---- -=== Keep Alive +==== Keep Alive -The keep alive time feature can be used to prevent connections from timing out: +You can use the keep-alive feature to prevent idle connections from timing out or being dropped by intermediate proxies: .Keep Alive [source,java,role="primary"] ---- { sse("/sse", sse -> { - sse.keepAlive(15, TimeUnit.SECONDS) + sse.keepAlive(15, TimeUnit.SECONDS); }); } ---- @@ -118,10 +112,6 @@ The keep alive time feature can be used to prevent connections from timing out: } ---- -The previous example will send a ':' message (empty comment) every 15 seconds to keep the -connection alive. If the client drops the connection, then the `sse.onClose(Runnable)` event will -be fired. +This example sends a `:` message (an empty SSE comment) every 15 seconds to keep the connection active. If the client drops the connection, the `sse.onClose` event will be fired. -This feature is useful when you want to detect close events without waiting for the next time you -send an event. If on the other hand your application already generates events every 15 seconds, -the use of keep alive is unnecessary. +This feature is especially useful for quickly detecting closed connections without having to wait until your application tries to send a real event. (However, if your application already pushes data frequently—e.g., every few seconds—enabling `keepAlive` is generally unnecessary). diff --git a/docs/asciidoc/servers.adoc b/docs/asciidoc/servers.adoc index 844b18b826..854b073233 100644 --- a/docs/asciidoc/servers.adoc +++ b/docs/asciidoc/servers.adoc @@ -1,86 +1,75 @@ -== Server +=== Server -There are three server implementations: +Jooby supports multiple web server implementations. A server is automatically registered based on its presence on the project classpath. -- javadoc:jetty.JettyServer[artifact=jooby-jetty] -- javadoc:netty.NettyServer[artifact=jooby-netty] -- javadoc:vertx.VertxServer[artifact=jooby-vertx] -- javadoc:undertow.UndertowServer[artifact=jooby-undertow] +Officially supported servers: -Servers are automatically registered based on their presence on the project classpath. +* javadoc:jetty.JettyServer[artifact=jooby-jetty] +* javadoc:netty.NettyServer[artifact=jooby-netty] +* javadoc:undertow.UndertowServer[artifact=jooby-undertow] +* javadoc:vertx.VertxServer[artifact=jooby-vertx] -To use Jetty, add the dependency: - -[dependency, artifactId="jooby-jetty"] -. - -To use Netty, add the dependency: +To use a specific server, add the corresponding dependency to your project: [dependency, artifactId="jooby-netty"] . -To use Undertow, add the dependency: - -[dependency, artifactId="jooby-undertow"] -. - -To use Vertx, add the dependency: - -[dependency, artifactId="jooby-vertx"] - -. - -The javadoc:vertx.VertxServer[] setup is fully described link:{uiVersion}/modules/vertx#vertx-server-advanced[here]. - [IMPORTANT] ==== -Only one server dependency must be available on classpath. +Only **one** server dependency should be available on the classpath at a time. ==== -=== Run multiple apps +==== Manual Setup + +While Jooby usually loads the server automatically via the `ServiceLoader` API, you can also instantiate and configure a server manually in your `main` method. -All the code examples show how to run a single application on server. Servers allow running multiple -applications. +This is particularly useful if you need to access server-specific features, such as configuring **Project Loom (Virtual Threads)** in Jetty: -.Server +.Jetty with Virtual Threads [source,java,role="primary"] ---- - -import io.jooby.netty.NettyServer; +import io.jooby.jetty.JettyServer; +import java.util.concurrent.Executors; +import org.eclipse.jetty.util.thread.QueuedThreadPool; public static void main(String[] args) { - runApp(args, new NettyServer(), List.of(FooApp::new, BarApp::new)); + var worker = new QueuedThreadPool(); + worker.setReservedThreads(0); + worker.setVirtualThreadsExecutor(Executors.newVirtualThreadPerTaskExecutor()); + + runApp(args, new JettyServer(worker), App::new); } ---- .Kotlin [source,kotlin,role="secondary"] ---- -import io.jooby.netty.NettyServer +import io.jooby.jetty.JettyServer +import java.util.concurrent.Executors +import org.eclipse.jetty.util.thread.QueuedThreadPool fun main(args: Array) { - runApp(args, NettyServer(), ::FooApp, ::BarApp) + val worker = QueuedThreadPool().apply { + reservedThreads = 0 + virtualThreadsExecutor = Executors.newVirtualThreadPerTaskExecutor() + } + + runApp(args, JettyServer(worker), ::App) } ---- -Some things to keep in mind: - -- Application configuration file must NOT define server properties more than once. If they do, the -first setup wins. -- Log configuration file must be provided by command line or system property. - -=== Manual setup +==== Running Multiple Apps -Servers are automatically loaded using `ServiceLoader` API: +Jooby servers can run multiple applications simultaneously. -.Server +.Multi-App Server [source,java,role="primary"] ---- - import io.jooby.netty.NettyServer; +import java.util.List; public static void main(String[] args) { - runApp(args, new NettyServer(), App::new); + runApp(args, new NettyServer(), List.of(FooApp::new, BarApp::new)); } ---- @@ -90,77 +79,30 @@ public static void main(String[] args) { import io.jooby.netty.NettyServer fun main(args: Array) { - runApp(args, NettyServer(), ::App) -} ----- - -Server might or might not provide way to configure it (depends on Server). For example `JettyServer` -let you customize the `ThreadPool`, here is an example to setup Jetty with Loom(Virtual Threads): - -.Jetty Loom -[source,java,role="primary"] ----- - -import io.jooby.jetty.JettyServer; - -import java.util.concurrent.Executors; - -import org.eclipse.jetty.util.thread.QueuedThreadPool; - -public static void main(String[] args) { - // Use virtual Thread in Java 19 with --enable-preview - var worker = new QueuedThreadPool(); - worker.setReservedThreads(0); - worker.setVirtualThreadsExecutor(Executors.newVirtualThreadPerTaskExecutor()); - - runApp(args, new JettyServer(worker), App::new); + runApp(args, NettyServer(), ::FooApp, ::BarApp) } ---- -.Kotlin -[source,kotlin,role="secondary"] ----- - -import io.jooby.jetty.JettyServer - -import java.util.concurrent.Executors - -import org.eclipse.jetty.util.thread.QueuedThreadPool - - -fun main(args: Array) { - // Use virtual Thread in Java 19 with --enable-preview - val worker = QueuedThreadPool() - worker.reservedThreads = 0 - worker.virtualThreadsExecutor = Executors.newVirtualThreadPerTaskExecutor() - - runApp(args, JettyServer(worker), ::App) -} ----- +[NOTE] +==== +When running multiple apps, the server configuration (ports, threads, etc.) is determined by the first application setup that defines them. +==== -=== Options +==== Server Options -Server options are available via javadoc:ServerOptions[] class: +Server behavior can be controlled via the javadoc:ServerOptions[] class or through `application.conf`. .Server Options [source,java,role="primary"] ---- { var options = new ServerOptions() - .setOutput(OutputOptions.defaults()) - .setCompressionLevel(6) .setPort(8080) .setIoThreads(16) .setWorkerThreads(64) .setGzip(false) - .setSingleLoop(false) - .setDefaultHeaders(true) - .setMaxRequestSize(10485760) - .setSecurePort(8433) - .setSsl(SslOptions.selfSigned()) - .setHttpsOnly(false) - .setHttp2(true) - .setExpectContinue(true); + .setMaxRequestSize(10485760) // 10MB + .setHttp2(true); } ---- @@ -168,84 +110,38 @@ Server options are available via javadoc:ServerOptions[] class: [source,kotlin,role="secondary"] ---- { - val options = ServerOptions().appy { - output.size = 16384 - output.useDirectBuffers = true - compressionLevel = 6 + val options = ServerOptions().apply { port = 8080 ioThreads = 16 workerThreads = 64 - gzip = false - singleLoop = false - defaultHeaders = true - maxRequestSize = 10485760 - securePort = 8443 - ssl = SslOptions.selfSigned() - isHttpsOnly = true + maxRequestSize = 10485760 // 10MB isHttp2 = true - isExpectContinue = true } } ---- -- output.size: Buffer size used by server for reading/writing data. Default is: `16k`. -- compressionLevel: Gzip support. Set compression level. Value between: `0..9`. -- port: Server HTTP port or `0` for random port. Default is: `8080`. -- ioThreads: Number of IO threads used by the server. Used by Netty and Undertow. Default is: `Runtime.getRuntime().availableProcessors() * 2` -- workerThreads: Number of worker (a.k.a application) threads. Default is: `ioThreads * 8`. -- gzip: Gzip support. Default is: `false`. Deprecated in favor of **compressionLevel**. -- singleLoop: Indicates if the web server should use a single loop/group for doing IO or not. **Netty only**. -- defaultHeaders: Configure server to set the following headers: `Date`, `Content-Type` and `Server` headers. -- maxRequestSize: Maximum request size in bytes. Request exceeding this value results in 413(REQUEST_ENTITY_TOO_LARGE) response. Default is `10mb`. -- securePort: Enable HTTPS. This option is fully covered in next section. -- ssl: SSL options with certificate details. This option is fully covered in next section. -- isHttpsOnly: bind only to HTTPS port, not HTTP. This requires SSL options to be configured. -- isHttp2: Enable HTTP 2.0. -- isExpectContinue: Whenever 100-Expect and continue requests are handled by the server. This is off - by default, except for Jetty which is always ON. - -For single application deployment server options are available as application configuration properties too: - -.application.conf -[source, properties] ----- -server.output.size = 16384 -server.output.useDirectBuffers = true -server.compressionLevel = 6 -server.port = 8080 -server.ioThreads = 16 -server.workerThreads = 64 -server.gzip = false -server.singleLoop = false -server.defaultHeaders = true -server.maxRequestSize = 10485760 -server.securePort = 8443 -server.ssl.type = self-signed | PKCS12 | X509 -server.httpsOnly = false -server.http2 = true -server.expectContinue = false ----- - -=== HTTPS Support +===== Core Settings -Jooby supports HTTPS out of the box. By default HTTPS is disabled and all requests are served using -HTTP. Jooby supports two certificate formats: +* `server.port`: The HTTP port (default: `8080`). Use `0` for a random port. +* `server.ioThreads`: Number of IO threads (Netty/Undertow). Defaults to `Processors * 2`. +* `server.workerThreads`: Number of worker threads. Defaults to `ioThreads * 8`. +* `server.maxRequestSize`: Maximum request size in bytes. Exceeding this triggers a `413 Request Entity Too Large` response. +* `server.defaultHeaders`: Automatically sets `Date`, `Content-Type`, and `Server` headers. +* `server.expectContinue`: Enables support for `100-Continue` requests. -- PKCS12 (this is the default format) -- X.509 +==== HTTPS Support -The javadoc:SslOptions[] class provides options to configure SSL: +Jooby supports HTTPS out of the box using either **PKCS12** (default) or **X.509** certificates. -- cert: A PKCS12 or X.509 certificate chain file in PEM format. Most commonly, a .crt file for X509 and .p12 for PKCS12. It can be an absolute path or a classpath resource. Required. -- key: A PKCS#8 private key file in PEM format. Most commonly a .key file. It can be an absolute path or a classpath resource. Required when using X.509 certificates. -- password: Password to use. Required when using PKCS12 certificates. +===== Hello HTTPS (Self-Signed) +For development, you can enable a self-signed certificate with one line: -.Hello HTTPS +.Self-Signed HTTPS [source,java,role="primary"] ---- public static void main(String[] args) { - var options = new ServerOptions().setSecurePort(8443); <1> + var options = new ServerOptions().setSecurePort(8443); runApp(args, new NettyServer(options), App::new); } ---- @@ -254,115 +150,43 @@ public static void main(String[] args) { [source,kotlin,role="secondary"] ---- fun main(args: Array) { - var options = ServerOptions().setSecurePort(8443) <1> + val options = ServerOptions().setSecurePort(8443) runApp(args, NettyServer(options), ::App) } ---- -<1> Set secure port and use a self-signed certificate +===== Valid Certificates (X.509 & PKCS12) -Once SSL is enabled application logs print something like: +For production, you should use valid certificates (e.g., from Let's Encrypt). You can configure these in your code or via `application.conf`. +.X.509 Configuration +[source,properties] ---- -listening on: - http://localhost:8080/ - https://localhost:8443/ +server.ssl.type = X509 +server.ssl.cert = "path/to/server.crt" +server.ssl.key = "path/to/server.key" ---- -[IMPORTANT] -==== -The `self-signed` certificate is useful for development but keep in mind it will generate a warning on the browser. -==== - -image::self-signed-not-secure.png[Not Secure] - -A better option for development is the https://mkcert.dev[mkcert] tool: - -.Generates a PKCS12 certificate -[source,bash,role="primary] +.PKCS12 Configuration +[source,properties] ---- -mkcert -pkcs12 localhost +server.ssl.type = PKCS12 +server.ssl.cert = "path/to/server.p12" +server.ssl.password = "password" ---- -.Generates a X.509 certificate -[source,bash,role="secondary"] ----- -mkcert localhost ----- - -==== Using X.509 +===== Mutual TLS (Client Authentication) -To use a valid X.509 certificate, for example one created with https://letsencrypt.org/[Let’s Encrypt]. You will need the `*.crt` and `*.key` files: +To require clients to present a certificate, set the `clientAuth` mode to `REQUIRED`. This usually requires a **Trust Store** containing the certificates you trust. -.X509 -[source,java,role="primary"] ----- -import io.jooby.netty.NettyServer; - -public static void main(String[] args) { - var ssl = SslOptions.x509("path/to/server.crt", "path/to/server.key"); - var options = new ServerOptions().setSsl(ssl); - runApp(args, new NettyServer(options), App::new); -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -import io.jooby.netty.NettyServer - -fun main(args: Array) { - val ssl = SslOptions.x509("path/to/server.crt", "path/to/server.key") - val options = ServerOptions().setSsl(ssl) - runApp(args, NettyServer(options), ::App) -} ----- - -<1> Creates a SslOptions using X509 certificates path - -Certificate (.crt) and private key (.key) location can be file system or class path locations. - -Optionally you can define the SSL options in your application configuration file: - -.Ssl options: -[source,json] ----- -server { - ssl { - type: X509, - cert: "path/to/server.crt", - key: "path/to/server.key" - } -} ----- - -.X509 from configuration +.Mutual TLS [source,java,role="primary"] ---- { - var options = new ServerOptions() - .setSsl(SslOptions.from(getConfig()).get()); -} ----- + var ssl = SslOptions.pkcs12("server.p12", "password") + .setTrustCert(Paths.get("trust.crt")) + .setClientAuth(SslOptions.ClientAuth.REQUIRED); -.Kotlin -[source,kotlin,role="secondary"] ----- -{ - val options = ServerOptions() - .setSsl(SslOptions.from(config).get()) -} ----- - -==== Using PKCS12 - -To use a valid PKCS12 certificate: - -.PKCS12 -[source,java,role="primary"] ----- -{ - var ssl = SslOptions.pkcs12("path/to/server.p12", "password"); <1> var options = new ServerOptions().setSsl(ssl); } ---- @@ -371,128 +195,25 @@ To use a valid PKCS12 certificate: [source,kotlin,role="secondary"] ---- { - val ssl = SslOptions.pkcs12("path/to/server.p12", "password") <1> - val options = ServerOptions().setSsl(ssl) -} ----- - -<1> Creates SslOptions using PKCS12 certificates path - -Certificate (.p12) location can be file system or class path locations. - -Optionally you can define the SSL options in your application configuration file: - -.Ssl options: -[source,json] ----- -server { - ssl { - type: PKCS12, - cert: "path/to/server.p12", - password: "password" - } -} ----- - -.PKCS12 from configuration -[source,java,role="primary"] ----- -{ - var options = new ServerOptions() - .setSsl(SslOptions.from(getConfig()).get()); -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -{ - val options = ServerOptions() - .setSsl(SslOptions.from(config).get()) -} ----- - -==== Client Authentication (Mutual TLS) - -To enable 2-way TLS (Mutual TLS), set the trust certificate and client authentication. Setting the trust certificate is required if using self-signed or custom generated certificates so that the server will trust the client's certificate signing authority. - -.Client Authentication -[source,java,role="primary"] ----- -{ - var ssl = SslOptions.pkcs12("path/to/server.p12", "password") - .setTrustCert(Files.newInputStream("path/to/trustCert")) <1> - .setTrustPassword("password") <2> - .setClientAuth(SslOptions.ClientAuth.REQUIRED); <3> - - var options = new ServerOptions().setSsl(ssl); -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -{ - val ssl = SslOptions.pkcs12("path/to/server.p12", "password") - .trustCert(Files.newInputStream("path/to/trustCert")) <1> - .trustPassword("password") <2> - .clientAuth(SslOptions.ClientAuth.REQUIRED) <3> + val ssl = SslOptions.pkcs12("server.p12", "password") + .setTrustCert(Paths.get("trust.crt")) + .setClientAuth(SslOptions.ClientAuth.REQUIRED) val options = ServerOptions().setSsl(ssl) } ---- -<1> Set the trust certificate path. -<2> Set the trust certificate password. -<3> Set the client authentication mode. Possible values are REQUIRED, REQUESTED, or NONE. Default is NONE. - -Optionally you can define these SSL options in your application configuration file: - -.Ssl options: -[source,json] ----- -server { - ssl { - type: PKCS12, - cert: "path/to/server.p12", - password: "password", - trust { - cert: "path/to/trustCert", - password: "password" - } - clientAuth: REQUIRED - } -} ----- - -.Mutual TLS from configuration -[source,java,role="primary"] ----- -{ - var options = new ServerOptions() - .setSsl(SslOptions.from(config).get()); -} ----- +==== HTTP/2 Support -.Kotlin -[source,kotlin,role="secondary"] ----- -{ - val options = ServerOptions() - .setSsl(SslOptions.from(config).get()) -} ----- - -==== TLS protocol - -Default protocol is `TLSv1.3, TLSv1.2`. To override, just do: +HTTP/2 is supported across all servers. To use it in a browser, you **must** enable HTTPS. -.TLS example +.Enable HTTP/2 [source,java,role="primary"] ---- { var options = new ServerOptions() - .setSsl(new SslOptions().setProtocol("TLSv1.3", "TLSv1.2")); + .setHttp2(true) + .setSecurePort(8443); } ---- @@ -500,79 +221,18 @@ Default protocol is `TLSv1.3, TLSv1.2`. To override, just do: [source,kotlin,role="secondary"] ---- { - val options = ServerOptions() - .setSsl(SslOptions().apply { - protocol = listOf("TLSv1.3", "TLSv1.2") - }) + val options = ServerOptions().apply { + isHttp2 = true + securePort = 8443 + } } ---- -If a listed protocol is not supported, it is ignored; however, if you specify a list of protocols, -none of which are supported, an exception will be thrown. - -[NOTE] -==== -*TLSv1.3 protocol is available in* - -- Open SSL via Conscrypt (see next section) -- Java 11.0.3 or higher. -==== +==== OpenSSL (Conscrypt) -==== OpenSSL +By default, Jooby uses the JDK's built-in SSL engine. For better performance and features (like **TLS v1.3** on older Java versions), you can use the OpenSSL-backed https://github.com/google/conscrypt[Conscrypt] provider. -SSL support is provided using built-in JDK capabilities. Jooby offers an OpenSSL support using -https://github.com/google/conscrypt[Conscrypt]. - -To enable, add the required dependency: +Simply add the dependency: [dependency, artifactId="jooby-conscrypt"] . - -Conscrypt is a Java Security Provider (JSP) that implements parts of the Java Cryptography Extension -(JCE) and Java Secure Socket Extension (JSSE). It uses https://boringssl.googlesource.com/boringssl[BoringSSL] to provide cryptographic -primitives and Transport Layer Security (TLS) for Java applications on Android and OpenJDK. - -=== HTTP/2 Support - -HTTP2 support is provided across web server implementation. You need to enabled `http2` option -programmatically or via `application.conf` properties. - -.HTTP/2 -[source,java,role="primary"] ----- -{ - var options =new ServerOptions() - .setHttp2(true) - .setSecurePort(8433); - - get("/", ctx -> - ctx.getProtocol() - ) -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -{ - val options = ServerOptions().apply { - http2 = true - securePort = 8433 - } - - get("/") { - ctx.protocol - } -} ----- - -[NOTE] -==== -To use HTTP/2 from browsers, you need TLS (the h2 protocol) please refer to -<> to configure TLS. -==== - -[NOTE] -==== -There is no support for HTTP/2 Push. -==== diff --git a/docs/asciidoc/session.adoc b/docs/asciidoc/session.adoc index eedda65527..4751bbb8f1 100644 --- a/docs/asciidoc/session.adoc +++ b/docs/asciidoc/session.adoc @@ -1,31 +1,25 @@ -== Session +=== Session -Session is accessible via +Sessions are accessible via: -- javadoc:Context[sessionOrNull]: which find an existing session -- javadoc:Context[session]: which find an existing session or create a new one +* javadoc:Context[sessionOrNull]: Finds and returns an existing session (or returns `null`). +* javadoc:Context[session]: Finds an existing session or creates a new one if none exists. -Sessions have a lot of uses cases but the most commons are: authentication, storing information -about current user, etc. +Sessions are commonly used for authentication, storing user preferences, or tracking user state. -A session attribute must be a String or a primitive. The session doesn't allow storing of arbitrary -objects. It's intended as a simple mechanism to store basic data (not an object graph). +A session attribute **must be a String or a primitive**. The session API does not allow storing arbitrary Java objects or complex object graphs. It is intended as a simple, lightweight mechanism to store basic data. -Jooby provides the following javadoc:SessionStore[]: +Jooby provides the following javadoc:SessionStore[] implementations: -- In-Memory sessions—which you should combine with a sticky sessions proxy if you plan to run multiple instances. -- Cookie sessions signed with a secret key -- JSON Web Token sessions +* **In-Memory Sessions:** Suitable for single-instance applications (or multi-instance if combined with a sticky-session proxy). +* **Signed Cookie Sessions:** Stateless sessions signed with a secret key. +* **JWT (JSON Web Token) Sessions:** Stateless, token-based sessions. -Since 4.0.0 no session is configured by default. Attempt to access to a session at runtime results -in exception. +**Note:** Since Jooby 4.0.0, no session store is configured by default. Attempting to access a session at runtime without first configuring a store will result in an exception. -=== In-Memory Session +==== In-Memory Session -Default session store uses memory to save session data. This store: - -- Uses a cookie/header to read/save the session ID -- Store session data in-memory +The in-memory session store saves session data directly in the server's RAM. It uses a cookie or HTTP header solely to track the session ID. .In-Memory Session [source,java,role="primary"] @@ -35,7 +29,6 @@ Default session store uses memory to save session data. This store: get("/", ctx -> { Session session = ctx.session(); // <1> - session.put("foo", "bar"); // <2> return session.get("foo").value(); // <3> @@ -47,23 +40,24 @@ Default session store uses memory to save session data. This store: [source,kotlin,role="secondary"] ---- { - setSessionStore(SessionStore.memory(Cookie.session("myappid"))) + sessionStore = SessionStore.memory(Cookie.session("myappid")) get("/") { val session = ctx.session() // <1> - session.put("foo", "bar") // <2> - + session.get("foo").value() // <3> } } ---- -<1> Find an existing session or create a new session -<2> Set a session attribute -<3> Get a session attribute +<1> Finds an existing session or creates a new one. +<2> Sets a session attribute. +<3> Gets a session attribute. + +By default, the session ID is retrieved from a request cookie. The default session cookie never expires and is set to `HttpOnly` under the root `/` path. -Session token/ID is retrieved it from request cookie. Default session cookie never expires, it is http only under the `/` path. To customize cookie details: +To customize the cookie details: .In-Memory Session with Custom Cookie [source,java,role="primary"] @@ -73,7 +67,6 @@ Session token/ID is retrieved it from request cookie. Default session cookie nev get("/", ctx -> { Session session = ctx.session(); - session.put("foo", "bar"); return session.get("foo").value(); @@ -89,17 +82,16 @@ Session token/ID is retrieved it from request cookie. Default session cookie nev get("/") { val session = ctx.session() - session.put("foo", "bar") - + session.get("foo").value() } } ---- -<1> Set an `in-memory` session store with a custom cookie named: `SESSION` +<1> Configures an in-memory session store using a custom cookie named `SESSION`. -Alternatively, you can use a request header to retrieve a session token/ID: +Alternatively, you can use an HTTP header to transmit the session token/ID instead of a cookie: .In-Memory Session with HTTP Header [source,java,role="primary"] @@ -109,7 +101,6 @@ Alternatively, you can use a request header to retrieve a session token/ID: get("/", ctx -> { Session session = ctx.session(); - session.put("foo", "bar"); return session.get("foo").value(); @@ -125,27 +116,27 @@ Alternatively, you can use a request header to retrieve a session token/ID: get("/") { val session = ctx.session() - session.put("foo", "bar") - + session.get("foo").value() } } ---- -<1> Session Token/ID comes from HTTP header `TOKEN` +<1> The Session Token/ID is read from the `TOKEN` HTTP header. -You can mix cookie and header tokens: +You can also combine both methods, telling Jooby to check the cookie first, and then fall back to the header: -.Java +.Mixed Tokens [source,java,role="primary"] ---- { - setSessionStore(SessionStore.memory(SessionToken.comibe(SessionToken.cookie("SESSION"), SessionToken.header("TOKEN")))); // <1> + setSessionStore(SessionStore.memory( + SessionToken.combine(SessionToken.cookie("SESSION"), SessionToken.header("TOKEN")) // <1> + )); get("/", ctx -> { Session session = ctx.session(); - session.put("foo", "bar"); return session.get("foo").value(); @@ -157,40 +148,39 @@ You can mix cookie and header tokens: [source,kotlin,role="secondary"] ---- { - sessionStore = SessionStore.memory(SessionToken.combie(SessionToken.cookie("SESSION"), SessionToken.header("TOKEN"))) // <1> + sessionStore = SessionStore.memory( + SessionToken.combine(SessionToken.cookie("SESSION"), SessionToken.header("TOKEN")) // <1> + ) get("/") { val session = ctx.session() - session.put("foo", "bar") - + session.get("foo").value() } } ---- -<1> Session Token/ID comes from HTTP Cookie `SESSION` or HTTP header `TOKEN` (in that order) +<1> The Session Token/ID is read from the `SESSION` cookie or the `TOKEN` header (in that order). -=== Signed Session +==== Signed Session -This is a stateless session store that expects to find session token on each request. The server doesn't keep any state. +This is a **stateless** session store. The server does not keep any session state in memory. Instead, the entire session payload is serialized, cryptographically signed, and sent back and forth between the client and server on every request. -- Session data is retrieve/save from/into HTTP Cookie or Header -- Session data is (un)signed with `HmacSHA256`. Key must be 256 bits long (32 bytes) +* Session data is retrieved/saved entirely from/into the HTTP Cookie or Header. +* Session data is signed using `HmacSHA256` to prevent tampering. The secret key must be at least 256 bits long (32 bytes). -Data sign/unsign is done using javadoc:Cookie[sign, java.lang.String, java.lang.String] and javadoc:Cookie[unsign, java.lang.String, java.lang.String]. +Signing and verification are handled internally using javadoc:Cookie[sign, java.lang.String, java.lang.String] and javadoc:Cookie[unsign, java.lang.String, java.lang.String]. .Usage [source,java,role="primary"] ---- { - String secret = "super secret key"; // <1> - + String secret = "super-secret-key-must-be-32-bytes"; // <1> setSessionStore(SessionStore.signed(Cookie.session("myappid"), secret)); // <2> get("/", ctx -> { Session session = ctx.session(); - session.put("foo", "bar"); return session.get("foo").value(); @@ -202,36 +192,32 @@ Data sign/unsign is done using javadoc:Cookie[sign, java.lang.String, java.lang. [source,kotlin,role="secondary"] ---- { - val secret = "super secret key" // <1> - - sessionStore = SessionStore.signed(Cookie.session("myappid"),secret) // <2> + val secret = "super-secret-key-must-be-32-bytes" // <1> + sessionStore = SessionStore.signed(Cookie.session("myappid"), secret) // <2> get("/") { val session = ctx.session() - session.put("foo", "bar") - + session.get("foo").value() } } ---- -<1> A secret key is required to signed the data -<2> Creates a cookie session store using the secret +<1> A secure, 32-byte secret key is required to sign the data. +<2> Creates a signed session store using a cookie and the secret key. -Like with `memory` session store you can use HTTP headers: +Just like the in-memory store, the signed session store also supports HTTP headers: -.Signed with headers +.Signed Session with HTTP Headers [source,java,role="primary"] ---- { - String secret = "super secret key"; // <1> - + String secret = "super-secret-key-must-be-32-bytes"; // <1> setSessionStore(SessionStore.signed(SessionToken.header("TOKEN"), secret)); // <2> get("/", ctx -> { Session session = ctx.session(); - session.put("foo", "bar"); return session.get("foo").value(); @@ -243,24 +229,22 @@ Like with `memory` session store you can use HTTP headers: [source,kotlin,role="secondary"] ---- { - val secret = "super secret key" // <1> - + val secret = "super-secret-key-must-be-32-bytes" // <1> sessionStore = SessionStore.signed(SessionToken.header("TOKEN"), secret) // <2> get("/") { val session = ctx.session() - session.put("foo", "bar") - + session.get("foo").value() } } ---- -=== Stores +==== Additional Stores -In addition to built-in memory stores we do provide: +In addition to the built-in memory and signed stores, Jooby provides external module integrations: -* link:modules/caffeine[Caffeine]: In-memory session store using Caffeine cache. -* link:modules/jwt-session-store[JWT]: JSON Web Token session store. -* link:modules/redis#redis-http-session[Redis]: Redis session store. +* link:modules/caffeine[Caffeine]: High-performance in-memory session store using the Caffeine cache library. +* link:modules/jwt-session-store[JWT]: Stateless JSON Web Token session store. +* link:modules/redis#redis-http-session[Redis]: Distributed session store using Redis. diff --git a/docs/asciidoc/static-files.adoc b/docs/asciidoc/static-files.adoc index 5ec95b5d62..5110b761c2 100644 --- a/docs/asciidoc/static-files.adoc +++ b/docs/asciidoc/static-files.adoc @@ -1,14 +1,12 @@ -== Static Files +=== Static Files -// Not sure on this one -Static files are available via javadoc:Router[assets, java.lang.String, java.nio.file.Path] route. The `assets` route -supports classpath and file-system resources. +Static files are served using the javadoc:Router[assets, java.lang.String, java.nio.file.Path] method. The `assets` route supports serving resources from both the classpath and the file system. -.Classpath resources: +.Serving a directory: [source, java, role="primary"] ---- { - assets("/static/*", "/source"); <1> + assets("/static/*", "/source"); // <1> } ---- @@ -16,24 +14,26 @@ supports classpath and file-system resources. [source, kotlin, role="secondary"] ---- { - assets("/static/*", "/source") <1> + assets("/static/*", "/source") // <1> } ---- -<1> Map all the incoming request starting with `/static/` to the `/source` classpath or file system location. +<1> Maps all incoming requests starting with `/static/` to the `/source` location. -- GET `/static/index.html` => `/source/index.html` -- GET `/static/js/file.js` => `/source/js/file.js` -- GET `/static/css/styles.css` => `/source/css/styles.css` +For example: +* `GET /static/index.html` => `/source/index.html` +* `GET /static/js/file.js` => `/source/js/file.js` +* `GET /static/css/styles.css` => `/source/css/styles.css` -The `/source` is resolved against the file system by prepending the `user.dir` path when exists -it favors the file system source, otherwise the `/source` directory must be present on classpath. +When passing a string as the source (`"/source"`), Jooby attempts to resolve it against the file system first (by prepending the `user.dir` path). If the directory exists on the file system, it uses it; otherwise, it falls back to looking for the `/source` directory on the classpath. -.File system resources: +To explicitly serve from the file system, pass a `Path` object: + +.Explicit File System Resources: [source, java, role="primary"] ---- { - assets("/static/*", Paths.get("www")); <1> + assets("/static/*", Paths.get("www")); // <1> } ---- @@ -41,45 +41,50 @@ it favors the file system source, otherwise the `/source` directory must be pres [source, kotlin, role="secondary"] ---- { - assets("/static/*", Paths.get("www")) <1> + assets("/static/*", Paths.get("www")) // <1> } ---- -<1> Map all the incoming request starting with `/static/` to a file system directory `www` - -- GET `/static/index.html` => `www/index.html` -- GET `/static/js/file.js` => `www/js/file.js` -- GET `/static/css/styles.css` => `www/css/styles.css` +<1> Maps all incoming requests starting with `/static/` directly to the `www` directory on the file system. -Individual file mapping is supported too: +You can also map individual files rather than entire directories: -.Classpath: -[source, role="primary"] +.Single File Mapping +[source, java, role="primary"] ---- { + // Classpath assets("/myfile.js", "/static/myfile.js"); + + // File System + Path basedir = Paths.get("www"); + assets("/myfile.js", basedir.resolve("myfile.js")); } ---- -.File system -[source, role="secondary"] +.Kotlin +[source, kotlin, role="secondary"] ---- { - Path basedir = ...; - assets("/myfile.js", basedire.resolve("/myfile.js")); + // Classpath + assets("/myfile.js", "/static/myfile.js") + + // File System + val basedir = Paths.get("www") + assets("/myfile.js", basedir.resolve("myfile.js")) } ---- -=== Static Site +==== Static Sites -The `assets` route works for static sites too. Just need to use a special path mapping: - -.Classpath resources: +The `assets` route can also serve full static websites (like generated documentation). To enable automatic resolution of `index.html` files, use the special `/?*` path mapping: + +.Static Site Directory [source, java, role="primary"] ---- { - Path docs = Paths.get("docs"); <1> - assets("/docs/?*", docs); <2> + Path docs = Paths.get("docs"); // <1> + assets("/docs/?*", docs); // <2> } ---- @@ -87,31 +92,32 @@ The `assets` route works for static sites too. Just need to use a special path m [source, kotlin, role="secondary"] ---- { - val docs = Paths.get("docs") <1> - assets("/docs/?*", docs) <2> + val docs = Paths.get("docs") // <1> + assets("/docs/?*", docs) // <2> } ---- -<1> Serve from `docs` directory -<2> Use the `/?*` mapping +<1> Serve from the `docs` directory. +<2> Use the `/?*` mapping to enable automatic root resolution. + +The `/?*` syntax automatically resolves root paths to their underlying `index.html`: +* `GET /docs` => `/docs/index.html` +* `GET /docs/index.html` => `/docs/index.html` +* `GET /docs/about.html` => `/docs/about.html` +* `GET /docs/note` => `/docs/note/index.html` -The key difference is the `/?*` mapping. This mapping add support for base root mapping: +==== SPAs -- GET `/docs` => `/docs/index.html` -- GET `/docs/index.html` => `/docs/index.html` -- GET `/docs/about.html` => `/docs/about.html` -- GET `/docs/note` => `/docs/note/index.html` +The `assets` route also supports Single Page Applications (SPAs) built with React, Vue, or Angular. -=== SPAs +For an SPA to handle client-side routing properly, the server must return the main `index.html` file whenever a requested asset or route is not found. You can configure this using the `AssetHandler`: -The `assets` route works for single page applications (SPAs) too. Just need to use a special path mapping plus a fallback asset: - -.Classpath resources: +.SPA Routing [source, java, role="primary"] ---- { - AssetSource docs = AssetSource.create(Paths.get("docs")); <1> - assets("/docs/?*", new AssetHandler("index.html", docs)); <2> + AssetSource buildDir = AssetSource.create(Paths.get("build")); // <1> + assets("/?*", new AssetHandler("index.html", buildDir)); // <2> } ---- @@ -119,22 +125,21 @@ The `assets` route works for single page applications (SPAs) too. Just need to u [source, kotlin, role="secondary"] ---- { - val docs = AssetSource.create(Paths.get("docs")) <1> - assets("/docs/?*", AssetHandler("index.html", docs)) <2> + val buildDir = AssetSource.create(Paths.get("build")) // <1> + assets("/?*", AssetHandler("index.html", buildDir)) // <2> } ---- -<1> Serve from `docs` directory -<2> Use the `/?*` mapping and uses `index.html` as fallback asset +<1> Define the source directory containing the built SPA. +<2> Map the root using `/?*` and provide `index.html` as the fallback asset. -SPAs mode never generates a `NOT FOUND (404)` response, unresolved assets fallback to `index.html` +In SPA mode, the server never generates a `404 NOT FOUND` response for missing files; it simply falls back to serving the `index.html` so the client-side router can take over. -=== Options +==== Options -The javadoc:handler.AssetHandler[] automatically handles `E-Tag` and `Last-Modified` headers. You can -control these headers programmatically: +The javadoc:handler.AssetHandler[] automatically generates and handles `ETag` and `Last-Modified` headers. You can control this behavior programmatically: -.Asset handler options: +.Disable ETag and Last-Modified: [source, java, role="primary"] ---- { @@ -154,14 +159,14 @@ control these headers programmatically: } ---- -The `maxAge` option set a `Cache-Control` header: +You can also set a `Cache-Control` header using the `maxAge` option: -.Cache control: +.Cache Control: [source, java, role="primary"] ---- { assets("/static/*", Paths.get("www")) - .setMaxAge(Duration.ofDays(365)) + .setMaxAge(Duration.ofDays(365)); } ---- @@ -174,26 +179,24 @@ The `maxAge` option set a `Cache-Control` header: } ---- -There is also a javadoc:handler.AssetHandler[setNoCache] method that explicitly forbids web browsers -to cache assets. +To explicitly forbid web browsers from caching assets, use the javadoc:handler.AssetHandler[setNoCache] method. -You can use different cache configurations for different assets based on asset name if you -specify a function via javadoc:handler.AssetHandler[cacheControl, java.util.Function]: +If you need different cache configurations for different files, you can provide a function via javadoc:handler.AssetHandler[cacheControl, java.util.Function]: -.Per-asset cache control: +.Per-Asset Cache Control: [source, java, role="primary"] ---- { assets("/static/*", Paths.get("www")) .cacheControl(path -> { if (path.endsWith("dont-cache-me.html")) { - return CacheControl.noCache(); // disable caching + return CacheControl.noCache(); // Disable caching } else if (path.equals("foo.js")) { return CacheControl.defaults() .setETag(false) .setMaxAge(Duration.ofDays(365)); } else { - return CacheControl.defaults(); // AssetHandler defaults + return CacheControl.defaults(); // Use default AssetHandler caching } }); } @@ -204,23 +207,21 @@ specify a function via javadoc:handler.AssetHandler[cacheControl, java.util.Func ---- { assets("/static/*", Paths.get("www")) - .cacheControl { + .cacheControl { path -> when { - it.endsWith("dont-cache-me.html") -> CacheControl.noCache() // disable caching - it == "foo.js" -> CacheControl.defaults() + path.endsWith("dont-cache-me.html") -> CacheControl.noCache() // Disable caching + path == "foo.js" -> CacheControl.defaults() .setETag(false) .setMaxAge(Duration.ofDays(365)) - else -> CacheControl.defaults() // AssetHandler defaults + else -> CacheControl.defaults() // Use default AssetHandler caching } } } ---- -The asset handler generates a `404` response code when requested path is not found. You can change this by throwing -an exception or generating any other content you want: - +By default, the asset handler generates a `404` response if the requested file is not found (unless you are using the SPA fallback). You can override this behavior by throwing a custom exception or generating alternative content: -.Custom not found: +.Custom Not Found Behavior: [source, java, role="primary"] ---- { @@ -230,7 +231,7 @@ an exception or generating any other content you want: }); error(MyAssetException.class, (ctx, cause, code) -> { - // render MyAssetException as you want + // Render MyAssetException the way you want }); } ---- @@ -243,8 +244,9 @@ an exception or generating any other content you want: .notFound { _ -> throw MyAssetException() } - error(MyAssetException::class) { - // render MyAssetException as you want + + error(MyAssetException::class) { ctx, cause, code -> + // Render MyAssetException the way you want } } ---- diff --git a/docs/asciidoc/templates.adoc b/docs/asciidoc/templates.adoc index 48b7830f81..f26dc1cfec 100644 --- a/docs/asciidoc/templates.adoc +++ b/docs/asciidoc/templates.adoc @@ -1,138 +1,132 @@ -== Templates +=== Templates -Templates are available via javadoc:ModelAndView[] and requires a javadoc:TemplateEngine[] implementation. +Templates are rendered using a javadoc:ModelAndView[] object and require a javadoc:TemplateEngine[] implementation to be installed. -.Java +.Basic Rendering [source, java, role = "primary"] ---- { - install(new MyTemplateEngineModule()); <1> + install(new MyTemplateEngineModule()); // <1> get("/", ctx -> { - MyModel model = ...; <2> - return new ModelAndView("index.html", model); <3> + MyModel model = new MyModel(); // <2> + return new ModelAndView("index.html", model); // <3> }); } ---- .Kotlin -[source, kt, role = "secondary"] +[source, kotlin, role = "secondary"] ---- { - install(MyTemplateEngineModule()) <1> + install(MyTemplateEngineModule()) // <1> get("/") { ctx -> - val model = MyModel(...) <2> - ModelAndView("index.html", model) <3> + val model = MyModel() // <2> + ModelAndView("index.html", model) // <3> } } ---- -<1> Install a template engine -<2> Build the view model -<3> Returns a ModelAndView instance +<1> Install a template engine module. +<2> Build the view model. +<3> Return a `ModelAndView` instance containing the template path and the model data. -javadoc:ModelAndView[] allows you to specify the desired locale used for template rendering: +You can explicitly set the desired locale for template rendering on the javadoc:ModelAndView[] object: -.Java +.Setting the Locale [source, java, role = "primary"] ---- { install(new MyTemplateEngineModule()); get("/", ctx -> { - MyModel model = ...; + MyModel model = new MyModel(); return new ModelAndView("index.html", model) - .setLocale(Locale.GERMAN); <1> + .setLocale(Locale.GERMAN); // <1> }); } ---- .Kotlin -[source, kt, role = "secondary"] +[source, kotlin, role = "secondary"] ---- { install(MyTemplateEngineModule()) get("/") { ctx -> - val model = MyModel(...) + val model = MyModel() ModelAndView("index.html", model) - .setLocale(Locale.GERMAN) <1> + .setLocale(Locale.GERMAN) // <1> } } ---- -<1> Set the preferred locale +<1> Explicitly set the preferred locale. -If no locale is specified explicitly, a locale matched by the `Accept-Language` header of the current -request is used. +If no locale is specified explicitly, Jooby falls back to the locale matched by the `Accept-Language` header of the current request. [NOTE] ==== -Not all template engines support setting and using a specific locale. If you use such a template -engine, the above configuration does not have any effect. +Not all template engines support localized rendering. If you use a template engine that doesn't support it, setting the locale will have no effect. ==== -=== Template Engine +==== Template Engine -Template engine does the view rendering/encoding. Template engine extends a javadoc:MessageEncoder[] -by accepting a `ModelAndView` instance and produces a `String` result. +A template engine handles the actual view rendering. A template engine extends the javadoc:MessageEncoder[] interface, accepting a `ModelAndView` instance and producing a `String` result. -The javadoc:TemplateEngine[extensions] method list the number of file extension that a template engine -supports. Default file extension is: `.html`. +The javadoc:TemplateEngine[extensions] method lists the file extensions that the template engine supports. The default file extension is `.html`. -The file extension is used to locate the template engine, when a file extension isn't supported -an `IllegalArgumentException` is thrown. +Jooby uses the file extension of the requested template to locate the correct template engine. If a template engine for the specified file extension isn't found, an `IllegalArgumentException` is thrown. -The file extension allow us to use/mix multiple template engines too: +This file-extension routing allows you to easily use multiple template engines side-by-side in the same application: -.Multiple template engines -.Java +.Multiple Template Engines [source, java, role = "primary"] ---- { - install(new HandlebarsModule()); <1> - install(new FreemarkerModule()); <2> + install(new HandlebarsModule()); // <1> + install(new FreemarkerModule()); // <2> get("/first", ctx -> { - return new ModelAndView("index.hbs", model); <3> + return new ModelAndView("index.hbs", model); // <3> }); get("/second", ctx -> { - return new ModelAndView("index.ftl", model); <4> + return new ModelAndView("index.ftl", model); // <4> }); } ---- .Kotlin -[source, kt, role = "secondary"] +[source, kotlin, role = "secondary"] ---- { - install(HandlebarsModule()) <1> - install(FreemarkerModule()) <2> + install(HandlebarsModule()) // <1> + install(FreemarkerModule()) // <2> get("/first") { ctx -> - ModelAndView("index.hbs", model) <3> + ModelAndView("index.hbs", model) // <3> } get("/second") { ctx -> - ModelAndView("index.ftl", model) <4> + ModelAndView("index.ftl", model) // <4> } } ---- -<1> Install Handlebars -<2> Install Freemarker -<3> Render using Handlebars, `.hbs` extension -<4> Render using Freemarker, `.ftl` extension +<1> Install Handlebars. +<2> Install Freemarker. +<3> Renders using Handlebars due to the `.hbs` extension. +<4> Renders using Freemarker due to the `.ftl` extension. -Checkout all the available <> provided by Jooby. +Check out the <> section for a full list of supported template engines. -=== View Model +==== View Model -Since Jooby `3.1.x` the model can be anything object you like, previous version requires to be always `map`. There -are two implementations of `ModelAndView`: +Since Jooby 3.1.x, the view model can be any custom Java object/POJO. (Previous versions strictly required the model to be a `Map`). -- ModelAndView(String view, Object model) -- MapModelAndView(String view, Map model) +There are two primary ways to instantiate a `ModelAndView`: +* `new ModelAndView(String view, Object model)` (For POJOs) +* `new MapModelAndView(String view, Map model)` (For Maps) diff --git a/docs/asciidoc/testing.adoc b/docs/asciidoc/testing.adoc index ee9dec8c46..2043bdb83e 100644 --- a/docs/asciidoc/testing.adoc +++ b/docs/asciidoc/testing.adoc @@ -1,15 +1,17 @@ -== Testing +=== Testing -This section will show you how to run unit and integration tests with Jooby. +Jooby provides dedicated tools for both lightweight unit testing and full-blown integration testing. -=== Unit Testing +==== Unit Testing -1) Add Jooby test dependency: +Unit testing in Jooby is fast because it allows you to test your routes without starting a real HTTP server. + +1) Add the Dependency: [dependency, artifactId="jooby-test"] . -2) Write your application: +2) Define your Application: .App [source,java,role="primary"] @@ -22,31 +24,28 @@ public class App extends Jooby { ---- .Kotlin -[source,java,role="kotlin"] +[source,kotlin,role="secondary"] ---- -class App: Kooby({ - - get("/") { ctx -> - "Easy unit testing!" - } - +class App : Kooby({ + get("/") { "Easy unit testing!" } }) ---- -3) Write your test: +3) Write the Test: + +Use the javadoc:test.MockRouter[artifact=jooby-test] to simulate requests and capture the return values of your handlers. .TestApp [source,java,role="primary"] ---- - import io.jooby.test.MockRouter; +import static org.junit.jupiter.api.Assertions.assertEquals; public class TestApp { - @Test public void test() { MockRouter router = new MockRouter(new App()); - assertEquals("OK", router.get("/").value()); + assertEquals("Easy unit testing!", router.get("/").value()); } } ---- @@ -55,256 +54,106 @@ public class TestApp { [source,kotlin,role="secondary"] ---- import io.jooby.test.MockRouter +import org.junit.jupiter.api.Assertions.assertEquals class TestApp { - @Test fun test() { val router = MockRouter(App()) - assertEquals("OK", router.get("/").value()) + assertEquals("Easy unit testing!", router.get("/").value()) } } ---- -Simple and easy {love}! +===== Checking Response Metadata -The javadoc:test.MockRouter[artifact=jooby-test] returns the value produced by the route handler. It is possible to get -access and check response metadata: +If your route modifies the context (like setting status codes or headers), you can verify the metadata using a callback: -.App +.Metadata Test [source,java,role="primary"] ---- -public class App extends Jooby { - { - get("/", ctx -> ctx - .setResponseCode(StatusCode.OK) - .send("Easy unit testing") - ); - } +@Test +public void testMetadata() { + MockRouter router = new MockRouter(new App()); + router.get("/", response -> { + assertEquals(StatusCode.OK, response.getStatusCode()); + assertEquals("Easy unit testing", response.value(String.class)); + }); } ---- .Kotlin [source,kotlin,role="secondary"] ---- -class App: Kooby({ - get("/") { - ctx.responseCode = StatusCode.OK - - ctx.send("Easy unit testing") - }) -}) ----- - -.Checking response metadata -[source,java,role="primary"] ----- -public class TestApp { - - @Test - public void test() { - MockRouter router = new MockRouter(new App()); - router.get("/", response -> { - assertEquals(StatusCode.OK, response.getStatusCode()); - assertEquals("Easy unit testing", response.value(String.class)); - }); - } -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -class TestApp { - - @Test - fun test() { - val router = MockRouter(App()) - router.get("/") { response -> - assertEquals(StatusCode.OK, response.statusCode) - assertEquals("Easy unit testing", response.value(String::class)) - } +@Test +fun testMetadata() { + val router = MockRouter(App()) + router.get("/") { response -> + assertEquals(StatusCode.OK, response.statusCode) + assertEquals("Easy unit testing", response.value(String::class.java)) } } ---- -For more complex route context interaction or responses, you can pass in a javadoc:test.MockContext[artifact=jooby-test]: - -.App -[source,java,role="primary"] ----- -public class App extends Jooby { - { - post("/", ctx -> { - String name = ctx.form("name").value(); - return name; - }); - } -} ----- +===== Mocking the Context -.Kotlin -[source,kotlin,role="secondary"] ----- -class App: Kooby({ - get("/") { - val name = ctx.form("name").value() - name - }) -}) ----- +For complex routes that interact with forms, bodies, or headers, you can provide a javadoc:test.MockContext[artifact=jooby-test] or a mock object from a library like Mockito. -.Using mock context +.Using MockContext [source,java,role="primary"] ---- -public class TestApp { - - @Test - public void test() { - MockRouter router = new MockRouter(new App()); - MockContext context = new MockContext(); - - context.setForm(Formdata.create(context) - .put("name", "Test!") - ); - assertEquals("Test!", router.post("/", context).value()); - } +@Test +public void testWithForm() { + MockRouter router = new MockRouter(new App()); + MockContext context = new MockContext(); + + context.setForm(Formdata.create(context).put("name", "Jooby")); + + assertEquals("Jooby", router.post("/", context).value()); } ---- .Kotlin [source,kotlin,role="secondary"] ---- -class TestApp { - - @Test - fun test() { - MockRouter router = MockRouter(App()) - MockContext context = MockContext().apply { - form = Formdata.create(this).apply { - put("name", "Test!") - } - } - assertEquals("Test!", router.post("/", context).value()) - } -} ----- - -Alternative you can provide your own mock context: - -.Mockito Context -[source,java,role="primary"] ----- -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -public class TestApp { - - @Test - public void test() { - Value name = mock(Value.class); - when(name.value()).thenReturn("Test!"); - - Context context = mock(Context.class); - when(context.form("name")).thenReturn(name); - - MockRouter router = new MockRouter(new App()); - - assertEquals("Test!", router.post("/", context).value()); - } -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -import org.mockito.Mockito.mock -import org.mockito.Mockito.when - -class TestApp { - - @Test - fun test() { - val name = mock(Value::class.java) - when(name.value()).thenReturn("Test!") - - val context = mock(Context::class.java) - when(context.form("name")).thenReturn(name) - - MockRouter router = MockRouter(App()) - - assertEquals("Test!", router.post("/", context).value()) +@Test +fun testWithForm() { + val router = MockRouter(App()) + val context = MockContext().apply { + form = Formdata.create(this).put("name", "Jooby") } + + assertEquals("Jooby", router.post("/", context).value()) } ---- -{love} {love}! - -For MVC routes you might prefer to write a unit test using a mock library. No need to use -`MockRouter`, but it is possible too. - -==== Options - -- javadoc:test.MockRouter[setFullExecution, boolean, artifact=jooby-test]: the javadoc:test.MockRouter[artifact=jooby-test] class ONLY execute the route -handler. For executing the entire pipeline use: javadoc:test.MockRouter[setFullExecution, boolean, artifact=jooby-test]. - -- javadoc:Jooby[setLateInit, boolean]: extension modules usually run at the time they are installed it. -This might not be ideally for unit tests. To delay extension initialization use the javadoc:Jooby[setLateInit, boolean] mode. +==== Integration Testing -Unit testing is simple and easy in Jooby. The javadoc:test.MockRouter[artifact=jooby-test] let you execute the route function, -while the javadoc:test.MockContext[artifact=jooby-test] allows you to create an light-weight and mutable context where -you can set HTTP parameters, body, headers, etc. - -=== Integration Testing - -Integration tests are supported via JUnit 5 extension mechanism. - -1) Add Jooby test dependency: +Integration tests run a real web server and allow you to test your application using any HTTP client. Jooby provides a JUnit 5 extension to manage the application lifecycle automatically. +1. **Add the Dependency:** [dependency, artifactId="jooby-test"] -. -2) Write your application: +2. **Write the Test:** +Annotate your test class with `@JoobyTest`. -.App +.Integration Test [source,java,role="primary"] ---- -public class App extends Jooby { - { - get("/", ctx -> "Easy testing!"); - } -} ----- +import io.jooby.test.JoobyTest; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; -.Kotlin -[source,java,role="kotlin"] ----- -class App: Kooby({ - - get("/") { ctx -> - "Easy testing!" - } - -}) ----- - -3) Write your test: - -.TestApp -[source,java,role="primary"] ----- - -import io.jooby.JoobyTest; - -@JoobyTest(App.class) -public class TestApp { +@JoobyTest(App.class) // <1> +public class IntegrationTest { static OkHttpClient client = new OkHttpClient(); @Test - public void test() { + public void testApp() throws IOException { Request request = new Request.Builder() - .url("http://localhost:8911") + .url("http://localhost:8911") // <2> .build(); try (Response response = client.newCall(request).execute()) { @@ -318,18 +167,20 @@ public class TestApp { [source,kotlin,role="secondary"] ---- import io.jooby.test.JoobyTest +import okhttp3.OkHttpClient +import okhttp3.Request -@JoobyTest(App::class) -class TestApp { +@JoobyTest(App::class) // <1> +class IntegrationTest { companion object { val client = OkHttpClient() } @Test - fun test() { + fun testApp() { val request = Request.Builder() - .url("http://localhost:8911") + .url("http://localhost:8911") // <2> .build() client.newCall(request).execute().use { response -> @@ -339,109 +190,57 @@ class TestApp { } ---- -The example uses https://square.github.io/okhttp/[OkHttp] client, but you are free to use any other HTTP client. +<1> Jooby starts the application before the test and stops it afterward. +<2> The default integration test port is `8911`. -Simple and easy {love}! +===== Injecting Server Details -The javadoc:test.JoobyTest[artifact=jooby-test] takes care of start and stop the application. +If you use a random port (`port = 0`) or want to avoid hardcoding URLs, you can inject server details directly into your test methods or fields: -The default port is: `8911`. Application port can be configured directly using the javadoc:test.JoobyTest[port, artifact=jooby-test] method: - -`@JoobyTest(value = App.class, port = 9999)` - -If port is set to zero(0) a random port is selected. You can inject the server port in your test like: - -.Server port injection -[source,java] +.Injection Example +[source,java,role="primary"] ---- -@JoobyTest(App.class) -public void test(int serverPort) { - -} - -@JoobyTest(App.class) -public void anotherTest(int serverPort) { - +@JoobyTest(value = App.class, port = 0) +public void test(int serverPort, String serverPath) { + // serverPort: e.g. 54321 + // serverPath: e.g. "http://localhost:54321" } ---- -The parameter name must be `serverPort` and be of type `int`. This injection let you access to the -random port used for the method-level application test. - -There is a `serverPath` value too, which is the entire path to the server: - -.Server path injection -[source,java] +.Kotlin +[source,kotlin,role="secondary"] ---- -@JoobyTest(App.class) -public void test(String serverPath) { <1> - +@JoobyTest(value = App::class, port = 0) +fun test(serverPort: Int, serverPath: String) { + // serverPort: e.g. 54321 + // serverPath: e.g. "http://localhost:54321" } ---- -The `serverPath` variable contains the entire path: `http://localhost:port/contextPath`. - -Here is the list of available injectable values: - -* `int serverPort`: Give you the port where the application is listening. This is named type injection (name and type are required). -* `String serverPath`: Give you the entire server path where the application is listening. This is named type injection (name and type are required). -* `io.jooby.Environment`: Give you access to the application environment. This is a type injection (name no matter). -* `com.typesafe.config.Config`: Give you access to the application environment. This is a type injection (name no matter). -* `io.jooby.Jooby`: Give you access to the application. This is a type injection (name no matter). - -These values can be injected via parameter or instance fields. - -The javadoc:test.JoobyTest[artifact=jooby-test] annotation starts the application using the `test` environment name. You can -creates a `conf/application.test.conf` file to override any other values for testing purpose. - -==== Arguments - -Application arguments are supported using a `factory method` strategy: - -.App -[source,java,role="primary"] ----- -public class App extends Jooby { - public App(String argument) { // <1> - get("/", ctx -> "Easy testing!"); - } -} ----- +Supported injectable types include: +* `int serverPort`: The port the application is listening on. +* `String serverPath`: The full base URL (e.g., `http://localhost:port`). +* `io.jooby.Environment`: Access to the test environment. +* `com.typesafe.config.Config`: Access to the application configuration. +* `io.jooby.Jooby`: Access to the application instance itself. -.Kotlin -[source,java,role="kotlin"] ----- -class App(argument: String): Kooby({ // <1> - get("/") { ctx -> - "Easy testing!" - } -}) ----- +[TIP] +==== +When running integration tests, Jooby automatically sets the environment name to `test`. You can create a `conf/application.test.conf` file to provide test-specific settings. +==== -<1> Application requires a String argument +===== Using a Factory Method -Write a test: +If your application requires constructor arguments, you can specify a `factoryMethod` to instantiate it: -.TestApp +.Factory Method Test [source,java,role="primary"] ---- -import io.jooby.JoobyTest; - +@JoobyTest(value = App.class, factoryMethod = "createApp") public class TestApp { - @JoobyTest(value = App.class, factoryMethod = "createApp") // <1> - public void test() { - Request request = new Request.Builder() - .url("http://localhost:8911") - .build(); - - try (Response response = client.newCall(request).execute()) { - assertEquals("Easy testing!", response.body().string()); - } - } - - public App createApp() { // <2> - return new App("Argument"); // <3> + public static App createApp() { + return new App("custom-argument"); } } ---- @@ -449,30 +248,12 @@ public class TestApp { .Kotlin [source,kotlin,role="secondary"] ---- -import io.jooby.JoobyTest - +@JoobyTest(value = App::class, factoryMethod = "createApp") class TestApp { - @JoobyTest(value = App.class, factoryMethod = "createApp") // <1> - fun test() { - val request = Request.Builder() - .url("http://localhost:8911") - .build() - - client.newCall(request).execute().use { response -> - assertEquals("Easy testing!", response.body().string()) - } - } - - fun createApp() { // <2> - return App("Argument") // <3> + companion object { + @JvmStatic + fun createApp(): App = App("custom-argument") } } ---- - -<1> Specify a factory method: `createApp` -<2> Creates the method: must be public and without arguments -<3> Creates your application - -If you prefer the annotation at class level (shared application between tests) the factory method -must be static. diff --git a/docs/asciidoc/tooling.adoc b/docs/asciidoc/tooling.adoc new file mode 100644 index 0000000000..4f5754c7cc --- /dev/null +++ b/docs/asciidoc/tooling.adoc @@ -0,0 +1,13 @@ +== Tooling and Operations +[.lead] +Streamline your development workflow with Jooby's productivity suite. This section covers essential utilities like Hot Reload for instantaneous code changes without restarting the server, and deep integration with build systems like Maven and Gradle to manage your project's lifecycle from the first line of code to the final deployment. + +include::configuration.adoc[] + +include::dev-tools.adoc[] + +include::testing.adoc[] + +include::servers.adoc[] + +include::packaging/packaging.adoc[] diff --git a/docs/asciidoc/value-api.adoc b/docs/asciidoc/value-api.adoc index 80bc7e5d92..60e5ec65c7 100644 --- a/docs/asciidoc/value-api.adoc +++ b/docs/asciidoc/value-api.adoc @@ -1,18 +1,17 @@ -=== Value API +==== Value API -The javadoc:value.Value[Value] is an unified and type-safe API across all parameter types: +The javadoc:value.Value[Value] is a unified, type-safe API for accessing all parameter types: -- Header -- Path -- Query -- Formdata/Multipart +* Header +* Path +* Query +* Formdata/Multipart -For learning purpose we are going to show all the javadoc:value.Value[Value] features using query -parameters, but keep in mind these features apply to all the parameter types. +For learning purposes, we will demonstrate the javadoc:value.Value[Value] features using query parameters, but keep in mind that these features apply to all parameter types. -==== Single value +===== Single value -Single value is available via `value()` or `[type]Value()` functions: +Single values are retrieved via the `value()` or `[type]Value()` functions: .Java [source, java, role="primary"] @@ -20,13 +19,10 @@ Single value is available via `value()` or `[type]Value()` functions: { get("/", ctx -> { String name = ctx.query("name").value(); // <1> - float score = ctx.query("score").floatValue(); // <2> - boolean enabled = ctx.query("enabled").booleanValue(); // <3> - BigDecimal decimal = ctx.query("decimal").value(BigDecimal::new); // <4> - ... + // ... }); } ---- @@ -37,62 +33,47 @@ Single value is available via `value()` or `[type]Value()` functions: { get("/") { val name = ctx.query("name").value() // <1> - val score = ctx.query("score").floatValue() // <2> - val enabled = ctx.query("enabled").booleanValue() // <3> - val decimal = ctx.query("decimal").value(::BigDecimal) // <4> - ... - }); + // ... + } } ---- -The `value()` family methods always retrieve a `value`. If there is no value, a -`BadRequest(400)` response is generated. So single value parameters *are required*: - -<1> Access to query parameter `q` and convert to `String`: - -- `/?name=foo` => `foo` -- `/` => `Bad Request(400): Missing value: "q"` - -<2> Access to query parameter `score` and convert to `float`: +The `value()` family of methods **always** expects a value to exist. If the value is missing or cannot be converted to the requested type, a `400 Bad Request` response is generated. Therefore, single-value parameters are implicitly **required**: -- `/?score=1` => `1.0` -- `/?score=string` => `Bad Request(400)` (Type mismatch: cannot convert to number) -- `/` => `Bad Request(400)` (Required parameter `score` is not present) +<1> Access parameter `name` as `String`: +* `/?name=foo` => `foo` +* `/` => `Bad Request(400): Missing value: "name"` -<3> Access to query parameter `enabled` and convert to `boolean`: +<2> Access parameter `score` as `float`: +* `/?score=1` => `1.0` +* `/?score=string` => `Bad Request(400)` (Type mismatch) +* `/` => `Bad Request(400)` -- `/?enabled=true` => `true` -- `/?enabled=string` => `Bad Request(400)` (Type mismatch: cannot convert to boolean) -- `/` => `Bad Request(400): Missing value: "enabled"` +<3> Access parameter `enabled` as `boolean`: +* `/?enabled=true` => `true` +* `/?enabled=string` => `Bad Request(400)` +* `/` => `Bad Request(400)` -<4> Access to query parameter `decimal` and convert to `BigDecimal`: +<4> Access parameter `decimal` and convert it to a custom type (`BigDecimal`): +* `/?decimal=2.3` => `2.3` +* `/?decimal=string` => `Bad Request(400)` +* `/` => `Bad Request(400)` -- `/?decimal=2.3` => `2.3` -- `/?decimal=string` => `Bad Request(400)` (Type mismatch: cannot convert to BigDecimal) -- `/` => `Bad Request(400): Missing value: "decimal"` +===== Default and Optional value -==== Default and Optional value - -Default and optional value are available in two different ways: - -- Providing a default value -- Requesting an `java.util.Optional` object +You can handle optional parameters by providing a default value or requesting an `Optional` object: .Java [source, java,role="primary"] ---- { get("/search", ctx -> { - String q = ctx.query("q").value("*:*"); // <1> - return q; - }); - - get("/search", ctx -> { - Optional q = ctx.query("q").toOptional(); // <2> - return q; + String q1 = ctx.query("q").value("*:*"); // <1> + Optional q2 = ctx.query("q").toOptional(); // <2> + return q1; }); } ---- @@ -102,46 +83,37 @@ Default and optional value are available in two different ways: ---- { get("/search") { - val q = ctx.query("q").value("*:*") // <1> - q - }); - - get("/search") { - val q = ctx.query("q").toOptional(); // <2> - q - }); + val q1 = ctx.query("q").value("*:*") // <1> + val q2 = ctx.query("q").toOptional() // <2> + q1 + } } ---- -<1> Access to query variable `q` and convert to `String` with a default value of `*:*`. - -- `/search?q=foo` => `foo` -- `/search` => `*:*` - -<2> Access to query variable `q` and convert to `Optional`: +<1> Retrieve variable `q` as a `String` with a fallback default value of `*:*`. +* `/search?q=foo` => `foo` +* `/search` => `*:*` -- `/search?q=foo` => `Optional[foo]` -- `/search` => `Optional.empty` +<2> Retrieve variable `q` wrapped in an `Optional`: +* `/search?q=foo` => `Optional[foo]` +* `/search` => `Optional.empty` -==== Multiple values +===== Multiple values -Multiple values are available via functions: +Multiple values for a single parameter key can be retrieved as Collections: -- javadoc:value.Value[toList]: Returns a `java.util.List` of values -- javadoc:value.Value[toSet]: Returns a `java.util.Set` of values +* javadoc:value.Value[toList]: Returns a `java.util.List` +* javadoc:value.Value[toSet]: Returns a `java.util.Set` .Java [source, java,role="primary"] ---- { get("/", ctx -> { - List q = ctx.query("q").toList(); // <1> - - List n = ctx.query("n").toList(Integer.class); // <2> - - List decimals = ctx.query("d").toList(BigDecimal::new); // <3> - - ... + List q = ctx.query("q").toList(); // <1> + List n = ctx.query("n").toList(Integer.class); // <2> + List d = ctx.query("d").toList(BigDecimal::new); // <3> + // ... }); } ---- @@ -152,41 +124,31 @@ Multiple values are available via functions: { get("/") { val q = ctx.query("q").toList() // <1> - - val n = ctx.query("n").toList(Integer.class) // <2> - - val decimals = ctx.query("d").toList(::BigDecimal) // <3> - - ... - }); + val n = ctx.query("n").toList(Integer::class.java) // <2> + val d = ctx.query("d").toList(::BigDecimal) // <3> + // ... + } } ---- -<1> Multi-value query parameter `q` as `List`: - -- `/` => `[]` (empty list) -- `/?q=foo` => `[foo]` -- `/?q=foo&q=bar` => `[foo, bar]` - -<2> Multi-value query parameter as `List` +<1> Parameter `q` as `List`: +* `/` => `[]` +* `/?q=foo` => `[foo]` +* `/?q=foo&q=bar` => `[foo, bar]` -- `/` => `[]` (empty list) -- `/?n=1` => `[1]` -- `/?n=1&n=2` => `[1, 2]` +<2> Parameter `n` as `List`: +* `/` => `[]` +* `/?n=1&n=2` => `[1, 2]` -<3> Multi-value query parameter as `List` +<3> Parameter `d` mapped to `List`: +* `/` => `[]` +* `/?d=1.5&d=2.0` => `[1.5, 2.0]` -- `/` => `[]` (empty list) -- `/?d=1` => `[1]` -- `/?d=1&n=2` => `[1, 2]` +===== Structured data -==== Structured data +The javadoc:value.Value[Value API] allows you to traverse and parse deeply nested structured data from a request. -The javadoc:value.Value[Value API] provides a way to traverse and parse structured data: - ----- -/?user.name=root&user.pass=pass ----- +Given the query: `/?user.name=root&user.pass=pass` .Traversal [source, java, role="primary"] @@ -197,8 +159,8 @@ The javadoc:value.Value[Value API] provides a way to traverse and parse structur String name = user.get("name").value(); // <2> String pass = user.get("pass").value(); // <3> String email = user.get("email").value("none"); // <4> - ... - }} + // ... + }); } ---- @@ -211,22 +173,21 @@ The javadoc:value.Value[Value API] provides a way to traverse and parse structur val name = user["name"].value() // <2> val pass = user["pass"].value() // <3> val email = user["email"].value("none") // <4> - ... - }} + // ... + } } ---- -<1> Get the `user` node -<2> Get the `name` value from `user` node -<3> Get the `pass` value from `user` node -<4> Get the `mail` value from `user` node. This is an optional value. +<1> Retrieves the `user` node. +<2> Extracts `name` from the `user` node. +<3> Extracts `pass` from the `user` node. +<4> Safely extracts `email` with a fallback default. -The javadoc:value.Value[get, java.lang.String] takes a `path` and returns another value. The returning -value may or may not exists. +The javadoc:value.Value[get, java.lang.String] method takes a `path` and returns a nested `Value` node, which may or may not exist. -===== Syntax +====== Syntax -Structured data decoder supports `dot` and `bracket` notation: +The structured data decoder supports both `dot` and `bracket` notation: .Dot notation ---- @@ -243,22 +204,20 @@ Structured data decoder supports `dot` and `bracket` notation: ?members[0]firstname=Pedro&members[0]lastname=Picapiedra ---- -===== POJO - -Structured data decoder is able to reconstruct a POJO (Plain Old Java Object) from: +====== POJO Binding -- <> encoded as https://tools.ietf.org/html/rfc3986#section-2[RFC 3986] -- <> encoded as `application/x-www-form-urlencoded` -- <> encoded as `multipart/form-data` +The data decoder can automatically reconstruct POJOs (Plain Old Java Objects) from: -We are going to use a `Group` and `Member` objects to demonstrate how the decoder works: +* URL-encoded Queries +* `application/x-www-form-urlencoded` Form data +* `multipart/form-data` Forms -.Example +.Example Models [source, java, role="primary"] ---- class Member { public final String firstname; - public final String lastName; + public final String lastname; public Member(String firstname, String lastname) { this.firstname = firstname; @@ -270,7 +229,7 @@ class Group { public final String id; public final List members; - public Member(String id, List members) { + public Group(String id, List members) { this.id = id; this.members = members; } @@ -280,15 +239,13 @@ class Group { .Kotlin [source, kotlin, role="secondary"] ---- -class Member (val firstname: String, lastName: String) +class Member(val firstname: String, val lastname: String) -class Group (val id: String, val members: List) +class Group(val id: String, val members: List) ---- -.Member parsing example: ----- -/?firstname=Pedro&lastName=Picapiedra ----- +**Binding a single Member:** +`/?firstname=Pedro&lastname=Picapiedra` .Java [source, java,role="primary"] @@ -296,7 +253,7 @@ class Group (val id: String, val members: List) { get("/", ctx -> { Member member = ctx.query(Member.class); - ... + // ... }); } ---- @@ -307,15 +264,13 @@ class Group (val id: String, val members: List) { get("/") { val member = ctx.query() - ... + // ... } } ---- -.Member parsing example from base node: ----- -/?member.firstname=Pedro&member.lastName=Picapiedra ----- +**Binding a nested Member from a root node:** +`/?member.firstname=Pedro&member.lastname=Picapiedra` .Java [source, java,role="primary"] @@ -323,7 +278,7 @@ class Group (val id: String, val members: List) { get("/", ctx -> { Member member = ctx.query("member").to(Member.class); - ... + // ... }); } ---- @@ -334,17 +289,13 @@ class Group (val id: String, val members: List) { get("/") { val member = ctx.query("member").to() - ... - }); + // ... + } } ---- -Tabular data uses the bracket array notation: - -.Member as tabular data: ----- -/?[0]firstname=Pedro&[0]lastName=Picapiedra&[1]firstname=Pablo&[2]lastname=Marmol ----- +**Binding tabular/array data:** +`/?[0]firstname=Pedro&[0]lastname=Picapiedra&[1]firstname=Pablo&[1]lastname=Marmol` .Java [source, java,role="primary"] @@ -352,7 +303,7 @@ Tabular data uses the bracket array notation: { get("/", ctx -> { List members = ctx.query().toList(Member.class); - ... + // ... }); } ---- @@ -363,15 +314,13 @@ Tabular data uses the bracket array notation: { get("/") { val members = ctx.query>() - ... - }); + // ... + } } ---- -.Group with members as tabular data: ----- -/?id=flintstones&members[0]firstname=Pedro&members[0]lastName=Picapiedra ----- +**Binding complex nested hierarchies:** +`/?id=flintstones&members[0]firstname=Pedro&members[0]lastname=Picapiedra` .Java [source, java,role="primary"] @@ -379,7 +328,7 @@ Tabular data uses the bracket array notation: { get("/", ctx -> { Group group = ctx.query(Group.class); - ... + // ... }); } ---- @@ -390,34 +339,34 @@ Tabular data uses the bracket array notation: { get("/") { val group = ctx.query() - ... - }); + // ... + } } ---- -The target `POJO` must follow one of these rules: - -- Has a zero argguments/default constructor, or -- Has only one constructor -- Has multiple constructors, but only one is annotated with https://javadoc.io/doc/jakarta.inject/jakarta.inject-api/2.0.1/jakarta.inject/jakarta/inject/Inject.html[Inject] +**POJO Binding Rules:** +The target POJO must follow one of these rules: -The decoder matches HTTP parameters in the following order: +* Have a zero-argument (default) constructor. +* Have exactly one constructor. +* Have multiple constructors, but only one is annotated with `@Inject`. -- As constructor arguments -- As setter method +The decoder maps HTTP parameters in the following order: +1. Constructor arguments +2. Setter methods -HTTP parameter name which are not a valid Java identifier must be annotated with https://javadoc.io/doc/jakarta.inject/jakarta.inject-api/2.0.1/jakarta.inject/jakarta/inject/Named.html[Named]: +If an HTTP parameter name is not a valid Java identifier (e.g., `first-name`), you must map it using the `@Named` annotation: .Java [source, java,role="primary"] ---- class Member { public final String firstname; - public final String lastname; public Member(@Named("first-name") String firstname, @Named("last-name") String lastname) { - .... + this.firstname = firstname; + this.lastname = lastname; } } ---- @@ -425,14 +374,15 @@ class Member { .Kotlin [source,kotlin,role="secondary"] ---- -class Member (@Named("first-name") val firstname: String, @Named("last-name") val lastName: String) +class Member(@Named("first-name") val firstname: String, @Named("last-name") val lastname: String) ---- -==== Value Factory +===== Value Factory -The javadoc:value.ValueFactory[] class allow you to register new types for conversion (or even override). +The javadoc:value.ValueFactory[] allows you to register new type conversions or override existing ones globally. -[source,java] +.Java +[source,java,role="primary"] ---- { var valueFactory = getValueFactory(); @@ -442,8 +392,27 @@ The javadoc:value.ValueFactory[] class allow you to register new types for conve import io.jooby.value.ValueConverter; class MyBeanConverter implements ValueConverter { + @Override public Object convert(Type type, Value node, ConversionHint hint) { - // convert node to MyBean.class + // Logic to convert the 'node' into MyBean.class + return new MyBean(); + } +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +{ + valueFactory.put(MyBean::class.java, MyBeanConverter()) +} + +import io.jooby.value.ValueConverter + +class MyBeanConverter : ValueConverter { + override fun convert(type: Type, node: Value, hint: ConversionHint): Any { + // Logic to convert the 'node' into MyBean::class.java + return MyBean() } } ---- diff --git a/docs/asciidoc/web.adoc b/docs/asciidoc/web.adoc new file mode 100644 index 0000000000..6f63aa8da1 --- /dev/null +++ b/docs/asciidoc/web.adoc @@ -0,0 +1,13 @@ +== Web +[.lead] +Everything you need to handle HTTP traffic and build robust APIs or web applications. Explore Jooby's expressive routing paradigms, request and response handling, content negotiation, and advanced web features like WebSockets and file uploads. + +include::mvc-api.adoc[] + +include::templates.adoc[] + +include::session.adoc[] + +include::server-sent-event.adoc[] + +include::websocket.adoc[] diff --git a/docs/asciidoc/websocket.adoc b/docs/asciidoc/websocket.adoc index 4877bb2341..af885b71c0 100644 --- a/docs/asciidoc/websocket.adoc +++ b/docs/asciidoc/websocket.adoc @@ -1,6 +1,6 @@ -== Web Sockets +=== WebSockets -Adding a javadoc:WebSocket[]: +WebSockets are added using the javadoc:Router[ws] method: .WebSocket [source,java,role="primary"] @@ -16,11 +16,11 @@ Adding a javadoc:WebSocket[]: }); configurer.onClose((ws, statusCode) -> { - // <4> + // Clean up resources <4> }); configurer.onError((ws, cause) -> { - // 5 + // Handle exceptions <5> }); }); } @@ -30,7 +30,7 @@ Adding a javadoc:WebSocket[]: [source,kotlin,role="secondary"] ---- { - ws("/ws") { // <1> + ws("/ws") { ctx, configurer -> // <1> configurer.onConnect { ws -> ws.send("Connected") // <2> } @@ -40,34 +40,32 @@ Adding a javadoc:WebSocket[]: } configurer.onClose { ws, statusCode -> - // <4> + // Clean up resources <4> } configurer.onError { ws, cause -> - // <5> + // Handle exceptions <5> } } } ---- -<1> Add a WebSocket handler. Useful to initialize resources -<2> On WebSocket connect/open send a message back to client. Useful to initialize resources -<3> On new message send back to client -<4> WebSocket is about to close, you must free/release any acquire resources -<5> WebSocket found a exception. Useful to log the error and provide an alternative response is -the WebSocket is still open +<1> Register a WebSocket handler. +<2> On connection (open), send a message back to the client. This is also a good place to initialize resources. +<3> On receiving a new message, send a response back to the client. +<4> The WebSocket is about to close. You must free/release any acquired resources here. +<5> The WebSocket encountered an exception. Useful for logging the error or providing an alternative response if the socket is still open. -You are free to access to HTTP context from WebSocket configurer or callback, but it is disallowed -to modify the HTTP context or produces a response from it: +You are free to access the HTTP context from the WebSocket configurer or callbacks, but **it is forbidden to modify the HTTP context or produce an HTTP response from it**. -.Context +.Accessing Context [source,java,role="primary"] ---- { ws("/ws/{key}", (ctx, configurer) -> { String key = ctx.path("key").value(); // <1> String foo = ctx.session().get("foo").value(); // <2> - ... + // ... }); } ---- @@ -79,33 +77,33 @@ to modify the HTTP context or produces a response from it: ws("/ws/{key}") { ctx, configurer -> val key = ctx.path("key").value() // <1> val foo = ctx.session().get("foo").value() // <2> - ... + // ... } } ---- -<1> Access to path variable: `key` -<2> Access to session variable: `foo` +<1> Access a path variable (`key`). +<2> Access a session variable (`foo`). -=== Structured data +==== Structured Data -Structure data is supported using the Value API and the javadoc:WebSocket[render, java.lang.Object] method: +Structured data (like JSON) is supported using the Value API and the javadoc:WebSocket[render, java.lang.Object] method. -.JSON example: +To use structured messages, you need a registered javadoc:MessageDecoder[] and javadoc:MessageEncoder[]. In the following example, both are provided by the `JacksonModule`. -.JSON example +.JSON Example [source,java,role="primary"] ---- import io.jooby.jackson.JacksonModule; { - install(new JackonModule()); // <1> + install(new JacksonModule()); // <1> ws("/ws", (ctx, configurer) -> { configurer.onMessage((ws, message) -> { MyObject myobject = message.to(MyObject.class); // <2> ws.render(myobject); // <3> - }) + }); }); } ---- @@ -113,10 +111,12 @@ import io.jooby.jackson.JacksonModule; .Kotlin [source,kotlin,role="secondary"] ---- +import io.jooby.jackson.JacksonModule + { install(JacksonModule()) // <1> - ws("/ws/{key}") { ctx, configurer -> + ws("/ws") { ctx, configurer -> configurer.onMessage { ws, message -> val myobject = message.to() // <2> ws.render(myobject) // <3> @@ -125,38 +125,40 @@ import io.jooby.jackson.JacksonModule; } ---- -<1> Install Jackson module (required for JSON decoding/encoding) -<2> Parse/decode message to `MyObject` -<3> Encode myobject as JSON and send to client +<1> Install the Jackson module (required for JSON decoding/encoding). +<2> Parse and decode the incoming message to a `MyObject`. +<3> Encode `myobject` as JSON and send it to the client. -Alternative you explicit tells with decoder/encoder to use consumes/produces attributes: +Alternatively, you can explicitly tell the WebSocket which decoder/encoder to use by specifying the `consumes` and `produces` attributes: -.Context +.Explicit Content Types [source,java,role="primary"] ---- import io.jooby.jackson.JacksonModule; { - install(new JackonModule()); // <1> + install(new JacksonModule()); // <1> ws("/ws", (ctx, configurer) -> { configurer.onMessage((ws, message) -> { MyObject myobject = message.to(MyObject.class); // <2> ws.render(myobject); // <3> - }) + }); }) - .consumes(MediaType.json) - .produces(MediaType.json); + .consumes(MediaType.json) + .produces(MediaType.json); } ---- .Kotlin [source,kotlin,role="secondary"] ---- +import io.jooby.jackson.JacksonModule + { install(JacksonModule()) // <1> - ws("/ws/{key}") { ctx, configurer -> + ws("/ws") { ctx, configurer -> configurer.onMessage { ws, message -> val myobject = message.to() // <2> ws.render(myobject) // <3> @@ -166,14 +168,10 @@ import io.jooby.jackson.JacksonModule; } ---- -Structure messages depends/requires a javadoc:MessageDecoder[] and javadoc:MessageEncoder[]. In this -example both are provided by the JacksonModule. - -=== Options +==== Options -==== Connection Timeouts -Jooby timeouts idle connections that have no activity after 5 minutes. You can -control this behaviour by setting the `websocket.idleTimeout` property: +===== Connection Timeouts +Jooby automatically times out idle connections that have no activity after 5 minutes. You can control this behavior by setting the `websocket.idleTimeout` property in your configuration file: .application.conf [source, properties] @@ -181,11 +179,11 @@ control this behaviour by setting the `websocket.idleTimeout` property: websocket.idleTimeout = 1h ---- -See https://github.com/lightbend/config/blob/master/HOCON.md#duration-format[duration format] +See the Typesafe Config documentation for the supported https://github.com/lightbend/config/blob/master/HOCON.md#duration-format[duration format]. -==== Max size +===== Max Size -Max size is set to `128K` you can override it like: +The maximum message size is set to `128K` by default. You can override it using the `websocket.maxSize` property: .application.conf [source, properties] @@ -193,4 +191,4 @@ Max size is set to `128K` you can override it like: websocket.maxSize = 128K ---- -See https://github.com/lightbend/config/blob/master/HOCON.md#size-in-bytes-format[sizes in bytes] +See the Typesafe Config documentation for the supported https://github.com/lightbend/config/blob/master/HOCON.md#size-in-bytes-format[size in bytes format]. diff --git a/docs/js/styles/theme.css b/docs/js/styles/theme.css new file mode 100644 index 0000000000..0b781eb15e --- /dev/null +++ b/docs/js/styles/theme.css @@ -0,0 +1,1070 @@ +/* ========================================================================== + 1. CSS Variables & Theming + ========================================================================== */ +:root { + /* Brand Colors */ + --jooby-blue: #2196f3; + --jooby-blue-hover: #1976d2; + --jooby-accent: #ffa726; + + /* Light Theme (Default) */ + --bg-main: #ffffff; + --bg-surface: #f8f9fa; + --bg-callout: #f3f4f6; + --border-color: #e5e7eb; + + --text-main: #374151; + --text-muted: #6b7280; + --heading-color: #111827; + --link-color: var(--jooby-blue); + + /* Code Blocks */ + --code-bg: #282c34; /* Update this to match Atom One Dark perfectly */ + --code-text: #ffffff; + --code-inline-bg: #f1f5f9; + --code-inline-text: #be185d; + + /* Layout */ + --sidebar-width: 300px; + --content-max-width: 900px; + --border-radius: 6px; + + /* Modern Developer Font Stack */ + --font-code: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + /* 1. The Modern Developer System Stack + This bypasses old default fonts and uses the highly-optimized UI font of whatever OS the user is on (San Francisco on Mac, Segoe UI on Windows, Roboto on Android) */ + --font-main: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; +} + +body { + font-family: var(--font-main); + font-size: 16px; /* Ensure a solid base size */ + line-height: 1.65; /* Gives the lines a bit more breathing room */ + + /* 2. Dark Mode Legibility Magic + These three properties stop the text from looking thin/jagged on dark backgrounds */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + + /* 3. A slightly crisper text color. + Pure white (#FFF) is too harsh, but a bright slate grey pops beautifully */ + color: #e2e8f0; +} + +/* Ensure standard paragraphs use the spacing properly */ +p { + margin-top: 0; + margin-bottom: 1.25rem; +} + +/* Optional polish for your bold text to make it stand out better against the new crisp text */ +strong, b { + font-weight: 600; + color: #f8fafc; /* Slightly brighter than normal text */ +} + +pre.highlightjs, .highlightjs code { + background: var(--code-bg) !important; +} + +/* Optional: Make the line under the tabs a bit softer to match the new color */ +.switch { + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +html[data-theme="dark"] { + --bg-main: #0f172a; + --bg-surface: #1e293b; + --bg-callout: #1e293b; + --border-color: #334155; + + --text-main: #cbd5e1; + --text-muted: #94a3b8; + --heading-color: #f8fafc; + --link-color: #38bdf8; + + --code-inline-bg: #1e293b; + --code-inline-text: #f472b6; +} + +/* ========================================================================== + 2. Base & Typography (TIGHTENED SPACING) + ========================================================================== */ +html { scroll-padding-top: 80px; } +*, ::before, ::after { box-sizing: border-box; } + +body { + background: var(--bg-main); + color: var(--text-main); + font-family: 'Open Sans', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + line-height: 1.6; + margin: 0; + padding: 0; + -webkit-font-smoothing: antialiased; +} + +/* Tighter Headings */ +h1, h2, h3, h4, h5, h6 { + color: var(--heading-color); + font-weight: 600; + line-height: 1.3; + margin-top: 1.5em; + margin-bottom: 0.5rem; +} + +h1 { font-size: 2.5rem; margin-top: 1em; letter-spacing: -0.02em; } +h2 { font-size: 1.875rem; border-bottom: 1px solid var(--border-color); padding-bottom: 0.3em; margin-top: 2em; } +h3 { font-size: 1.5rem; } +h4 { font-size: 1.25rem; } /* 20px */ +h5 { font-size: 1.125rem; } /* 18px - Just slightly larger than body text */ + +/* Optional: Make h6 distinct since it's the same size as body text */ +h6 { + font-size: 1rem; /* 16px - Same as body */ + color: var(--text-muted); + letter-spacing: 0.05em; +} + +/* ASCIIDOCTOR FIX: Pull subheadings closer when they immediately follow a parent section */ +.sectionbody > .sect2:first-child > h3, +.sect2 > .sect3:first-child > h4, +.sect3 > .sect4:first-child > h5 { + margin-top: 0.75em; +} + +p, table, blockquote { margin-top: 0; margin-bottom: 1rem; } + +a { color: var(--link-color); text-decoration: none; transition: color 0.15s ease; } +a:hover { text-decoration: underline; color: var(--jooby-blue-hover); } +hr { border: none; border-bottom: 1px solid var(--border-color); margin: 2rem 0; } + +/* ASCIIDOCTOR FIX: Fix bloated lists caused by

tags inside

  • */ +ul, ol, dl { margin-top: 0; margin-bottom: 1rem; } +li { margin-bottom: 0.35rem; } +.ulist li p, .olist li p, .dlist li p { margin-bottom: 0; } /* Kills the double-spacing */ + +/* ========================================================================== + 3. Main Content Layout + ========================================================================== */ +#header { padding: 0; margin: 0; } + +@media screen and (min-width: 768px) { + body.toc2 { padding-left: var(--sidebar-width); } + #content, #footer { + max-width: var(--content-max-width); + margin: 0 auto; + padding: 2.5rem 3rem; + } +} + +@media screen and (max-width: 767px) { + #content, #footer { padding: 1.5rem; } +} + +#content > *:first-child, +#content > #preamble:first-child .sectionbody > *:first-child { + margin-top: 0 !important; +} + +/* ========================================================================== + 4. Table of Contents (Left Sidebar) + ========================================================================== */ +@media screen and (min-width: 768px) { + #toc.toc2 { + position: fixed; + top: 0; + left: 0; + width: var(--sidebar-width); + height: 100vh; + overflow-y: auto; + background: var(--bg-surface); + border-right: 1px solid var(--border-color); + padding: 2.5rem 1.5rem; + z-index: 1000; + } +} + +@media (max-width: 767px) { #toc.toc2 { display: none; } } + +#toctitle { + font-weight: bold; + font-size: 1.1rem; + color: var(--heading-color); + margin-top: 0; + margin-bottom: 1.2rem; + padding-left: 0.2rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +#toc ul { list-style: none; margin: 0; padding: 0; font-family: inherit; } +#toc ul ul { display: none; padding-left: 1.1rem; border-left: 1px solid var(--border-color); margin: 0.2rem 0; } +#toc li.expanded > ul { display: block !important; } + +#toc a { + display: block; + padding: 0.35rem 0; + color: var(--text-muted); + font-size: 0.92rem; + line-height: 1.4; + border-left: 3px solid transparent; + transition: all 0.15s ease-in-out; + word-break: break-word; +} + +#toc a:hover { color: var(--jooby-blue); text-decoration: none; } +#toc a.active { + color: var(--jooby-blue) !important; + font-weight: 600; + border-left-color: var(--jooby-blue); + padding-left: 0.8rem; + background-color: rgba(33, 150, 243, 0.04); +} + +#toc::-webkit-scrollbar { width: 5px; } +#toc::-webkit-scrollbar-track { background: transparent; } +#toc::-webkit-scrollbar-thumb { background: #e0e0e0; border-radius: 10px; } +#toc::-webkit-scrollbar-thumb:hover { background: #cccccc; } +html[data-theme="dark"] #toc::-webkit-scrollbar-thumb { background: #475569; } +html[data-theme="dark"] #toc::-webkit-scrollbar-thumb:hover { background: #64748b; } + +/* ========================================================================== + 5. Admonitions (Callouts) + ========================================================================== */ +.admonitionblock { margin-bottom: 1.5rem; } + +/* Override Asciidoctor's rigid table layout with modern Flexbox */ +.admonitionblock > table, +.admonitionblock > table > tbody, +.admonitionblock > table > tbody > tr { + display: flex; + width: 100%; +} + +.admonitionblock > table { + background: var(--admonition-bg, var(--bg-callout)); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + border-left: 4px solid var(--admonition-color, var(--jooby-blue)); + padding: 1.25rem; +} + +/* Style the Left Column (Label & Icon) */ +.admonitionblock td.icon { + border: none; + padding: 0; + padding-right: 1.25rem; + flex-shrink: 0; + display: flex; + align-items: flex-start; +} + +/* The Text "Note", "Tip", etc. */ +.admonitionblock td.icon .title { + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 0.8rem; + color: var(--admonition-color, var(--jooby-blue)); + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* Style the Main Content */ +.admonitionblock td.content { + border: none; + padding: 0; + font-size: 0.95rem; + color: var(--text-main); + line-height: 1.6; +} + +.admonitionblock td.content p:last-child { margin-bottom: 0; } + +/* --- Color Variants & Zero-Dependency CSS Icons --- */ + +/* NOTE (Blue) */ +.admonitionblock.note { + --admonition-color: var(--jooby-blue); + --admonition-bg: rgba(33, 150, 243, 0.05); /* Subtle 5% tint */ +} +.admonitionblock.note td.icon .title::before { + content: url('data:image/svg+xml;utf8,'); + display: block; width: 16px; height: 16px; +} + +/* TIP (Green) */ +.admonitionblock.tip { + --admonition-color: #10b981; + --admonition-bg: rgba(16, 185, 129, 0.05); +} +.admonitionblock.tip td.icon .title::before { + content: url('data:image/svg+xml;utf8,'); + display: block; width: 16px; height: 16px; +} + +/* WARNING (Orange/Yellow) */ +.admonitionblock.warning { + --admonition-color: #f59e0b; + --admonition-bg: rgba(245, 158, 11, 0.05); +} +.admonitionblock.warning td.icon .title::before { + content: url('data:image/svg+xml;utf8,'); + display: block; width: 16px; height: 16px; +} + +/* IMPORTANT (Red) */ +.admonitionblock.important { + --admonition-color: #ef4444; + --admonition-bg: rgba(239, 68, 68, 0.05); +} +.admonitionblock.important td.icon .title::before { + content: url('data:image/svg+xml;utf8,'); + display: block; width: 16px; height: 16px; +} + +/* ========================================================================== + 6. Code Blocks & Inline Code + ========================================================================== */ +pre { + position: relative; + background: var(--code-bg); + color: var(--code-text); + padding: 1rem 1.25rem; + border-radius: var(--border-radius); + overflow-x: auto; + + /* Use the new font */ + font-family: var(--font-code); + font-size: 0.85rem; + font-weight: 400; + line-height: 1.6; /* Slightly increased for better code readability */ + letter-spacing: -0.01em; /* Crisp rendering */ + + margin-top: 0; + margin-bottom: 1rem; +} + +/* Also update your inline code blocks */ +:not(pre) > code { + font-family: var(--font-code); +} + +pre code, pre .hljs { + color: var(--code-text); + background: transparent !important; + padding: 0 !important; + font-size: inherit; +} + +/* ========================================================================== + 6. Code Blocks & Inline Code + ========================================================================== */ + +/* 1. Style the Title as a Mac/IDE File Tab */ +.listingblock .title { + background-color: #21252b; /* Slightly darker than the code background */ + color: #abb2bf; /* Muted terminal gray */ + font-family: var(--font-code); + font-size: 0.78rem; + padding: 0.5rem 1.25rem; + margin-bottom: 0; /* Connects it to the block below */ + border-top-left-radius: var(--border-radius); + border-top-right-radius: var(--border-radius); + border-bottom: 1px solid rgba(0, 0, 0, 0.4); + display: block; +} + +/* 2. Remove the top rounded corners of the code block so it attaches perfectly */ +.listingblock .title + .content pre { + border-top-left-radius: 0; + border-top-right-radius: 0; + margin-top: 0; +} + +/* ========================================================================== + 7. Custom UI: Tabs (Switch) & Clipboard + ========================================================================== */ +.hidden { display: none !important; } +.primary > .title { display: none; } + +.switch { + display: flex; + background-color: var(--code-bg); + border-radius: var(--border-radius) var(--border-radius) 0 0; + overflow: hidden; + border-bottom: 1px solid #444; + margin-bottom: 0; +} + +.switch--item { + padding: 0.6rem 1.25rem; + font-size: 0.85rem; + font-weight: 600; + color: #999; + cursor: pointer; + background-color: var(--code-bg); + transition: all 0.2s ease; + user-select: none; +} + +.switch--item:hover { color: #fff; background-color: #444; } +.switch--item.selected { + color: var(--jooby-accent); + background-color: #222; + box-shadow: inset 0 -2px 0 var(--jooby-accent); +} + +.primary .content pre { + margin-top: 0 !important; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.clipboard { + position: absolute; + top: 0.5rem !important; + right: 0.5rem !important; + bottom: auto !important; + height: 32px; + width: 32px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + padding: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + opacity: 0; +} + +pre:hover .clipboard { opacity: 1; } +.clipboard:hover { background: rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 0.4); } +.clipboard:active { background: rgba(255, 255, 255, 0.3); } +.clipboard img { filter: invert(1); opacity: 0.8; height: 16px; width: 16px; } + +/* ========================================================================== + 8. Theme Toggle Button Styles + ========================================================================== */ +.theme-toggle { + position: fixed; + bottom: 2rem; + right: 2rem; + background: var(--bg-surface); + color: var(--text-main); + border: 1px solid var(--border-color); + border-radius: 50%; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + z-index: 1001; + transition: all 0.2s ease; +} + +.theme-toggle:hover { + transform: scale(1.1); + border-color: var(--jooby-blue); + color: var(--jooby-blue); +} + +.theme-toggle svg { width: 22px; height: 22px; fill: currentColor; } +.theme-toggle .moon-icon { display: none; } +html[data-theme="dark"] .theme-toggle .moon-icon { display: block; } +html[data-theme="dark"] .theme-toggle .sun-icon { display: none; } + +/* ========================================================================== + 9. Header Anchor Links (Deep Linking) + ========================================================================== */ +h2, h3, h4, h5, h6 { + position: relative; +} + +h2 > a.anchor, +h3 > a.anchor, +h4 > a.anchor, +h5 > a.anchor, +h6 > a.anchor { + position: absolute; + left: -1.5rem; + top: 0; + text-decoration: none; + opacity: 0; + transition: opacity 0.2s ease; + color: var(--text-muted); + font-weight: 400; +} + +h2 > a.anchor::before, +h3 > a.anchor::before, +h4 > a.anchor::before, +h5 > a.anchor::before, +h6 > a.anchor::before { + content: "#"; /* Modern hash symbol instead of the old section mark */ +} + +/* Show the anchor on hover */ +h2:hover > a.anchor, +h3:hover > a.anchor, +h4:hover > a.anchor, +h5:hover > a.anchor, +h6:hover > a.anchor { + opacity: 1; +} + +h2 > a.anchor:hover, +h3 > a.anchor:hover, +h4 > a.anchor:hover, +h5 > a.anchor:hover, +h6 > a.anchor:hover { + color: var(--jooby-blue); +} + +/* ========================================================================== + 10. Hero Section (Preamble) + ========================================================================== */ +#preamble .sectionbody > h2.discrete { + font-size: 3.5rem; + font-weight: 800; + letter-spacing: -0.03em; + margin-top: 0; + margin-bottom: 1rem; + /* Optional: A subtle gradient using Jooby's blue to accent */ + background: linear-gradient(135deg, var(--jooby-blue) 0%, #8b5cf6 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +#preamble .sectionbody > .paragraph:first-of-type p { + font-size: 1.25rem; + color: var(--text-muted); + max-width: 800px; + line-height: 1.7; +} + +/* ========================================================================== + 12. Print Styles (For "Save to PDF") + ========================================================================== */ +@media print { + /* Force light theme for printing */ + :root { + --bg-main: #ffffff !important; + --text-main: #000000 !important; + --heading-color: #000000 !important; + } + + body { + background: white !important; + color: black !important; + font-size: 11pt; /* Better for paper */ + } + + /* Hide UI elements */ + #toc, .theme-toggle, .clipboard, .version-selector, .switch { + display: none !important; + } + + /* Reset layout constraints */ + body.toc2, #content, #header, #footer { + padding: 0 !important; + margin: 0 !important; + max-width: 100% !important; + } + + /* Prevent awkward page breaks */ + h2, h3, h4, h5 { page-break-after: avoid; } + pre, blockquote, table, img { page-break-inside: avoid; } + + /* Show link URLs explicitly on paper */ + #content a::after { + content: " (" attr(href) ")"; + font-size: 0.85em; + color: #666; + } + /* Don't print URLs for internal anchor links */ + #content a[href^="#"]::after { + content: ""; + } +} + +.badge { + display: inline-block; + padding: 0.15em 0.5em; + font-size: 0.75em; + font-weight: 700; + border-radius: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + vertical-align: middle; + margin-left: 0.5em; +} + +.badge.experimental { background: #f59e0b; color: #fff; } +.badge.deprecated { background: #ef4444; color: #fff; } +.badge.new { background: #10b981; color: #fff; } + +/* ========================================================================== + 13. Callouts (Code Pointers) + ========================================================================== */ + +/* 1. The badge INSIDE the code block (created via JavaScript) */ +.conum-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + background-color: var(--code-inline-text); /* The Pink/Red */ + color: #ffffff; + font-size: 0.85rem; + font-weight: 700; + font-style: normal; +} + +/* 2. The list BELOW the code block */ +.colist { + margin-top: 1rem; + margin-bottom: 1.5rem; +} + +.colist table { border: none !important; background: transparent !important; margin: 0 !important;} +.colist tr { background: transparent !important; } +.colist td { border: none !important; padding: 0.4rem 0.5rem !important; vertical-align: top !important; color: var(--text-main); } +.colist td:first-child { padding-left: 0 !important; width: 2.5rem !important; } + +/* The dark circle in the list below the code block */ +.colist .conum { + display: inline-flex !important; + align-items: center; + justify-content: center; + width: 1.5rem !important; + height: 1.5rem !important; + border-radius: 50% !important; + background-color: var(--text-main) !important; + color: var(--bg-main) !important; + font-size: 0.85rem !important; + font-weight: 700 !important; + font-style: normal !important; + position: relative; +} + +/* Hide fallback text Asciidoctor injects */ +.colist i.conum[data-value] { color: transparent !important; } +.colist i.conum[data-value]::after { + content: attr(data-value); + color: var(--bg-main) !important; + position: absolute; + top: 50%; left: 50%; + transform: translate(-50%, -50%); +} +.colist .conum + b { display: none !important; } + +/* ========================================================================== + 13. Callouts (Code Pointers & Lists) + ========================================================================== */ + +/* 1. Prevent the Flash: Keep background solid, micro-fade the text only */ +pre.highlightjs code { + opacity: 0; + transition: opacity 0.05s ease-out; +} +pre.highlightjs.badges-loaded code { + opacity: 1; +} + +/* 2. The Pink Badge INSIDE the code block */ +.conum-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + background-color: var(--code-inline-text); + color: #ffffff !important; + font-size: 0.85rem; + font-weight: 700; + font-style: normal; + flex-shrink: 0; +} + +/* 3. The List BELOW the code block */ +.colist.arabic ol { + list-style: none !important; + padding-left: 0; + margin-top: 1.5rem; + counter-reset: colist-counter; +} + +.colist.arabic li { + margin-bottom: 1.25rem; + counter-increment: colist-counter; + position: relative; + /* This creates a "gutter" for the badge */ + padding-left: 2.5rem; + min-height: 1.5rem; +} + +/* Draws the Dark Badge */ +.colist.arabic li::before { + content: counter(colist-counter); + position: absolute; + left: 0; + top: 0; + display: flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + background-color: var(--text-main); + color: var(--bg-main); + font-size: 0.85rem; + font-weight: 700; +} + +/* Ensures all nested content (paragraphs, ulist, etc.) aligns to the right of the badge */ +.colist.arabic li > *:first-child { + display: block; + margin-top: 0; +} + +.colist.arabic li p { + margin-bottom: 0.5rem; + line-height: 1.5rem; +} + +/* Nested Bullet Lists */ +.colist.arabic li .ulist { + margin-top: 0.5rem; + margin-bottom: 0.5rem; +} + +.colist.arabic li .ulist ul { + list-style: disc; + padding-left: 1.25rem; /* Standard bullet indentation */ + margin: 0; +} + +.colist.arabic li .ulist li { + padding-left: 0; /* Reset the callout padding for nested bullets */ + margin-bottom: 0.25rem; + counter-increment: none; +} + +.colist.arabic li .ulist li::before { + content: none; /* No badges on nested bullets */ +} + +/* --- Utilities --- */ + +.visually-hidden { + position: absolute !important; width: 1px !important; height: 1px !important; + padding: 0 !important; margin: -1px !important; overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; white-space: nowrap !important; border: 0 !important; +} + +h2 > a.anchor.copied::before, h3 > a.anchor.copied::before, h4 > a.anchor.copied::before, +h5 > a.anchor.copied::before, h6 > a.anchor.copied::before { + content: "✓"; color: #10b981; +} + +/* ========================================================================== + 14. Tables + ========================================================================== */ + +table.tableblock { + width: 100%; + /* Switch back to fixed to strictly honor [cols="1,1,4"] */ + table-layout: fixed !important; + border-collapse: separate; + border-spacing: 0; + margin: 1.5rem 0; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + overflow: hidden; +} + +/* 1. Reset all columns to allow wrapping by default */ +table.tableblock td, +table.tableblock th { + padding: 0.75rem 1.25rem; + border-bottom: 1px solid var(--border-color); + font-size: 0.9rem; + line-height: 1.6; + text-align: left; + vertical-align: top; + word-wrap: break-word; + overflow-wrap: anywhere; +} + +/* 2. Header Polish */ +table.tableblock thead th { + background-color: var(--bg-surface); + font-weight: 600; + border-bottom: 2px solid var(--border-color); + color: var(--heading-color); + white-space: nowrap; /* Keep headers clean on one line */ +} + +/* 3. Smart First Column Handling + If you have NOT defined specific columns, we force it to fit. + If you HAVE defined [cols], we let it wrap to fit your proportions. */ +table.tableblock:not(:has(colgroup col[style*="width"])) td:first-child { + white-space: nowrap; + width: 1%; /* Shrink-wrap effect */ +} + +/* 4. Column Spacing & Visuals */ +table.tableblock td:first-child { + font-weight: 500; + color: var(--heading-color); +} + +/* Ensure code blocks inside tables don't have extra margins */ +table.tableblock td p { + margin: 0; +} +table.tableblock td p + p { + margin-top: 0.5rem; +} + +table.tableblock tbody tr:hover { background-color: var(--bg-callout); } +table.tableblock tbody tr:last-child td { border-bottom: none; } + +/* ========================================================================== + 15. Keyboard Shortcuts (kbd) + ========================================================================== */ +kbd { + display: inline-block; + padding: 0.1rem 0.4rem; + font-family: var(--font-code); /* Uses JetBrains Mono for that tech feel */ + font-size: 0.8rem; + font-weight: 500; + color: var(--text-main); + background-color: var(--bg-surface); + /* The "3D" Key Effect */ + border: 1px solid var(--border-color); + border-radius: 4px; + box-shadow: + 0 1px 0 rgba(0, 0, 0, 0.2), + inset 0 0 0 2px var(--bg-main); + margin: 0 0.15rem; + vertical-align: middle; + line-height: 1.2; +} + +/* ========================================================================== + 16. Responsive / Mobile Adjustments + ========================================================================== */ + +@media screen and (max-width: 768px) { + /* 1. Create a top bar so the button isn't just floating over text */ + #header { + padding-top: 4rem !important; /* Push content down to clear the bar */ + } + + .mobile-nav-bar { + display: flex; + align-items: center; + justify-content: space-between; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 3.5rem; + background-color: var(--bg-main); + border-bottom: 1px solid var(--border-color); + padding: 0 1.5rem; + z-index: 10001; /* Higher than the sidebar */ + } + + /* 2. Reposition the button inside the new bar */ + #menu-toggle { + position: static !important; /* Remove the fixed positioning from before */ + margin-left: auto; + } +} + +@media screen and (max-width: 768px) { + /* 1. Make tables scrollable horizontally without breaking the page */ + .tableblock { + display: block; + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */ + } + + /* 2. Prevent the "First Column" from taking up too much room on mobile */ + table.tableblock td:first-child { + white-space: normal !important; /* Allow wrapping on small screens */ + min-width: 120px; + } + + /* 3. Slightly reduce padding to save precious horizontal space */ + table.tableblock th, + table.tableblock td { + padding: 0.5rem 0.75rem !important; + font-size: 0.85rem; + } +} + +/* Hamburger Button Styling */ +.hamburger { + display: none; /* Hidden by default on Desktop */ + flex-direction: column; + justify-content: space-around; + width: 2rem; + height: 2rem; + background: transparent; + border: none; + cursor: pointer; + padding: 0; + z-index: 1001; +} + +.hamburger span { + width: 2rem; + height: 0.25rem; + background: var(--text-main); + border-radius: 10px; + transition: all 0.3s linear; +} + +/* Animate Hamburger to X */ +.hamburger.open span:nth-child(1) { + transform: rotate(45deg) translate(5px, 5px); +} +.hamburger.open span:nth-child(2) { + opacity: 0; +} +.hamburger.open span:nth-child(3) { + transform: rotate(-45deg) translate(7px, -6px); +} + +@media screen and (max-width: 768px) { + /* 1. Force the navigation to be a fixed mobile drawer */ + #toc, .nav-container, #sidebar { + position: fixed !important; + top: 0 !important; + left: -100% !important; /* Start off-screen */ + width: 80% !important; /* Take up most of the screen */ + max-width: 300px !important; + height: 100vh !important; + background-color: var(--bg-main) !important; /* Ensure it's not transparent */ + z-index: 9999 !important; /* Sit on top of everything */ + transition: left 0.3s ease-in-out !important; + display: block !important; /* Ensure it's not hidden with display:none */ + box-shadow: 5px 0 15px rgba(0,0,0,0.5); + padding: 2rem 1rem !important; + overflow-y: auto !important; + } + + /* 2. Slide in when the 'active' class is applied */ + #toc.active, .nav-container.active, #sidebar.active { + left: 0 !important; + } + + /* 3. Ensure the hamburger button stays visible and on top */ + #menu-toggle { + display: flex !important; + position: fixed !important; + top: 1rem; + right: 1rem; + z-index: 10000 !important; + background: var(--bg-surface); + padding: 5px; + border-radius: var(--border-radius); + } +} + +@media screen and (max-width: 768px) { + .hamburger { + display: flex; /* Show on Mobile */ + position: fixed; + top: 1rem; + right: 1rem; + } + + /* Target your specific sidebar class (usually .nav-container or #toc) */ + #sidebar, .nav-container { + position: fixed; + top: 0; + left: -100%; /* Hide off-screen */ + width: 280px; + height: 100vh; + background: var(--bg-main); + transition: left 0.3s ease-in-out; + z-index: 1000; + box-shadow: 2px 0 10px rgba(0,0,0,0.3); + } + + /* When the 'active' class is added via JS, slide it in */ + #sidebar.active, .nav-container.active { + left: 0; + } +} + +/* ========================================================================== + 17. Print Styles (Ink-Friendly PDF) + ========================================================================== */ + +@media print { + /* 1. Force a clean white background and black text for legibility */ + body { + background: white !important; + color: black !important; + font-size: 11pt; + } + + /* 2. Remove the sidebar, header, and footer to focus on the content */ + #header, #footer, #sidebar, .nav-container, .edit-link { + display: none !important; + } + + #content { + width: 100% !important; + margin: 0 !important; + padding: 0 !important; + } + + /* 3. Convert Code Blocks to a light theme for the printer */ + pre { + background: #f5f5f5 !important; + color: #333 !important; + border: 1px solid #ddd !important; + page-break-inside: avoid; /* Prevents code from splitting across pages */ + } + + pre code { + color: #333 !important; + } + + /* 4. Ensure Tables expand and borders are visible */ + table.tableblock { + border: 1px solid #999 !important; + page-break-inside: auto; + } + + tr { + page-break-inside: avoid; + page-break-after: auto; + } + + /* 5. Show the actual URL next to links (useful for paper) */ + a[href^="http"]:after { + content: " (" attr(href) ")"; + font-size: 90%; + color: #666; + } +} + diff --git a/docs/js/toc.js b/docs/js/toc.js new file mode 100644 index 0000000000..03ac024481 --- /dev/null +++ b/docs/js/toc.js @@ -0,0 +1,78 @@ +document.addEventListener('DOMContentLoaded', () => { + const toc = document.getElementById('toc'); + if (!toc) return; + + // 1. Check the path once on load + const isModulesPage = window.location.pathname.includes('/modules'); + + const links = Array.from(toc.querySelectorAll('a')); + const sections = []; + + links.forEach(link => { + const href = link.getAttribute('href'); + if (href && href.startsWith('#')) { + const id = href.substring(1); + const target = document.getElementById(id); + if (target) { + sections.push({ link, target }); + } + } + }); + + if (sections.length === 0) return; + + function updateToc() { + const offset = 95; + let activeIndex = -1; + + for (let i = 0; i < sections.length; i++) { + const bounds = sections[i].target.getBoundingClientRect(); + if (bounds.top <= offset) { + activeIndex = i; + } else { + break; + } + } + + const scrollY = window.scrollY || window.pageYOffset; + + if ((window.innerHeight + scrollY) >= document.documentElement.scrollHeight - 10) { + activeIndex = sections.length - 1; + } + + if (scrollY < 50) { + activeIndex = -1; + } + + // 1. Reset all states completely + links.forEach(l => { + l.classList.remove('active'); + let p = l.parentElement; + while (p && p !== toc) { + if (p.tagName === 'LI') p.classList.remove('expanded'); + p = p.parentElement; + } + }); + + // 2. Apply the active path based on scroll position + if (activeIndex !== -1) { + const activeLink = sections[activeIndex].link; + activeLink.classList.add('active'); + let p = activeLink.parentElement; + while (p && p !== toc) { + if (p.tagName === 'LI') p.classList.add('expanded'); + p = p.parentElement; + } + } + + // If we are on the modules page, ensure the first layer is ALWAYS expanded, + // regardless of where we are scrolled. + if (isModulesPage) { + const topLevelLis = toc.querySelectorAll('.sectlevel1 > li'); + topLevelLis.forEach(li => li.classList.add('expanded')); + } + } + + window.addEventListener('scroll', updateToc, { passive: true }); + setTimeout(updateToc, 100); +}); diff --git a/docs/js/tocbot.min.js b/docs/js/tocbot.min.js deleted file mode 100755 index 40c2adfab6..0000000000 --- a/docs/js/tocbot.min.js +++ /dev/null @@ -1 +0,0 @@ -!function(e){function t(o){if(n[o])return n[o].exports;var l=n[o]={i:o,l:!1,exports:{}};return e[o].call(l.exports,l,l.exports,t),l.l=!0,l.exports}var n={};t.m=e,t.c=n,t.d=function(e,n,o){t.o(e,n)||Object.defineProperty(e,n,{configurable:!1,enumerable:!0,get:o})},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},t.p="",t(t.s=0)}([function(e,t,n){(function(o){var l,i,s;!function(n,o){i=[],l=o(n),void 0!==(s="function"==typeof l?l.apply(t,i):l)&&(e.exports=s)}(void 0!==o?o:this.window||this.global,function(e){"use strict";function t(){for(var e={},t=0;te.fixedSidebarOffset?-1===n.className.indexOf(e.positionFixedClass)&&(n.className+=h+e.positionFixedClass):n.className=n.className.split(h+e.positionFixedClass).join("")}function s(t){var n=document.documentElement.scrollTop||f.scrollTop;e.positionFixedSelector&&i();var o,l=t;if(m&&null!==document.querySelector(e.tocSelector)&&l.length>0){d.call(l,function(t,i){if(t.offsetTop>n+e.headingsOffset+10){return o=l[0===i?i:i-1],!0}if(i===l.length-1)return o=l[l.length-1],!0});var s=document.querySelector(e.tocSelector).querySelectorAll("."+e.linkClass);u.call(s,function(t){t.className=t.className.split(h+e.activeLinkClass).join("")});var c=document.querySelector(e.tocSelector).querySelectorAll("."+e.listItemClass);u.call(c,function(t){t.className=t.className.split(h+e.activeListItemClass).join("")});var a=document.querySelector(e.tocSelector).querySelector("."+e.linkClass+".node-name--"+o.nodeName+'[href="#'+o.id+'"]');-1===a.className.indexOf(e.activeLinkClass)&&(a.className+=h+e.activeLinkClass);var p=a.parentNode;p&&-1===p.className.indexOf(e.activeListItemClass)&&(p.className+=h+e.activeListItemClass);var C=document.querySelector(e.tocSelector).querySelectorAll("."+e.listClass+"."+e.collapsibleClass);u.call(C,function(t){-1===t.className.indexOf(e.isCollapsedClass)&&(t.className+=h+e.isCollapsedClass)}),a.nextSibling&&-1!==a.nextSibling.className.indexOf(e.isCollapsedClass)&&(a.nextSibling.className=a.nextSibling.className.split(h+e.isCollapsedClass).join("")),r(a.parentNode.parentNode)}}function r(t){return-1!==t.className.indexOf(e.collapsibleClass)&&-1!==t.className.indexOf(e.isCollapsedClass)?(t.className=t.className.split(h+e.isCollapsedClass).join(""),r(t.parentNode.parentNode)):t}function c(t){var n=t.target||t.srcElement;"string"==typeof n.className&&-1!==n.className.indexOf(e.linkClass)&&(m=!1)}function a(){m=!0}var u=[].forEach,d=[].some,f=document.body,m=!0,h=" ";return{enableTocAnimation:a,disableTocAnimation:c,render:n,updateToc:s}}},function(e,t){e.exports=function(e){function t(e){return e[e.length-1]}function n(e){return+e.nodeName.split("H").join("")}function o(t){var o={id:t.id,children:[],nodeName:t.nodeName,headingLevel:n(t),textContent:t.textContent.trim()};return e.includeHtml&&(o.childNodes=t.childNodes),o}function l(l,i){for(var s=o(l),r=n(l),c=i,a=t(c),u=a?a.headingLevel:0,d=r-u;d>0;)a=t(c),a&&void 0!==a.children&&(c=a.children),d--;return r>=e.collapseDepth&&(s.isCollapsed=!0),c.push(s),c}function i(t,n){var o=n;e.ignoreSelector&&(o=n.split(",").map(function(t){return t.trim()+":not("+e.ignoreSelector+")"}));try{return document.querySelector(t).querySelectorAll(o)}catch(e){return console.warn("Element not found: "+t),null}}function s(e){return r.call(e,function(e,t){return l(o(t),e.nest),e},{nest:[]})}var r=[].reduce;return{nestHeadingsArray:s,selectHeadings:i}}},function(e,t){function n(e){function t(e){return"a"===e.tagName.toLowerCase()&&(e.hash.length>0||"#"===e.href.charAt(e.href.length-1))&&(n(e.href)===s||n(e.href)+"#"===s)}function n(e){return e.slice(0,e.lastIndexOf("#"))}function l(e){var t=document.getElementById(e.substring(1));t&&(/^(?:a|select|input|button|textarea)$/i.test(t.tagName)||(t.tabIndex=-1),t.focus())}!function(){document.documentElement.style}();var i=e.duration,s=location.hash?n(location.href):location.href;!function(){function n(n){!t(n.target)||n.target.className.indexOf("no-smooth-scroll")>-1||"#"===n.target.href.charAt(n.target.href.length-2)&&"!"===n.target.href.charAt(n.target.href.length-1)||-1===n.target.className.indexOf(e.linkClass)||o(n.target.hash,{duration:i,callback:function(){l(n.target.hash)}})}document.body.addEventListener("click",n,!1)}()}function o(e,t){function n(e){s=e-i,window.scrollTo(0,c.easing(s,r,u,d)),s io.jooby.adoc.DocApp - 4.0.7 + 4.0.15 17 17 17 diff --git a/docs/src/main/java/io/jooby/adoc/DocGenerator.java b/docs/src/main/java/io/jooby/adoc/DocGenerator.java index 258374a70b..ec71e58263 100644 --- a/docs/src/main/java/io/jooby/adoc/DocGenerator.java +++ b/docs/src/main/java/io/jooby/adoc/DocGenerator.java @@ -245,22 +245,27 @@ private static Options createOptions(Path basedir, Path outdir, String version, throws IOException { var attributes = Attributes.builder(); + attributes.linkCss(true); + attributes.experimental(true); + attributes.styleSheetName("theme.css"); + attributes.stylesDir("js/styles"); + attributes.setAnchors(true); + attributes.attribute("docinfo", "shared"); + attributes.attribute("docfile", docfile.toString()); attributes.attribute("uiVersion", uiVersion); attributes.attribute("love", "♡"); attributes.attribute("docinfo", "shared"); attributes.title(title == null ? "jooby: do more! more easily!!" : "jooby: " + title); attributes.tableOfContents(Placement.LEFT); - attributes.attribute("toclevels", "3"); + attributes.attribute("toclevels", "5"); attributes.setAnchors(true); attributes.attribute("sectlinks", ""); attributes.sectionNumbers(true); - attributes.attribute("sectnumlevels", "3"); attributes.linkAttrs(true); attributes.noFooter(true); attributes.attribute("idprefix", ""); attributes.attribute("idseparator", "-"); - attributes.icons("font"); attributes.attribute("description", "The modular micro web framework for Java"); attributes.attribute( "keywords", "Java, Modern, Micro, Web, Framework, Reactive, Lightweight, Microservices"); @@ -268,7 +273,7 @@ private static Options createOptions(Path basedir, Path outdir, String version, attributes.sourceHighlighter("highlightjs"); attributes.attribute("highlightjsdir", "js"); // agate, atom-one-dark, tomorrow-night-bright, tokyo-night-dark - attributes.attribute("highlightjs-theme", "agate"); + attributes.attribute("highlightjs-theme", "atom-one-dark"); attributes.attribute("favicon", "images/favicon96.png"); // versions: @@ -283,6 +288,7 @@ private static Options createOptions(Path basedir, Path outdir, String version, attributes.attribute("joobyVersion", version); attributes.attribute("date", DateTimeFormatter.ISO_INSTANT.format(Instant.now())); + attributes.attribute("numbered!", ""); OptionsBuilder options = Options.builder(); options.backend("html5"); diff --git a/docs/src/main/java/io/jooby/adoc/DocPostprocessor.java b/docs/src/main/java/io/jooby/adoc/DocPostprocessor.java index 40e25bc90f..5a1edf228a 100644 --- a/docs/src/main/java/io/jooby/adoc/DocPostprocessor.java +++ b/docs/src/main/java/io/jooby/adoc/DocPostprocessor.java @@ -3,13 +3,19 @@ import static java.util.function.Predicate.not; import java.io.IOException; +import java.util.HashSet; import java.util.LinkedHashSet; +import java.util.Set; import java.util.UUID; + +import edu.umd.cs.findbugs.annotations.NonNull; import org.asciidoctor.extension.Postprocessor; +import org.jcodings.util.Hash; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Document.OutputSettings; import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; public class DocPostprocessor extends Postprocessor { @@ -18,6 +24,7 @@ public String process(org.asciidoctor.ast.Document document, String output) { try { Document doc = Jsoup.parse(output, "UTF-8"); + toc(doc); headerIds(doc); languageTab(doc); clipboard(doc); @@ -79,6 +86,38 @@ private static void headerIds(Document doc) { headerIds(doc, 3); headerIds(doc, 4); headerIds(doc, 5); + headerIds(doc, 6); + } + + private static void toc(Document doc) { + var rootUl = doc.selectFirst("#toc > ul"); + if (rootUl != null) { + tocList("", rootUl); + } + } + + private static void tocList(String prefix, Element parent) { + // In Jsoup, to get direct children of the current element, + // start the query with the '>' combinator. + var items = parent.select("> li"); + + for (var item : items) { + // Get the direct anchor child + var link = item.selectFirst("> a"); + + if (link != null) { + var currentId = link.attr("href").replace("#", ""); + var newFullId = cleanId( prefix + currentId); + + link.attr("href", "#" + newFullId); + + // Recurse into the direct nested list + var nestedUl = item.selectFirst("> ul"); + if (nestedUl != null) { + tocList(newFullId + "-", nestedUl); + } + } + } } private static void headerIds(Document doc, int level) { @@ -95,7 +134,7 @@ private static void headerIds(Document doc, int level) { name.add(parentId); } } - name.add(id.replaceAll("([a-zA-Z0-9-]+)-\\d+$", "$1")); + name.add(cleanId(id)); String newId = String.join("-", name); if (!id.equals(newId)) { h.attr("id", newId); @@ -106,6 +145,11 @@ private static void headerIds(Document doc, int level) { }); } + @NonNull + private static String cleanId(String id) { + return id.replaceAll("-\\d+$", ""); + } + private static boolean isDiscrete(Element e) { return e.hasClass("discrete"); } diff --git a/jooby/pom.xml b/jooby/pom.xml index d3fb5db11e..d6b781528e 100644 --- a/jooby/pom.xml +++ b/jooby/pom.xml @@ -6,7 +6,7 @@ io.jooby jooby-project - 4.0.15 + 4.0.16 jooby jooby diff --git a/jooby/src/main/java/io/jooby/Context.java b/jooby/src/main/java/io/jooby/Context.java index 1f5be9f676..5017687243 100644 --- a/jooby/src/main/java/io/jooby/Context.java +++ b/jooby/src/main/java/io/jooby/Context.java @@ -817,6 +817,19 @@ default Locale locale() { */ Value form(@NonNull String name); + /** + * Get a form field that matches the given name. + * + *

    File upload retrieval is available using {@link Context#file(String)}. + * + *

    Only for multipart/form-data request. + * + * @param name Field name. + * @param defaultValue Default value. + * @return Multipart value. + */ + Value form(@NonNull String name, @NonNull String defaultValue); + /** * Convert form data to the given type. * diff --git a/jooby/src/main/java/io/jooby/DefaultContext.java b/jooby/src/main/java/io/jooby/DefaultContext.java index 40edf0cb25..eec97ffd45 100644 --- a/jooby/src/main/java/io/jooby/DefaultContext.java +++ b/jooby/src/main/java/io/jooby/DefaultContext.java @@ -452,6 +452,11 @@ default Value form(@NonNull String name) { return form().get(name); } + @Override + default Value form(@NonNull String name, @NonNull String defaultValue) { + return form().getOrDefault(name, defaultValue); + } + @Override default T form(@NonNull Class type) { return form().to(type); diff --git a/jooby/src/main/java/io/jooby/ForwardingContext.java b/jooby/src/main/java/io/jooby/ForwardingContext.java index 5d2a8fcfe6..f271c61d11 100644 --- a/jooby/src/main/java/io/jooby/ForwardingContext.java +++ b/jooby/src/main/java/io/jooby/ForwardingContext.java @@ -1001,6 +1001,11 @@ public Value form(@NonNull String name) { return ctx.form(name); } + @Override + public Value form(@NonNull String name, @NonNull String defaultValue) { + return ctx.form(name, defaultValue); + } + @Override public T form(@NonNull Class type) { return ctx.form(type); diff --git a/jooby/src/main/java/io/jooby/Projected.java b/jooby/src/main/java/io/jooby/Projected.java new file mode 100644 index 0000000000..eaeefb3722 --- /dev/null +++ b/jooby/src/main/java/io/jooby/Projected.java @@ -0,0 +1,62 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby; + +import java.util.*; + +/** + * A wrapper for a value and its associated {@link Projection}. + * + * @param The value type. + * @author edgar + * @since 4.0.0 + */ +public class Projected { + private final T value; + private final Projection projection; + + private Projected(T value, Projection projection) { + this.value = value; + this.projection = projection; + } + + @SuppressWarnings("unchecked") + public static Projected wrap(T value) { + return new Projected(value, Projection.of(computeProjectionType(value))); + } + + @SuppressWarnings("rawtypes") + private static Class computeProjectionType(Object value) { + return switch (value) { + case Set col -> col.isEmpty() ? Object.class : col.iterator().next().getClass(); + case Collection col -> col.isEmpty() ? Object.class : col.iterator().next().getClass(); + case Optional optional -> optional.isEmpty() ? Object.class : optional.get().getClass(); + default -> value.getClass(); + }; + } + + public static Projected wrap(T value, Projection projection) { + return new Projected<>(value, projection); + } + + public T getValue() { + return value; + } + + public Projection getProjection() { + return projection; + } + + public Projected include(String... paths) { + projection.include(paths); + return this; + } + + @Override + public String toString() { + return projection.toString(); + } +} diff --git a/jooby/src/main/java/io/jooby/Projection.java b/jooby/src/main/java/io/jooby/Projection.java new file mode 100644 index 0000000000..c3ece608c6 --- /dev/null +++ b/jooby/src/main/java/io/jooby/Projection.java @@ -0,0 +1,516 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +import io.jooby.value.ValueFactory; + +/** + * Hierarchical schema for JSON field selection. A Projection defines exactly which fields of a Java + * object should be serialized to JSON. + * + *

    It supports multiple declaration styles, all of which are validated against the target class + * hierarchy (including unwrapping Collections and Maps) at definition time. + * + *

    1. Dot Notation

    + * + *

    Standard path-based selection for nested objects. + * + *

    {@code
    + * Projection.of(User.class).include("name", "address.city");
    + * }
    + * + *

    2. Avaje Notation

    + * + *

    Parenthesis-based grouping for complex nested graphs, compatible with LinkedIn-style syntax. + * + *

    {@code
    + * Projection.of(User.class).include("id, address(city, zip, geo(lat, lon))");
    + * }
    + * + *

    3. Type-Safe Method References

    + * + *

    Refactor-safe selection using Java method references. These are validated by the compiler. + * + *

    {@code
    + * Projection.of(User.class).include(User::getName, User::getId);
    + * }
    + * + *

    4. Functional Nested DSL

    + * + *

    A type-safe way to define deep projections while maintaining IDE autocomplete for nested + * types. + * + *

    {@code
    + * Projection.of(User.class)
    + * .include(User::getName)
    + * .include(User::getAddress, addr -> addr
    + * .include(Address::getCity)
    + * );
    + * }
    + * + *

    Polymorphism and Validation

    + * + *

    By default, projections strictly validate requested fields against the declared return type + * using reflection. If a field is not found, an {@link IllegalArgumentException} is thrown at + * compilation time. + * + *

    If your route returns polymorphic types (e.g., a {@code List} containing {@code Dog} + * and {@code Cat} instances), strict validation will fail if you request a subclass-specific field + * like {@code barkVolume}. To support polymorphic shaping, you can disable strict validation using + * {@link #validate()} prior to calling {@code include()}: + * + *

    {@code
    + * Projection.of(Animal.class)
    + * .validate(false)
    + * .include("name, barkVolume")
    + * }
    + * + *

    Performance

    + * + *

    Projections are pre-compiled. All reflection and path validation happen during the + * include calls. In a production environment, it is recommended to define Projections as + * static final constants. + * + * @param The root type being projected. + * @author edgar + * @since 4.0.0 + */ +public class Projection { + + private static final Map, String> PROP_CACHE = new ConcurrentHashMap<>(); + + private final Class type; + private final Map> children = new LinkedHashMap<>(); + private String view = ""; + private final boolean root; + private boolean validate; + + private Projection(Class type, boolean root, boolean validate) { + this.type = Objects.requireNonNull(type); + this.root = root; + this.validate = validate; + } + + /** + * Creates a new Projection for the given type. + * + * @param Root type. + * @param type Root class. + * @return A new Projection instance. + */ + public static Projection of(Class type) { + return new Projection<>(type, true, false); + } + + /** + * Includes fields via string notation. Supports both Dot notation ({@code a.b}) and Avaje + * notation ({@code a(b,c)}). + * + * @param paths Field paths to include. + * @return This projection instance. + * @throws IllegalArgumentException If a field name is not found on the class hierarchy. + */ + public Projection include(String... paths) { + for (String path : paths) { + if (path == null || path.isEmpty()) continue; + validateParentheses(path); + for (String segment : splitByComma(path)) { + parseAndValidate(segment.trim()); + } + } + rebuild(); + return this; + } + + public Map> getChildren() { + return Collections.unmodifiableMap(children); + } + + /** + * Configures whether the projection should fail when a requested property is not found on the + * declared class type. + * + * @return This projection instance. + */ + public Projection validate() { + this.validate = true; + return this; + } + + /** Determines if a type is a simple/scalar value that cannot be further projected. */ + private boolean isSimpleType(Type type) { + var valueFactory = new ValueFactory(); + return valueFactory.get(type) != null; + } + + /** + * Returns the Avaje-compatible DSL string. + * + * @return The pre-compiled view string. + */ + public String toView() { + return view; + } + + public Class getType() { + return type; + } + + private void validateParentheses(String path) { + int depth = 0; + for (int i = 0; i < path.length(); i++) { + char c = path.charAt(i); + if (c == '(') { + depth++; + } else if (c == ')') { + depth--; + } + + // If depth drops below 0, we have an extra closing parenthesis like "id)" + if (depth < 0) { + throw new IllegalArgumentException("Mismatched parentheses in projection: " + path); + } + } + + // If depth is not 0 at the end, we are missing a closing parenthesis + if (depth > 0) { + throw new IllegalArgumentException("Missing closing parenthesis in projection: " + path); + } + } + + private void parseAndValidate(String path) { + if (path == null || path.trim().isEmpty()) return; + path = path.trim(); + + // 1. Root-level grouping: "(id, name, address)" + if (path.startsWith("(") && path.endsWith(")")) { + String content = path.substring(1, path.length() - 1).trim(); + for (String p : splitByComma(content)) { + parseAndValidate(p); + } + return; + } + + int parenIdx = path.indexOf('('); + int dotIdx = path.indexOf('.'); + + // 2. Nested grouping: "address(city, loc)" or "address(*)" + if (parenIdx != -1 && (dotIdx == -1 || parenIdx < dotIdx)) { + String parentName = path.substring(0, parenIdx).trim(); + if (parentName.isEmpty()) return; + + String content = path.substring(parenIdx + 1, path.lastIndexOf(')')).trim(); + + Class childType = resolveFieldType(this.type, parentName); + Projection child = + children.computeIfAbsent(parentName, k -> new Projection<>(childType, false, validate)); + + for (String p : splitByComma(content)) { + p = p.trim(); + // Ignore explicit wildcard to leave children map empty (triggering allow-all later) + if (!p.equals("*") && !p.isEmpty()) { + child.parseAndValidate(p); + } + } + child.rebuild(); + } + // 3. Dot notation: "address.city" + else if (dotIdx != -1) { + String parentName = path.substring(0, dotIdx).trim(); + String content = path.substring(dotIdx + 1).trim(); + + Class childType = resolveFieldType(this.type, parentName); + Projection child = + children.computeIfAbsent(parentName, k -> new Projection<>(childType, false, validate)); + + if (!content.equals("*") && !content.isEmpty()) { + child.parseAndValidate(content); + } + child.rebuild(); + } + // 4. Flat field: "id" + else { + if (!path.equals("*")) { + Class childType = resolveFieldType(this.type, path); + children.computeIfAbsent(path, k -> new Projection<>(childType, false, validate)); + } + } + } + + private List splitByComma(String s) { + List result = new ArrayList<>(); + int depth = 0; + StringBuilder sb = new StringBuilder(); + for (char c : s.toCharArray()) { + if (c == '(') depth++; + else if (c == ')') depth--; + + if (c == ',' && depth == 0) { + result.add(sb.toString()); + sb.setLength(0); + } else { + sb.append(c); + } + } + result.add(sb.toString()); + return result; + } + + private void rebuild() { + StringBuilder buffer = new StringBuilder(); + int i = 0; + for (Map.Entry> entry : children.entrySet()) { + if (i > 0) { + buffer.append(","); + } + + buffer.append(entry.getKey()); + Projection child = entry.getValue(); + + if (!child.getChildren().isEmpty()) { + // Node has explicit children, recurse normally + buffer.append("(").append(child.toView()).append(")"); + } else { + // Option 3: Deep Smart Wildcard injection + Class childType = child.type; + if (!childType.isPrimitive() && !childType.getName().startsWith("java.")) { + // It's a complex POJO with no explicit children. + // We must build a full explicit wildcard string for Avaje. + String deepWildcard = buildDeepWildcard(childType); + if (!deepWildcard.isEmpty()) { + buffer.append("(").append(deepWildcard).append(")"); + } + } + } + i++; + } + + String result = buffer.toString(); + + // Ensure root-level multi-fields are strictly wrapped for Avaje + if (root && !result.startsWith("(") && result.contains(",")) { + this.view = "(" + result + ")"; + } else { + this.view = result; + } + } + + private String buildDeepWildcard(Class type) { + return buildDeepWildcard(type, new HashSet<>()); + } + + private String buildDeepWildcard(Class type, Set> seen) { + if (type == null || type.isPrimitive() || type.getName().startsWith("java.")) { + return ""; + } + + if (!seen.add(type)) { + return ""; + } + + Map properties = new TreeMap<>(); + + // 1. Getters FIRST (The ultimate source of truth for JSON serialization) + for (Method method : type.getMethods()) { + if (method.getDeclaringClass() == Object.class + || method.getParameterCount() > 0 + || java.lang.reflect.Modifier.isStatic(method.getModifiers())) { + continue; + } + + String methodName = method.getName(); + String propName = null; + + if (methodName.startsWith("get") && methodName.length() > 3) { + propName = Character.toLowerCase(methodName.charAt(3)) + methodName.substring(4); + } else if (methodName.startsWith("is") && methodName.length() > 2) { + Class retType = method.getReturnType(); + if (retType == boolean.class || retType == Boolean.class) { + propName = Character.toLowerCase(methodName.charAt(2)) + methodName.substring(3); + } + } + + if (propName != null) { + properties.putIfAbsent(propName, method.getGenericReturnType()); + } + } + + // 2. Fields SECOND (Fallback for properties without getters, like Java Records or plain fields) + Class currentClass = type; + while (currentClass != null && currentClass != Object.class) { + for (java.lang.reflect.Field field : currentClass.getDeclaredFields()) { + int modifiers = field.getModifiers(); + if (java.lang.reflect.Modifier.isStatic(modifiers) + || java.lang.reflect.Modifier.isTransient(modifiers)) { + continue; + } + // Only adds the field if a getter didn't already claim this property name + properties.putIfAbsent(field.getName(), field.getGenericType()); + } + currentClass = currentClass.getSuperclass(); + } + + // 3. Build the View String + StringBuilder sb = new StringBuilder(); + int count = 0; + + for (Map.Entry entry : properties.entrySet()) { + if (count > 0) sb.append(","); + sb.append(entry.getKey()); + + Type propType = entry.getValue(); + Class rawType = null; + + if (propType instanceof Class) { + rawType = (Class) propType; + } else if (propType instanceof ParameterizedType) { + ParameterizedType paramType = (ParameterizedType) propType; + Type raw = paramType.getRawType(); + + if (raw instanceof Class) { + Class rawClass = (Class) raw; + if (Collection.class.isAssignableFrom(rawClass)) { + Type typeArg = paramType.getActualTypeArguments()[0]; + if (typeArg instanceof Class) rawType = (Class) typeArg; + } else if (Map.class.isAssignableFrom(rawClass)) { + Type typeArg = paramType.getActualTypeArguments()[1]; + if (typeArg instanceof Class) rawType = (Class) typeArg; + } else { + rawType = rawClass; + } + } + } + + if (rawType != null && !rawType.isPrimitive() && !rawType.getName().startsWith("java.")) { + String nested = buildDeepWildcard(rawType, seen); + if (!nested.isEmpty()) { + sb.append("(").append(nested).append(")"); + } + } + count++; + } + + seen.remove(type); + return sb.toString(); + } + + private Class resolveFieldType(Class currentType, String fieldName) { + // 1. If we are already in a dynamic tree, keep returning Object.class + if (currentType == null || currentType == Object.class) { + return Object.class; + } + + Type genericType = null; + Class rawType = null; + + // 2. Try Getters FIRST (The ultimate source of truth for JSON serialization) + String capitalized = Character.toUpperCase(fieldName.charAt(0)) + fieldName.substring(1); + + try { + Method method = currentType.getMethod("get" + capitalized); + rawType = method.getReturnType(); + genericType = method.getGenericReturnType(); + } catch (NoSuchMethodException e1) { + try { + Method method = currentType.getMethod("is" + capitalized); + Class retType = method.getReturnType(); + if (retType == boolean.class || retType == Boolean.class) { + rawType = retType; + genericType = method.getGenericReturnType(); + } + } catch (NoSuchMethodException e2) { + // Ignore + } + } + + // Try record-style / fluent getter if standard getters weren't found + if (rawType == null) { + try { + Method method = currentType.getMethod(fieldName); + rawType = method.getReturnType(); + genericType = method.getGenericReturnType(); + } catch (NoSuchMethodException e3) { + // Ignore + } + } + + // 3. Fallback to Fields SECOND (climbing the hierarchy) + if (rawType == null) { + Class clazz = currentType; + while (clazz != null && clazz != Object.class) { + try { + Field field = clazz.getDeclaredField(fieldName); + rawType = field.getType(); + genericType = field.getGenericType(); + break; // Found it! + } catch (NoSuchFieldException ignored) { + clazz = clazz.getSuperclass(); // Check the parent class + } + } + } + + // 4. Handle Not Found + if (rawType == null) { + // Dynamic map keys fallback + if (currentType.getName().startsWith("java.")) { + return Object.class; + } + if (validate) { + throw new IllegalArgumentException( + "Invalid projection path: '" + + fieldName + + "' not found on " + + currentType.getName() + + " or its superclasses."); + } + return Object.class; + } + + // 5. Unwrap Generics (e.g., List -> Role) + if (genericType instanceof ParameterizedType) { + ParameterizedType paramType = (ParameterizedType) genericType; + + if (Collection.class.isAssignableFrom(rawType)) { + Type typeArg = paramType.getActualTypeArguments()[0]; + if (typeArg instanceof Class) return (Class) typeArg; + } + + if (Map.class.isAssignableFrom(rawType)) { + Type typeArg = paramType.getActualTypeArguments()[1]; // Maps resolve to Value type + if (typeArg instanceof Class) return (Class) typeArg; + } + } + + return rawType; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Projection that = (Projection) o; + return root == that.root + && Objects.equals(type, that.type) + && Objects.equals(children, that.children); + } + + @Override + public int hashCode() { + return Objects.hash(type, children, root); + } + + @Override + public String toString() { + return type.getSimpleName() + view; + } +} diff --git a/jooby/src/main/java/io/jooby/annotation/DELETE.java b/jooby/src/main/java/io/jooby/annotation/DELETE.java index 4eefcf3fd0..720324407a 100644 --- a/jooby/src/main/java/io/jooby/annotation/DELETE.java +++ b/jooby/src/main/java/io/jooby/annotation/DELETE.java @@ -57,4 +57,14 @@ * @return Consume types. */ String[] consumes() default {}; + + /** + * Field paths to include. Supports dot-notation and avaje-notation. It is a shortcut for {@link + * Project}. + * + *

    Example: id, name, address(city, zip) + * + * @return Field paths to include. Supports dot-notation and avaje-notation. + */ + String projection() default ""; } diff --git a/jooby/src/main/java/io/jooby/annotation/GET.java b/jooby/src/main/java/io/jooby/annotation/GET.java index 9d3a93e0c9..a32b1f44e0 100644 --- a/jooby/src/main/java/io/jooby/annotation/GET.java +++ b/jooby/src/main/java/io/jooby/annotation/GET.java @@ -57,4 +57,14 @@ * @return Consume types. */ String[] consumes() default {}; + + /** + * Field paths to include. Supports dot-notation and avaje-notation. It is a shortcut for {@link + * Project}. + * + *

    Example: id, name, address(city, zip) + * + * @return Field paths to include. Supports dot-notation and avaje-notation. + */ + String projection() default ""; } diff --git a/jooby/src/main/java/io/jooby/annotation/PATCH.java b/jooby/src/main/java/io/jooby/annotation/PATCH.java index 96e8ffe158..5ce1fd7311 100644 --- a/jooby/src/main/java/io/jooby/annotation/PATCH.java +++ b/jooby/src/main/java/io/jooby/annotation/PATCH.java @@ -57,4 +57,14 @@ * @return Consume types. */ String[] consumes() default {}; + + /** + * Field paths to include. Supports dot-notation and avaje-notation. It is a shortcut for {@link + * Project}. + * + *

    Example: id, name, address(city, zip) + * + * @return Field paths to include. Supports dot-notation and avaje-notation. + */ + String projection() default ""; } diff --git a/jooby/src/main/java/io/jooby/annotation/POST.java b/jooby/src/main/java/io/jooby/annotation/POST.java index b7a3e6e0d4..3e1fb597f3 100644 --- a/jooby/src/main/java/io/jooby/annotation/POST.java +++ b/jooby/src/main/java/io/jooby/annotation/POST.java @@ -57,4 +57,14 @@ * @return Consume types. */ String[] consumes() default {}; + + /** + * Field paths to include. Supports dot-notation and avaje-notation. It is a shortcut for {@link + * Project}. + * + *

    Example: id, name, address(city, zip) + * + * @return Field paths to include. Supports dot-notation and avaje-notation. + */ + String projection() default ""; } diff --git a/jooby/src/main/java/io/jooby/annotation/PUT.java b/jooby/src/main/java/io/jooby/annotation/PUT.java index e4d48d747c..61e455392d 100644 --- a/jooby/src/main/java/io/jooby/annotation/PUT.java +++ b/jooby/src/main/java/io/jooby/annotation/PUT.java @@ -57,4 +57,14 @@ * @return Consume types. */ String[] consumes() default {}; + + /** + * Field paths to include. Supports dot-notation and avaje-notation. It is a shortcut for {@link + * Project}. + * + *

    Example: id, name, address(city, zip) + * + * @return Field paths to include. Supports dot-notation and avaje-notation. + */ + String projection() default ""; } diff --git a/jooby/src/main/java/io/jooby/annotation/Project.java b/jooby/src/main/java/io/jooby/annotation/Project.java new file mode 100644 index 0000000000..730c9d3dd0 --- /dev/null +++ b/jooby/src/main/java/io/jooby/annotation/Project.java @@ -0,0 +1,58 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation; + +import java.lang.annotation.*; + +/** + * Declarative JSON projection for route handlers. + * + *

    When applied to a method or class, Jooby automatically filters the JSON output to include only + * the specified fields. * + * + *

    String Notation Support:

    + * + *
      + *
    • Dot Notation: {@code "address.city"} + *
    • Avaje Notation: {@code "address(city, zip)"} + *
    + * + * * + * + *

    Usage:

    + * + *
    {@code
    + * @GET
    + * @Project({"id", "name", "address(city, zip)"})
    + * public User getUser() {
    + * return userService.find(1);
    + * }
    + * }
    + * + * Or + * + *
    {@code
    + * @GET(projection = "id, name, address(city, zip)")
    + * public User getUser() {
    + * return userService.find(1);
    + * }
    + * }
    + * + * @author edgar + * @since 4.0.0 + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +public @interface Project { + /** + * Example: {@code "id, name, address(city, zip)"} + * + * @return Avaje notation. + */ + String value() default ""; +} diff --git a/jooby/src/main/java/module-info.java b/jooby/src/main/java/module-info.java index 87b3707006..d8382ec3b5 100644 --- a/jooby/src/main/java/module-info.java +++ b/jooby/src/main/java/module-info.java @@ -14,7 +14,6 @@ exports io.jooby.problem; exports io.jooby.value; exports io.jooby.output; - exports io.jooby.internal.output; uses io.jooby.Server; uses io.jooby.SslProvider; diff --git a/jooby/src/test/java/io/jooby/ProjectionTest.java b/jooby/src/test/java/io/jooby/ProjectionTest.java new file mode 100644 index 0000000000..ab7437953e --- /dev/null +++ b/jooby/src/test/java/io/jooby/ProjectionTest.java @@ -0,0 +1,329 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.*; + +import org.junit.jupiter.api.Test; + +/** Tests for Jooby Projection API. */ +public class ProjectionTest { + + // --- Test Models --- + + public static class User { + private String id; + private String name; + private Address address; + private List roles; + private Map meta; + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public Address getAddress() { + return address; + } + + public List getRoles() { + return roles; + } + + public Map getMeta() { + return meta; + } + } + + public static class ExtendedUser extends User { + public String getFullName() { + return getName(); + } + } + + public static class NamedGroup { + private String name; + + private Group group; + + public String getName() { + return name; + } + + public Group getGroup() { + return group; + } + } + + public static class Group { + private List users; + + public List getUsers() { + return users; + } + } + + public static class Address { + private String city; + private Location loc; + + public String getCity() { + return city; + } + + public Location getLoc() { + return loc; + } + } + + public record Role(String name, int level) {} + + public record Location(double lat, double lon) {} + + // --- Tests --- + + @Test + public void testOrderPreservation() { + // LinkedMap should preserve the order 'name' then 'id' + Projection p = Projection.of(User.class).include("name", "id"); + assertEquals("(name,id)", p.toView()); + + // Swapping order should result in swapped view + Projection p2 = Projection.of(User.class).include("id", "name"); + assertEquals("(id,name)", p2.toView()); + } + + @Test + public void testAvajeNotationRoot() { + // Root level should be wrapped in parentheses for Avaje + Projection p = Projection.of(User.class).include("id, address(city, loc(lat, lon))"); + + assertEquals("(id,address(city,loc(lat,lon)))", p.toView()); + } + + @Test + public void testInherited() { + Projection group = Projection.of(NamedGroup.class).include("id, group(*)"); + + assertEquals( + "(id,group(users(address(city,loc(lat,lon)),fullName,id,meta,name,roles(level,name))))", + group.toView()); + + Projection p = Projection.of(ExtendedUser.class).include("id, fullname"); + + assertEquals("(id,fullname)", p.toView()); + } + + @Test + public void testMixedNotationRecursive() { + // Validates that nested children still use parentheses + Projection p = Projection.of(User.class).include("address.loc(lat, lon)", "roles(name)"); + + assertEquals("(address(loc(lat,lon)),roles(name))", p.toView()); + } + + @Test + public void testCollectionGenericUnwrapping() { + Projection p = Projection.of(User.class).include("roles.name"); + assertEquals("roles(name)", p.toView()); + } + + @Test + public void testMapGenericUnwrapping() { + // Maps resolve to their value type (String in this case) + assertEquals("meta(bytes)", Projection.of(User.class).include("meta.bytes").toView()); + assertEquals( + "(id,meta(target))", Projection.of(User.class).include("(id, meta(target))").toView()); + } + + @Test + public void testRecordSupport() { + Projection p = Projection.of(Role.class).include("name", "level"); + assertEquals("(name,level)", p.toView()); + } + + @Test + public void testFailFastValidation() { + // Ensures we still blow up on typos during pre-compilation + assertThrows( + IllegalArgumentException.class, + () -> Projection.of(User.class).validate().include("address(ctiy)")); + } + + @Test + public void testRootParenthesesBug() { + assertEquals( + "(name,address(city))", + Projection.of(User.class).include("(name, address(city))").toView()); + + // Address expands to its deep explicit wildcard definition for Avaje + assertEquals( + "(name,address(city,loc(lat,lon)))", + Projection.of(User.class).include("(name, address)").toView()); + } + + @Test + public void testRootParentheses() { + Projection p = Projection.of(User.class).include("(id, name, address)"); + + assertTrue(p.getChildren().containsKey("id")); + assertTrue(p.getChildren().containsKey("name")); + assertTrue(p.getChildren().containsKey("address")); + + // Address should have no explicitly defined children initially + // (the deep wildcard happens during toView()) + assertTrue(p.getChildren().get("address").getChildren().isEmpty()); + + // Test the expanded view + assertEquals("(id,name,address(city,loc(lat,lon)))", p.toView()); + } + + @Test + public void testAvajeWildcardSyntax() { + Projection p = Projection.of(User.class).include("id, name, address(*)"); + + assertTrue(p.getChildren().containsKey("id")); + assertTrue(p.getChildren().containsKey("name")); + assertTrue(p.getChildren().containsKey("address")); + + // The explicit '*' should result in an empty children map for address + assertTrue(p.getChildren().get("address").getChildren().isEmpty()); + + // Test the expanded view + assertEquals("(id,name,address(city,loc(lat,lon)))", p.toView()); + } + + @Test + public void testNestedWildcardSyntax() { + Projection p = Projection.of(User.class).include("id, address(city, loc(*))"); + + assertTrue(p.getChildren().containsKey("id")); + assertTrue(p.getChildren().containsKey("address")); + + Projection addressProj = p.getChildren().get("address"); + assertTrue(addressProj.getChildren().containsKey("city")); + assertTrue(addressProj.getChildren().containsKey("loc")); + + // loc(*) should result in an empty children map for loc + assertTrue(addressProj.getChildren().get("loc").getChildren().isEmpty()); + + // Test the expanded view (loc expands to its fields) + assertEquals("(id,address(city,loc(lat,lon)))", p.toView()); + } + + @Test + public void testCollectionNestedSyntax() { + // Tests: (id, roles(name)) + Projection p = Projection.of(User.class).include("(id, roles(name))"); + + assertTrue(p.getChildren().containsKey("id")); + assertTrue(p.getChildren().containsKey("roles")); + + Projection rolesProj = p.getChildren().get("roles"); + assertTrue(rolesProj.getChildren().containsKey("name")); + + assertEquals("(id,roles(name))", p.toView()); + } + + @Test + public void testMissingClosingParenthesis() { + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> { + Projection.of(User.class).include("(id, name, address(*)"); + }); + + assertEquals( + "Missing closing parenthesis in projection: (id, name, address(*)", ex.getMessage()); + } + + @Test + public void testExtraClosingParenthesis() { + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> { + Projection.of(User.class).include("address(city))"); + }); + + assertEquals("Mismatched parentheses in projection: address(city))", ex.getMessage()); + } + + @Test + public void testCollectionDeepWildcardSyntax() { + // Test: Requesting the 'roles' list without explicitly defining its children. + // The projection engine should see that 'roles' is a List, + // extract the Role class, and expand it to its explicit fields (name, level) for Avaje. + Projection p = Projection.of(User.class).include("(id, roles)"); + + assertTrue(p.getChildren().containsKey("id")); + assertTrue(p.getChildren().containsKey("roles")); + + // The 'roles' node itself has no explicitly parsed children + assertTrue(p.getChildren().get("roles").getChildren().isEmpty()); + + // But the resulting view string should be fully expanded! + assertEquals("(id,roles(level,name))", p.toView()); + } + + @Test + public void testExplicitCollectionDeepWildcardSyntax() { + // Test: Requesting the 'roles' list using the explicit (*) syntax. + Projection p = Projection.of(User.class).include("id, roles(*)"); + + assertTrue(p.getChildren().containsKey("id")); + assertTrue(p.getChildren().containsKey("roles")); + + // The resulting view string should be fully expanded! + assertEquals("(id,roles(level,name))", p.toView()); + } + + @Test + public void testValidateToggle() { + // 1. Verify default strict behavior (throws exception) + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> Projection.of(User.class).validate().include("address(zipcode)")); + assertTrue(ex.getMessage().contains("zipcode")); + + // 2. Verify polymorphic/unknown fields are accepted when flag is false + Projection p = + Projection.of(User.class).include("address(zipcode), extraPolymorphicField"); + + // The projection shouldn't throw, and it should successfully generate the explicit paths + assertEquals("(address(zipcode),extraPolymorphicField)", p.toView()); + + // Verify the internal tree mapped them correctly as generic leaves + assertTrue(p.getChildren().containsKey("extraPolymorphicField")); + assertTrue(p.getChildren().get("address").getChildren().containsKey("zipcode")); + } + + @Test + public void testTopLevelListProjection() { + // If your route returns a List, the root projection type is just User.class. + // The JSON engines (Jackson/Avaje) will naturally apply this User projection + // to every element in the JSON array. + Projection projection = Projection.of(User.class).include("id, email"); + + // Assert: Avaje view string + assertEquals("(id,email)", projection.toView()); + + // Assert: Tree Structure validates against User + assertEquals(User.class, projection.getType()); + assertEquals(2, projection.getChildren().size()); + assertTrue(projection.getChildren().containsKey("id")); + assertTrue(projection.getChildren().containsKey("email")); + assertFalse(projection.getChildren().containsKey("name")); + } +} diff --git a/modules/jooby-apt/pom.xml b/modules/jooby-apt/pom.xml index b94e7f7603..2ee8a6add5 100644 --- a/modules/jooby-apt/pom.xml +++ b/modules/jooby-apt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-apt jooby-apt @@ -113,6 +113,12 @@ swagger-annotations test + + + org.assertj + assertj-core + test + diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java index 00bdef21da..107f177516 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java @@ -98,13 +98,13 @@ yield hasAnnotation(NULLABLE) if (strategy.isEmpty()) { // must be body yield ParameterGenerator.BodyParam.toSourceCode( - kt, route, null, type, parameterName, isNullable(kt)); + kt, route, null, type, parameter, parameterName, isNullable(kt)); } else { var paramGenerator = strategy.get().getKey(); paramGenerator.verifyType(parameterType, parameterName, route); yield paramGenerator.toSourceCode( - kt, route, strategy.get().getValue(), type, parameterName, isNullable(kt)); + kt, route, strategy.get().getValue(), type, parameter, parameterName, isNullable(kt)); } } }; diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java index 975a746d46..b50423de28 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java @@ -65,11 +65,40 @@ public MvcContext getContext() { return context; } + public String getProjection() { + var project = AnnotationSupport.findAnnotationByName(method, Types.PROJECT); + if (project != null) { + return AnnotationSupport.findAnnotationValue(project, VALUE).stream() + .findFirst() + .orElse(null); + } + // look inside the method annotation + var httpMethod = annotationMap.values().iterator().next(); + var projection = AnnotationSupport.findAnnotationValue(httpMethod, "projection"::equals); + return projection.stream().findFirst().orElse(null); + } + + public boolean isProjection() { + if (returnType.is(Types.PROJECTED)) { + return false; + } + var isProjection = AnnotationSupport.findAnnotationByName(method, Types.PROJECT) != null; + if (isProjection) { + return true; + } + // look inside the method annotation + var httpMethod = annotationMap.values().iterator().next(); + var projection = AnnotationSupport.findAnnotationValue(httpMethod, "projection"::equals); + return !projection.isEmpty(); + } + public TypeDefinition getReturnType() { var processingEnv = context.getProcessingEnvironment(); var types = processingEnv.getTypeUtils(); var elements = processingEnv.getElementUtils(); - if (returnType.isVoid()) { + if (isProjection()) { + return new TypeDefinition(types, elements.getTypeElement(Types.PROJECTED).asType(), true); + } else if (returnType.isVoid()) { return new TypeDefinition(types, elements.getTypeElement("io.jooby.StatusCode").asType()); } else if (isSuspendFun()) { var continuation = parameters.get(parameters.size() - 1).getType(); @@ -220,6 +249,13 @@ public List generateHandlerCall(boolean kt) { var returnTypeGenerics = getReturnType().getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); var returnTypeString = type(kt, getReturnType().toString()); + var customReturnType = getReturnType(); + if (customReturnType.isProjection()) { + // Override for projection + returnTypeGenerics = ""; + returnTypeString = Types.PROJECTED + "<" + returnType + ">"; + } + boolean nullable = false; if (kt) { nullable = @@ -317,7 +353,10 @@ public List generateHandlerCall(boolean kt) { buffer.add(statement(indent(2), "return statusCode", semicolon(kt))); } else { controllerVar(kt, buffer); - var cast = getReturnType().getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); + var cast = + customReturnType.isProjection() + ? "" + : customReturnType.getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); var kotlinNotEnoughTypeInformation = !cast.isEmpty() && kt ? "" : ""; var call = of( @@ -329,7 +368,15 @@ public List generateHandlerCall(boolean kt) { setUncheckedCast(true); call = kt ? call + " as " + returnTypeString : "(" + returnTypeString + ") " + call; } - buffer.add(statement(indent(2), "return ", call, kt && nullable ? "!!" : "", semicolon(kt))); + if (customReturnType.isProjection()) { + var projected = + of(Types.PROJECTED, ".wrap(", call, ").include(", string(getProjection()), ")"); + buffer.add( + statement(indent(2), "return ", projected, kt && nullable ? "!!" : "", semicolon(kt))); + } else { + buffer.add( + statement(indent(2), "return ", call, kt && nullable ? "!!" : "", semicolon(kt))); + } } buffer.add(statement("}", System.lineSeparator())); if (uncheckedCast) { diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ParameterGenerator.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ParameterGenerator.java index d99d8942e1..34f1e07d9b 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ParameterGenerator.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ParameterGenerator.java @@ -5,15 +5,14 @@ */ package io.jooby.internal.apt; -import static io.jooby.internal.apt.AnnotationSupport.findAnnotationValue; -import static io.jooby.internal.apt.Types.BUILT_IN; -import static java.util.stream.Collectors.joining; - +import javax.lang.model.element.*; import java.util.*; import java.util.function.Predicate; import java.util.stream.Stream; -import javax.lang.model.element.*; +import static io.jooby.internal.apt.AnnotationSupport.findAnnotationValue; +import static io.jooby.internal.apt.Types.BUILT_IN; +import static java.util.stream.Collectors.joining; public enum ParameterGenerator { ContextParam("getAttribute", "io.jooby.annotation.ContextParam", "jakarta.ws.rs.core.Context") { @@ -23,6 +22,7 @@ public String toSourceCode( MvcRoute route, AnnotationMirror annotation, TypeDefinition type, + VariableElement parameter, String name, boolean nullable) { if (type.is(Map.class)) { @@ -70,6 +70,7 @@ public String toSourceCode( MvcRoute route, AnnotationMirror annotation, TypeDefinition type, + VariableElement parameter, String name, boolean nullable) { var rawType = type.getRawType().toString(); @@ -107,6 +108,7 @@ public String toSourceCode( MvcRoute route, AnnotationMirror annotation, TypeDefinition type, + VariableElement parameter, String name, boolean nullable) { List converters = new ArrayList<>(); @@ -200,10 +202,11 @@ public String toSourceCode( MvcRoute route, AnnotationMirror annotation, TypeDefinition type, + VariableElement parameter, String name, boolean nullable) { var paramSource = source(annotation); - var builtin = builtinType(kt, annotation, type, name, nullable); + var builtin = builtinType(kt, annotation, type, parameter, name, nullable); if (builtin == null) { // List, Set, var toValue = @@ -323,10 +326,15 @@ public String toSourceCode( } protected String builtinType( - boolean kt, AnnotationMirror annotation, TypeDefinition type, String name, boolean nullable) { + boolean kt, + AnnotationMirror annotation, + TypeDefinition type, + VariableElement parameter, + String name, + boolean nullable) { if (BUILT_IN.stream().anyMatch(type::is)) { var paramSource = source(annotation); - var defaultValue = defaultValue(annotation); + var defaultValue = defaultValue(parameter, annotation); // look at named parameter if (type.isPrimitive()) { // like: .intValue @@ -425,10 +433,19 @@ protected String source(AnnotationMirror annotation) { return ""; } - protected String defaultValue(AnnotationMirror annotation) { - if (annotation.getAnnotationType().toString().startsWith("io.jooby.annotation")) { + protected String defaultValue(VariableElement parameter, AnnotationMirror annotation) { + var annotationType = annotation.getAnnotationType().toString(); + + if (annotationType.startsWith("io.jooby.annotation")) { var sources = findAnnotationValue(annotation, AnnotationSupport.VALUE); return sources.isEmpty() ? "" : CodeBlock.of(", ", CodeBlock.string(sources.getFirst())); + } else if (annotationType.startsWith("jakarta.ws.rs")) { + var defaultValueAnnotation = AnnotationSupport.findAnnotationByName( + parameter, "jakarta.ws.rs.DefaultValue"); + if (defaultValueAnnotation != null) { + var defaultValue = findAnnotationValue(defaultValueAnnotation, AnnotationSupport.VALUE); + return defaultValue.isEmpty() ? "" : CodeBlock.of(", ", CodeBlock.string(defaultValue.getFirst())); + } } return ""; } diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TypeDefinition.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TypeDefinition.java index 7213a0df5a..089adc4bc2 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TypeDefinition.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TypeDefinition.java @@ -22,12 +22,22 @@ public class TypeDefinition { private final TypeMirror type; private final TypeMirror unwrapType; private final TypeMirror rawType; + private final boolean projection; public TypeDefinition(Types types, TypeMirror type) { + this(types, type, false); + } + + public TypeDefinition(Types types, TypeMirror type, boolean projection) { this.typeUtils = types; this.type = type; this.unwrapType = unwrapType(type); this.rawType = typeUtils.erasure(unwrapType); + this.projection = projection; + } + + public boolean isProjection() { + return projection; } public String toSourceCode(boolean kt) { diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/Types.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/Types.java index f653f32547..a0b41a10dd 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/Types.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/Types.java @@ -14,6 +14,8 @@ import java.util.TimeZone; class Types { + static final String PROJECT = "io.jooby.annotation.Project"; + static final String PROJECTED = "io.jooby.Projected"; static final Set BUILT_IN = Set.of( String.class.getName(), diff --git a/modules/jooby-apt/src/test/java/tests/i3761/C3761.java b/modules/jooby-apt/src/test/java/tests/i3761/C3761.java index ddca9bdaa3..578c5ee07d 100644 --- a/modules/jooby-apt/src/test/java/tests/i3761/C3761.java +++ b/modules/jooby-apt/src/test/java/tests/i3761/C3761.java @@ -5,6 +5,7 @@ */ package tests.i3761; +import io.jooby.annotation.FormParam; import io.jooby.annotation.GET; import io.jooby.annotation.Path; import io.jooby.annotation.QueryParam; @@ -31,4 +32,9 @@ public String emptySet(@QueryParam("") String emptySet) { public String string(@QueryParam("Hello") String stringVal) { return stringVal; } + + @GET("/boolVal") + public boolean bool(@FormParam("false") boolean boolVal) { + return boolVal; + } } diff --git a/modules/jooby-apt/src/test/java/tests/i3761/C3761Jakarta.java b/modules/jooby-apt/src/test/java/tests/i3761/C3761Jakarta.java new file mode 100644 index 0000000000..aadae957bd --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3761/C3761Jakarta.java @@ -0,0 +1,41 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.i3761; + +import io.jooby.annotation.GET; +import io.jooby.annotation.Path; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.FormParam; + +@Path("/3761") +public class C3761Jakarta { + + @GET("/number") + public int number(@QueryParam("num") @DefaultValue("5") int num) { + return num; + } + + @GET("/unset") + public String unset(@QueryParam("unset") String unset) { + return unset; + } + + @GET("/emptySet") + public String emptySet(@QueryParam("emptySet") @DefaultValue("") String emptySet) { + return emptySet; + } + + @GET("/stringVal") + public String string(@QueryParam("stringVal") @DefaultValue("Hello") String stringVal) { + return stringVal; + } + + @GET("/boolVal") + public boolean bool(@FormParam("boolVal") @DefaultValue("false") boolean boolVal) { + return boolVal; + } +} diff --git a/modules/jooby-apt/src/test/java/tests/i3761/Issue3761.java b/modules/jooby-apt/src/test/java/tests/i3761/Issue3761.java index f8347154b1..963f350c74 100644 --- a/modules/jooby-apt/src/test/java/tests/i3761/Issue3761.java +++ b/modules/jooby-apt/src/test/java/tests/i3761/Issue3761.java @@ -5,24 +5,32 @@ */ package tests.i3761; -import static org.junit.jupiter.api.Assertions.*; - +import io.jooby.apt.ProcessorRunner; import org.junit.jupiter.api.Test; -import io.jooby.apt.ProcessorRunner; +import static org.junit.jupiter.api.Assertions.assertTrue; public class Issue3761 { @Test public void shouldGenerateDefaultValues() throws Exception { new ProcessorRunner(new C3761()) - .withSourceCode( - (source) -> { - assertTrue(source.contains("return c.number(ctx.query(\"num\", \"5\").intValue());")); - assertTrue(source.contains("return c.unset(ctx.query(\"unset\").valueOrNull());")); - assertTrue( - source.contains("return c.emptySet(ctx.query(\"emptySet\", \"\").value());")); - assertTrue( - source.contains("return c.string(ctx.query(\"stringVal\", \"Hello\").value());")); - }); + .withSourceCode(Issue3761::assertSourceCodeRespectDefaultValues); + } + + @Test + public void shouldGenerateJakartaDefaultValues() throws Exception { + new ProcessorRunner(new C3761Jakarta()) + .withSourceCode(Issue3761::assertSourceCodeRespectDefaultValues); + } + + private static void assertSourceCodeRespectDefaultValues(String source) { + assertTrue(source.contains("return c.number(ctx.query(\"num\", \"5\").intValue());")); + assertTrue(source.contains("return c.unset(ctx.query(\"unset\").valueOrNull());")); + assertTrue( + source.contains("return c.emptySet(ctx.query(\"emptySet\", \"\").value());")); + assertTrue( + source.contains("return c.string(ctx.query(\"stringVal\", \"Hello\").value());")); + assertTrue( + source.contains("return c.bool(ctx.form(\"boolVal\", \"false\").booleanValue());")); } } diff --git a/modules/jooby-apt/src/test/java/tests/i3853/C3853.java b/modules/jooby-apt/src/test/java/tests/i3853/C3853.java new file mode 100644 index 0000000000..51ecf302c8 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3853/C3853.java @@ -0,0 +1,47 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.i3853; + +import java.util.List; +import java.util.Optional; + +import io.jooby.Projected; +import io.jooby.annotation.GET; +import io.jooby.annotation.Path; +import io.jooby.annotation.Project; + +@Path("/3854") +public class C3853 { + @GET(value = "/stub", projection = "(id, name)") + public U3853 projectUser() { + return new U3853(1, "Projected User", "Projected", "User"); + } + + @GET("/optinal") + @Project("(id, name)") + public Optional findUser() { + return Optional.of(new U3853(1, "Projected User", "Projected", "User")); + } + + @GET("/list") + @Project("(id, name)") + public List findUsers() { + return List.of(new U3853(1, "Projected User", "Projected", "User")); + } + + @GET("/list") + @Project("(id, name)") + public Projected projected() { + return Projected.wrap(new U3853(1, "Projected User", "Projected", "User")) + .include("(id, name)"); + } + + @GET(value = "/list", projection = "(id, name)") + public Projected projectedProjection() { + return Projected.wrap(new U3853(1, "Projected User", "Projected", "User")) + .include("(id, name)"); + } +} diff --git a/modules/jooby-apt/src/test/java/tests/i3853/Issue3853.java b/modules/jooby-apt/src/test/java/tests/i3853/Issue3853.java new file mode 100644 index 0000000000..b280bdd0ae --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3853/Issue3853.java @@ -0,0 +1,49 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.i3853; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import io.jooby.apt.ProcessorRunner; + +public class Issue3853 { + @Test + public void shouldSupportNameAttribute() throws Exception { + new ProcessorRunner(new C3853()) + .withSourceCode( + source -> { + assertThat(source) + .contains( + "return io.jooby.Projected.wrap(c.projectUser()).include(\"(id, name)\");"); + Assertions.assertTrue( + source.contains( + "return io.jooby.Projected.wrap(c.findUser()).include(\"(id, name)\");")); + Assertions.assertTrue( + source.contains( + "return io.jooby.Projected.wrap(c.findUsers()).include(\"(id, name)\");")); + Assertions.assertTrue(source.contains("return c.projected();")); + Assertions.assertTrue(source.contains("return c.projectedProjection();")); + }) + .withSourceCode( + true, + source -> { + Assertions.assertTrue( + source.contains( + "return io.jooby.Projected.wrap(c.projectUser()).include(\"(id, name)\")")); + Assertions.assertTrue( + source.contains( + "return io.jooby.Projected.wrap(c.findUser()).include(\"(id, name)\")")); + Assertions.assertTrue( + source.contains( + "return io.jooby.Projected.wrap(c.findUsers()).include(\"(id, name)\")")); + Assertions.assertTrue(source.contains("return c.projected()")); + Assertions.assertTrue(source.contains("return c.projectedProjection()")); + }); + } +} diff --git a/modules/jooby-apt/src/test/java/tests/i3853/U3853.java b/modules/jooby-apt/src/test/java/tests/i3853/U3853.java new file mode 100644 index 0000000000..d143fc39b4 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3853/U3853.java @@ -0,0 +1,8 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.i3853; + +public record U3853(long id, String name, String firstName, String lastName) {} diff --git a/modules/jooby-avaje-inject/pom.xml b/modules/jooby-avaje-inject/pom.xml index 0b7dc1190d..4180caa86e 100644 --- a/modules/jooby-avaje-inject/pom.xml +++ b/modules/jooby-avaje-inject/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-avaje-inject jooby-avaje-inject diff --git a/modules/jooby-avaje-jsonb/pom.xml b/modules/jooby-avaje-jsonb/pom.xml index 7dff5e6c75..409ca6cf0c 100644 --- a/modules/jooby-avaje-jsonb/pom.xml +++ b/modules/jooby-avaje-jsonb/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-avaje-jsonb jooby-avaje-jsonb diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonbModule.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonbModule.java index 706e737b77..e5e10ef7ff 100644 --- a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonbModule.java +++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonbModule.java @@ -7,17 +7,13 @@ import java.io.InputStream; import java.lang.reflect.Type; +import java.util.*; import edu.umd.cs.findbugs.annotations.NonNull; +import io.avaje.json.JsonWriter; +import io.avaje.jsonb.JsonView; import io.avaje.jsonb.Jsonb; -import io.jooby.Body; -import io.jooby.Context; -import io.jooby.Extension; -import io.jooby.Jooby; -import io.jooby.MediaType; -import io.jooby.MessageDecoder; -import io.jooby.MessageEncoder; -import io.jooby.ServiceRegistry; +import io.jooby.*; import io.jooby.internal.avaje.jsonb.BufferedJsonOutput; import io.jooby.output.Output; @@ -104,14 +100,47 @@ public Object decode(@NonNull Context ctx, @NonNull Type type) throws Exception } } - @NonNull @Override + @Override public Output encode(@NonNull Context ctx, @NonNull Object value) { ctx.setDefaultResponseType(MediaType.json); var factory = ctx.getOutputFactory(); var buffer = factory.allocate(); try (var writer = jsonb.writer(new BufferedJsonOutput(buffer))) { - jsonb.toJson(value, writer); + if (value instanceof Projected projected) { + encodeProjection(writer, projected); + } else { + jsonb.toJson(value, writer); + } return buffer; } } + + @SuppressWarnings("unchecked") + private void encodeProjection(JsonWriter writer, Projected projected) { + var value = projected.getValue(); + if (value instanceof Optional optional) { + if (optional.isEmpty()) { + writer.serializeNulls(true); + writer.nullValue(); + return; + } + value = optional.get(); + } + if (value instanceof Collection collection && collection.isEmpty()) { + writer.emptyArray(); + return; + } + var projection = projected.getProjection(); + var viewString = projection.toView(); + var type = projection.getType(); + var jsonbType = jsonb.type(type); + jsonbType = + switch (value) { + case Set ignored -> jsonbType.set(); + case Collection ignored -> jsonbType.list(); + default -> jsonbType; + }; + var view = (JsonView) jsonbType.view(viewString); + view.toJson(value, writer); + } } diff --git a/modules/jooby-avaje-validator/pom.xml b/modules/jooby-avaje-validator/pom.xml index c74d132ca8..c30788ec2b 100644 --- a/modules/jooby-avaje-validator/pom.xml +++ b/modules/jooby-avaje-validator/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-avaje-validator jooby-avaje-validator @@ -88,7 +88,7 @@ org.assertj assertj-core - 3.27.6 + 3.27.7 test diff --git a/modules/jooby-awssdk-v1/pom.xml b/modules/jooby-awssdk-v1/pom.xml index d509fcec98..82d2ff98ea 100644 --- a/modules/jooby-awssdk-v1/pom.xml +++ b/modules/jooby-awssdk-v1/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-awssdk-v1 jooby-awssdk-v1 diff --git a/modules/jooby-awssdk-v2/pom.xml b/modules/jooby-awssdk-v2/pom.xml index 1c8713978d..8b4fb9ff15 100644 --- a/modules/jooby-awssdk-v2/pom.xml +++ b/modules/jooby-awssdk-v2/pom.xml @@ -6,13 +6,13 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-awssdk-v2 jooby-awssdk-v2 - 2.41.5 + 2.42.4 diff --git a/modules/jooby-bom/pom.xml b/modules/jooby-bom/pom.xml index 4190e6e211..3cba8f7fc9 100644 --- a/modules/jooby-bom/pom.xml +++ b/modules/jooby-bom/pom.xml @@ -7,14 +7,14 @@ io.jooby modules - 4.0.15 + 4.0.16 io.jooby jooby-bom jooby-bom pom - 4.0.15 + 4.0.16 Jooby (Bill of Materials) https://jooby.io diff --git a/modules/jooby-caffeine/pom.xml b/modules/jooby-caffeine/pom.xml index 80b4e273a0..a6c318be9b 100644 --- a/modules/jooby-caffeine/pom.xml +++ b/modules/jooby-caffeine/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-caffeine jooby-caffeine diff --git a/modules/jooby-camel/pom.xml b/modules/jooby-camel/pom.xml index 616051e905..b05c86f2c2 100644 --- a/modules/jooby-camel/pom.xml +++ b/modules/jooby-camel/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-camel jooby-camel diff --git a/modules/jooby-cli/pom.xml b/modules/jooby-cli/pom.xml index 487960e9d2..4e931f8a9b 100644 --- a/modules/jooby-cli/pom.xml +++ b/modules/jooby-cli/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-cli jooby-cli @@ -94,7 +94,7 @@ org.codehaus.mojo properties-maven-plugin - 1.2.1 + 1.3.0 diff --git a/modules/jooby-commons-email/pom.xml b/modules/jooby-commons-email/pom.xml index d8d8e91dd9..b648eba77c 100644 --- a/modules/jooby-commons-email/pom.xml +++ b/modules/jooby-commons-email/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-commons-email jooby-commons-email diff --git a/modules/jooby-conscrypt/pom.xml b/modules/jooby-conscrypt/pom.xml index 7e71c02e00..5deeb9a6d8 100644 --- a/modules/jooby-conscrypt/pom.xml +++ b/modules/jooby-conscrypt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-conscrypt jooby-conscrypt diff --git a/modules/jooby-db-scheduler/pom.xml b/modules/jooby-db-scheduler/pom.xml index bf3fd6f4c3..c19f0a776c 100644 --- a/modules/jooby-db-scheduler/pom.xml +++ b/modules/jooby-db-scheduler/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-db-scheduler jooby-db-scheduler @@ -22,7 +22,7 @@ com.github.kagkarlsson db-scheduler - 16.7.0 + 16.7.1 diff --git a/modules/jooby-distribution/pom.xml b/modules/jooby-distribution/pom.xml index c7530daa65..4b16a1d559 100644 --- a/modules/jooby-distribution/pom.xml +++ b/modules/jooby-distribution/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-distribution jooby-distribution diff --git a/modules/jooby-ebean/pom.xml b/modules/jooby-ebean/pom.xml index 20c0244eda..4e9a33afd4 100644 --- a/modules/jooby-ebean/pom.xml +++ b/modules/jooby-ebean/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-ebean jooby-ebean diff --git a/modules/jooby-flyway/pom.xml b/modules/jooby-flyway/pom.xml index d6f81599a9..ff6f5b2f6a 100644 --- a/modules/jooby-flyway/pom.xml +++ b/modules/jooby-flyway/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-flyway jooby-flyway diff --git a/modules/jooby-freemarker/pom.xml b/modules/jooby-freemarker/pom.xml index 5fe7dcb731..3fe53adefa 100644 --- a/modules/jooby-freemarker/pom.xml +++ b/modules/jooby-freemarker/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-freemarker jooby-freemarker diff --git a/modules/jooby-gradle-setup/pom.xml b/modules/jooby-gradle-setup/pom.xml index d05398b08a..f3484c813a 100644 --- a/modules/jooby-gradle-setup/pom.xml +++ b/modules/jooby-gradle-setup/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-gradle-setup jooby-gradle-setup diff --git a/modules/jooby-graphiql/pom.xml b/modules/jooby-graphiql/pom.xml index db614e85b7..e2034de259 100644 --- a/modules/jooby-graphiql/pom.xml +++ b/modules/jooby-graphiql/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-graphiql jooby-graphiql diff --git a/modules/jooby-graphql/pom.xml b/modules/jooby-graphql/pom.xml index 97144813cb..120fedd68b 100644 --- a/modules/jooby-graphql/pom.xml +++ b/modules/jooby-graphql/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-graphql jooby-graphql diff --git a/modules/jooby-gson/pom.xml b/modules/jooby-gson/pom.xml index 6b6c0c9c1c..8ed17eb338 100644 --- a/modules/jooby-gson/pom.xml +++ b/modules/jooby-gson/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-gson jooby-gson diff --git a/modules/jooby-guice/pom.xml b/modules/jooby-guice/pom.xml index 7945de2bf4..a8a6afe8f3 100644 --- a/modules/jooby-guice/pom.xml +++ b/modules/jooby-guice/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-guice jooby-guice diff --git a/modules/jooby-handlebars/pom.xml b/modules/jooby-handlebars/pom.xml index ec3c0a2790..5c0d7a89f8 100644 --- a/modules/jooby-handlebars/pom.xml +++ b/modules/jooby-handlebars/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-handlebars jooby-handlebars diff --git a/modules/jooby-hibernate-validator/pom.xml b/modules/jooby-hibernate-validator/pom.xml index 033e964197..a1d658b579 100644 --- a/modules/jooby-hibernate-validator/pom.xml +++ b/modules/jooby-hibernate-validator/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-hibernate-validator jooby-hibernate-validator diff --git a/modules/jooby-hibernate/pom.xml b/modules/jooby-hibernate/pom.xml index 798ae17711..91c07d0072 100644 --- a/modules/jooby-hibernate/pom.xml +++ b/modules/jooby-hibernate/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-hibernate jooby-hibernate diff --git a/modules/jooby-hikari/pom.xml b/modules/jooby-hikari/pom.xml index 9caf6c822d..515a16bf56 100644 --- a/modules/jooby-hikari/pom.xml +++ b/modules/jooby-hikari/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-hikari jooby-hikari diff --git a/modules/jooby-jackson/pom.xml b/modules/jooby-jackson/pom.xml index aa491b44a9..80bc59429e 100644 --- a/modules/jooby-jackson/pom.xml +++ b/modules/jooby-jackson/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-jackson jooby-jackson diff --git a/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonModule.java b/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonModule.java index 2cd82c291c..fd8e960342 100644 --- a/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonModule.java +++ b/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonModule.java @@ -5,37 +5,36 @@ */ package io.jooby.jackson; +import static com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter.*; + import java.io.InputStream; import java.lang.reflect.Type; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Stream; +import com.fasterxml.jackson.annotation.JsonFilter; import com.fasterxml.jackson.core.JsonParseException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.Module; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider; import com.fasterxml.jackson.databind.type.TypeFactory; import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; import edu.umd.cs.findbugs.annotations.NonNull; -import io.jooby.Body; -import io.jooby.Context; -import io.jooby.Extension; -import io.jooby.Jooby; -import io.jooby.MediaType; -import io.jooby.MessageDecoder; -import io.jooby.MessageEncoder; -import io.jooby.ServiceRegistry; -import io.jooby.StatusCode; +import io.jooby.*; import io.jooby.output.Output; /** - * JSON module using Jackson: https://jooby.io/modules/jackson. + * JSON module using Jackson: https://jooby.io/modules/jackson2. * *

    Usage: * @@ -72,12 +71,20 @@ * } * } * - * Complete documentation is available at: https://jooby.io/modules/jackson. + * Complete documentation is available at: https://jooby.io/modules/jackson2. * * @author edgar * @since 2.0.0 */ public class JacksonModule implements Extension, MessageDecoder, MessageEncoder { + public static final String FILTER_ID = "jooby.projection"; + + // Cache for ObjectWriters tied to specific projection strings + private final Map writerCache = new ConcurrentHashMap<>(); + + @JsonFilter(FILTER_ID) + private interface ProjectionMixIn {} + private final MediaType mediaType; private final ObjectMapper mapper; @@ -143,6 +150,14 @@ public void install(@NonNull Jooby application) { // Parsing exception as 400 application.errorCode(JsonParseException.class, StatusCode.BAD_REQUEST); + // Filter + var defaultProvider = new SimpleFilterProvider().setFailOnUnknownId(false); + mapper.addMixIn(Object.class, ProjectionMixIn.class); + mapper.setFilterProvider(defaultProvider); + var projectionModule = new SimpleModule(); + projectionModule.addSerializer(Projected.class, new JacksonProjectedSerializer(mapper)); + mapper.registerModule(projectionModule); + application.onStarting( () -> { for (Class type : modules) { @@ -156,6 +171,19 @@ public void install(@NonNull Jooby application) { public Output encode(@NonNull Context ctx, @NonNull Object value) throws Exception { var factory = ctx.getOutputFactory(); ctx.setDefaultResponseType(mediaType); + if (value instanceof Projected projected) { + var p = projected.getProjection(); + var writer = + writerCache.computeIfAbsent( + p.getType().getName() + p.toView(), + k -> { + // Use a specialized ObjectWriter with our custom path filter + var filters = + new SimpleFilterProvider().addFilter(FILTER_ID, new JacksonProjectionFilter(p)); + return mapper.writer(filters); + }); + return factory.wrap(writer.writeValueAsBytes(projected.getValue())); + } // let jackson uses his own cache, so wrap the bytes return factory.wrap(mapper.writeValueAsBytes(value)); } diff --git a/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonProjectedSerializer.java b/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonProjectedSerializer.java new file mode 100644 index 0000000000..13fce9d27a --- /dev/null +++ b/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonProjectedSerializer.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.jackson; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider; +import io.jooby.Projected; +import io.jooby.Projection; + +public class JacksonProjectedSerializer extends JsonSerializer { + private final Map, ObjectWriter> writerCache = new ConcurrentHashMap<>(); + + private final ObjectMapper mapper; + + public JacksonProjectedSerializer(ObjectMapper mapper) { + this.mapper = mapper; + } + + @Override + public void serialize( + Projected projected, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) + throws IOException { + // Create a dynamic provider for this specific projection + var writer = + writerCache.computeIfAbsent( + projected.getProjection(), + p -> { + var filters = + new SimpleFilterProvider() + .addFilter(JacksonModule.FILTER_ID, new JacksonProjectionFilter(p)); + return mapper.writer(filters); + }); + + // Write the value using the filtered writer + writer.writeValue(jsonGenerator, projected.getValue()); + } +} diff --git a/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonProjectionFilter.java b/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonProjectionFilter.java new file mode 100644 index 0000000000..f6650e4e40 --- /dev/null +++ b/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonProjectionFilter.java @@ -0,0 +1,101 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.jackson; + +import java.util.ArrayDeque; +import java.util.Deque; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonStreamContext; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.PropertyWriter; +import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter; +import io.jooby.Projection; + +/** + * High-performance, fully stateless Jackson filter for Jooby Projections. Determines the correct + * filtering context by walking Jackson's internal stream context path back to the root. + * + * @author edgar + * @since 4.0.0 + */ +public class JacksonProjectionFilter extends SimpleBeanPropertyFilter { + + private final Projection root; + + public JacksonProjectionFilter(Projection root) { + this.root = root; + } + + @Override + public void serializeAsField( + Object pojo, JsonGenerator jgen, SerializerProvider provider, PropertyWriter writer) + throws Exception { + + // Bypass projection filtering for Map entries to match Avaje's behavior. + // We want to dump the entire Map payload without validating its dynamic keys against the static + // tree. + if (pojo instanceof java.util.Map) { + writer.serializeAsField(pojo, jgen, provider); + return; + } + + // 1. Resolve the active projection node for the object currently being serialized. + Projection current = resolveNode(jgen.getOutputContext()); + + if (current != null) { + String fieldName = writer.getName(); + + // 2. If the current node has no children defined, it acts as a wildcard (e.g., user + // requested 'address' instead of 'address(city)'), so we include all fields. + // Otherwise, we strictly check if the field is in the children map. + if (current.getChildren().isEmpty() || current.getChildren().containsKey(fieldName)) { + writer.serializeAsField(pojo, jgen, provider); + } + } + } + + private Projection resolveNode(JsonStreamContext context) { + if (context == null) { + return root; + } + + // Use a Deque to build the path in the correct (root-to-leaf) order by + // inserting at the front, eliminating the need for Collections.reverse(). + Deque path = new ArrayDeque<>(); + + // 1. Start from the parent context to build the path TO the current object being serialized. + // The current context's name is the property currently being evaluated, not the path. + JsonStreamContext curr = context.getParent(); + + while (curr != null && !curr.inRoot()) { + // 2. Only extract names from Object contexts. Array boundaries are ignored + // so that lists (e.g., List) map seamlessly to their parent field name. + if (curr.inObject() && curr.getCurrentName() != null) { + path.addFirst(curr.getCurrentName()); + } + curr = curr.getParent(); + } + + Projection node = root; + for (String segment : path) { + if (node == null) { + return null; // The path Jackson took is completely outside our projection + } + + // If we hit a node in our projection tree that exists but has no explicitly + // defined children, it means the user wants this entire subgraph. + // We stop traversing Jackson's path and return this wildcard node. + if (node != root && node.getChildren().isEmpty()) { + return node; + } + + node = node.getChildren().get(segment); + } + + return node; + } +} diff --git a/modules/jooby-jackson3/pom.xml b/modules/jooby-jackson3/pom.xml new file mode 100644 index 0000000000..aa4e989af9 --- /dev/null +++ b/modules/jooby-jackson3/pom.xml @@ -0,0 +1,54 @@ + + + + 4.0.0 + + + io.jooby + modules + 4.0.16 + + jooby-jackson3 + jooby-jackson3 + + + + io.jooby + jooby + ${jooby.version} + + + + + tools.jackson.core + jackson-databind + + + + + org.junit.jupiter + junit-jupiter-engine + test + + + + org.jacoco + org.jacoco.agent + runtime + test + + + + org.mockito + mockito-core + test + + + + tools.jackson.dataformat + jackson-dataformat-xml + test + + + + diff --git a/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/Jackson3Module.java b/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/Jackson3Module.java new file mode 100644 index 0000000000..32b92b5884 --- /dev/null +++ b/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/Jackson3Module.java @@ -0,0 +1,227 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.jackson3; + +import java.io.InputStream; +import java.lang.reflect.Type; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Stream; + +import com.fasterxml.jackson.annotation.JsonFilter; +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.*; +import io.jooby.output.Output; +import tools.jackson.core.exc.StreamReadException; +import tools.jackson.databind.JacksonModule; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectWriter; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.ser.std.SimpleFilterProvider; +import tools.jackson.databind.type.TypeFactory; + +/** + * JSON module using Jackson3: https://jooby.io/modules/jackson3. + * + *

    Usage: + * + *

    {@code
    + * {
    + *
    + *   install(new Jackson3Module());
    + *
    + *   get("/", ctx -> {
    + *     MyObject myObject = ...;
    + *     // send json
    + *     return myObject;
    + *   });
    + *
    + *   post("/", ctx -> {
    + *     // read json
    + *     MyObject myObject = ctx.body(MyObject.class);
    + *     // send json
    + *     return myObject;
    + *   });
    + * }
    + * }
    + * + *

    For body decoding the client must specify the Content-Type header set to + * application/json. + * + *

    You can retrieve the {@link ObjectMapper} via require call: + * + *

    {@code
    + * {
    + *
    + *   ObjectMapper mapper = require(ObjectMapper.class);
    + *
    + * }
    + * }
    + * + * @author edgar, kliushnichenko + * @since 4.1.0 + */ +public class Jackson3Module implements Extension, MessageDecoder, MessageEncoder { + // A hardcoded ID for our filter + public static final String FILTER_ID = "jooby.projection"; + + // Cache for ObjectWriters tied to specific projection strings + private final Map writerCache = new ConcurrentHashMap<>(); + + private final MediaType mediaType; + + private final ObjectMapper mapper; + private ObjectMapper projectionMapper; + + private final TypeFactory typeFactory; + + private final Set> modules = new HashSet<>(); + + private static final Map defaultTypes = new HashMap<>(); + + static { + defaultTypes.put("XmlMapper", MediaType.xml); + } + + /** + * Creates a Jackson module. + * + * @param mapper Object mapper to use. + * @param contentType Content type. + */ + public Jackson3Module(@NonNull ObjectMapper mapper, @NonNull MediaType contentType) { + this.mapper = mapper; + this.typeFactory = mapper.getTypeFactory(); + this.mediaType = contentType; + } + + /** + * Creates a Jackson module. + * + * @param mapper Object mapper to use. + */ + public Jackson3Module(@NonNull ObjectMapper mapper) { + this(mapper, defaultTypes.getOrDefault(mapper.getClass().getSimpleName(), MediaType.json)); + } + + /** + * Creates a Jackson module using the default object mapper from {@link + * #create(JacksonModule...)}. + */ + public Jackson3Module() { + this(create()); + } + + /** + * Add a Jackson module to the object mapper. This method require a dependency injection framework + * which is responsible for provisioning a module instance. + * + * @param module Module type. + * @return This module. + */ + public Jackson3Module module(Class module) { + modules.add(module); + return this; + } + + @Override + @SuppressWarnings({"rawtypes", "unchecked"}) + public void install(@NonNull Jooby application) { + application.decoder(mediaType, this); + application.encoder(mediaType, this); + + ServiceRegistry services = application.getServices(); + Class mapperType = mapper.getClass(); + services.put(mapperType, mapper); + services.put(ObjectMapper.class, mapper); + + // Parsing exception as 400 + application.errorCode(StreamReadException.class, StatusCode.BAD_REQUEST); + + application.onStarting(() -> onStarting(application, services, mapperType)); + + // 2. Branch off a specialized mapper JUST for Projections. + // .rebuild() copies the user's configuration, and we add our global MixIn + // strictly to this specialized instance. + projectionMapper = mapper.rebuild().addMixIn(Object.class, ProjectionMixIn.class).build(); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private void onStarting(Jooby application, ServiceRegistry services, Class mapperType) { + if (!modules.isEmpty()) { + var builder = mapper.rebuild(); + for (Class type : modules) { + JacksonModule module = application.require(type); + builder.addModule(module); + } + var newMapper = builder.build(); + services.put(mapperType, newMapper); + services.put(ObjectMapper.class, newMapper); + } + } + + @Override + public Output encode(@NonNull Context ctx, @NonNull Object value) { + var factory = ctx.getOutputFactory(); + ctx.setDefaultResponseType(mediaType); + if (value instanceof Projected projected) { + var p = projected.getProjection(); + + var writer = + writerCache.computeIfAbsent( + p.getType().getName() + p.toView(), + k -> { + // Build the filter and writer only once per unique projection string + var filters = + new SimpleFilterProvider().addFilter(FILTER_ID, new JacksonProjectionFilter(p)); + return projectionMapper.writer(filters); + }); + return factory.wrap(writer.writeValueAsBytes(projected.getValue())); + } + // let jackson uses his own cache, so wrap the bytes + return factory.wrap(mapper.writeValueAsBytes(value)); + } + + @Override + public Object decode(Context ctx, Type type) throws Exception { + Body body = ctx.body(); + if (body.isInMemory()) { + if (type == JsonNode.class) { + return mapper.readTree(body.bytes()); + } + return mapper.readValue(body.bytes(), typeFactory.constructType(type)); + } else { + try (InputStream stream = body.stream()) { + if (type == JsonNode.class) { + return mapper.readTree(stream); + } + return mapper.readValue(stream, typeFactory.constructType(type)); + } + } + } + + /** + * Default object mapper. + * + * @param modules Extra/additional modules to install. + * @return Object mapper instance. + */ + public static ObjectMapper create(JacksonModule... modules) { + JsonMapper.Builder builder = JsonMapper.builder(); + + Stream.of(modules).forEach(builder::addModule); + + return builder.build(); + } + + /** Global MixIn to force Jackson to apply our filter to ALL outgoing objects. */ + @JsonFilter(FILTER_ID) + private interface ProjectionMixIn {} +} diff --git a/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/JacksonProjectionFilter.java b/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/JacksonProjectionFilter.java new file mode 100644 index 0000000000..ce74da8e85 --- /dev/null +++ b/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/JacksonProjectionFilter.java @@ -0,0 +1,97 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.jackson3; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import io.jooby.Projection; +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.TokenStreamContext; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ser.AnyGetterWriter; +import tools.jackson.databind.ser.PropertyWriter; +import tools.jackson.databind.ser.std.SimpleBeanPropertyFilter; + +/** A Jackson 3 property filter that enforces a Jooby Projection. */ +public class JacksonProjectionFilter extends SimpleBeanPropertyFilter { + + private final Projection projection; + + public JacksonProjectionFilter(Projection projection) { + this.projection = projection; + } + + @Override + public void serializeAsProperty( + Object pojo, JsonGenerator gen, SerializationContext provider, PropertyWriter writer) + throws Exception { + + // Bypass projection filtering for Map entries to match Avaje's behavior. + // We want to dump the entire Map payload without validating its dynamic keys against the static + // tree. + if (pojo instanceof java.util.Map) { + writer.serializeAsProperty(pojo, gen, provider); + return; + } + + if (include(writer, gen)) { + writer.serializeAsProperty(pojo, gen, provider); + } else if (!gen.canOmitProperties()) { + writer.serializeAsOmittedProperty(pojo, gen, provider); + } else if (writer instanceof AnyGetterWriter) { + // Support for @JsonAnyGetter maps + ((AnyGetterWriter) writer).getAndFilter(pojo, gen, provider, this); + } + } + + // Custom include method that takes JsonGenerator so we can access the context + private boolean include(PropertyWriter writer, JsonGenerator gen) { + if (projection == null || projection.getChildren().isEmpty()) { + return true; // No projection applied, serialize everything + } + + String propName = writer.getName(); + TokenStreamContext context = gen.streamWriteContext(); + + // 1. Build the current path from Jackson's TokenStreamContext + List path = new ArrayList<>(); + path.add(propName); + + // Walk up the context tree to build the full property path. + // We skip ARRAY contexts because projections don't care about array indexes. + TokenStreamContext parent = context.getParent(); + while (parent != null && !parent.inRoot()) { + if (parent.currentName() != null) { + path.add(parent.currentName()); + } + parent = parent.getParent(); + } + + // Context gives us leaf-to-root, so we reverse it for root-to-leaf traversal + Collections.reverse(path); + + // 2. Traverse our Projection tree + Projection currentNode = projection; + + for (String pathSegment : path) { + // If the node has no children defined, it acts as a "deep wildcard" + if (currentNode.getChildren().isEmpty()) { + return true; + } + + currentNode = currentNode.getChildren().get(pathSegment); + + // If the path segment isn't found in the projection tree, block it + if (currentNode == null) { + return false; + } + } + + return true; + } +} diff --git a/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/package-info.java b/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/package-info.java new file mode 100644 index 0000000000..05d1271144 --- /dev/null +++ b/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/package-info.java @@ -0,0 +1,2 @@ +@edu.umd.cs.findbugs.annotations.ReturnValuesAreNonnullByDefault +package io.jooby.jackson3; diff --git a/modules/jooby-jackson3/src/main/java/module-info.java b/modules/jooby-jackson3/src/main/java/module-info.java new file mode 100644 index 0000000000..5500abfdda --- /dev/null +++ b/modules/jooby-jackson3/src/main/java/module-info.java @@ -0,0 +1,14 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +/** Jackson module. */ +module io.jooby.jackson3 { + exports io.jooby.jackson3; + + requires io.jooby; + requires static com.github.spotbugs.annotations; + requires typesafe.config; + requires tools.jackson.databind; +} diff --git a/modules/jooby-jackson3/src/test/java/io/jooby/jackson3/Jackson3JsonModuleTest.java b/modules/jooby-jackson3/src/test/java/io/jooby/jackson3/Jackson3JsonModuleTest.java new file mode 100644 index 0000000000..d54ad90ae0 --- /dev/null +++ b/modules/jooby-jackson3/src/test/java/io/jooby/jackson3/Jackson3JsonModuleTest.java @@ -0,0 +1,96 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.jackson3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.dataformat.xml.XmlMapper; +import io.jooby.Body; +import io.jooby.Context; +import io.jooby.MediaType; +import io.jooby.output.OutputFactory; +import io.jooby.output.OutputOptions; + +public class Jackson3JsonModuleTest { + + @Test + public void renderJson() { + Context ctx = mock(Context.class); + when(ctx.getOutputFactory()).thenReturn(OutputFactory.create(OutputOptions.small())); + + Jackson3Module jackson = new Jackson3Module(new ObjectMapper()); + + var buffer = jackson.encode(ctx, mapOf("k", "v")); + assertEquals("{\"k\":\"v\"}", StandardCharsets.UTF_8.decode(buffer.asByteBuffer()).toString()); + + verify(ctx).setDefaultResponseType(MediaType.json); + } + + @Test + public void parseJson() throws Exception { + byte[] bytes = "{\"k\":\"v\"}".getBytes(StandardCharsets.UTF_8); + Body body = mock(Body.class); + when(body.isInMemory()).thenReturn(true); + when(body.bytes()).thenReturn(bytes); + + Context ctx = mock(Context.class); + when(ctx.body()).thenReturn(body); + + Jackson3Module jackson = new Jackson3Module(new ObjectMapper()); + + Map result = (Map) jackson.decode(ctx, Map.class); + assertEquals(mapOf("k", "v"), result); + } + + @Test + public void renderXml() { + Context ctx = mock(Context.class); + when(ctx.getOutputFactory()).thenReturn(OutputFactory.create(OutputOptions.small())); + + Jackson3Module jackson = new Jackson3Module(new XmlMapper()); + + var buffer = jackson.encode(ctx, mapOf("k", "v")); + assertEquals( + "v", + StandardCharsets.UTF_8.decode(buffer.asByteBuffer()).toString()); + + verify(ctx).setDefaultResponseType(MediaType.xml); + } + + @Test + public void parseXml() throws Exception { + byte[] bytes = "v".getBytes(StandardCharsets.UTF_8); + Body body = mock(Body.class); + when(body.isInMemory()).thenReturn(true); + when(body.bytes()).thenReturn(bytes); + + Context ctx = mock(Context.class); + when(ctx.body()).thenReturn(body); + + Jackson3Module jackson = new Jackson3Module(new XmlMapper()); + + Map result = (Map) jackson.decode(ctx, Map.class); + assertEquals(mapOf("k", "v"), result); + } + + private Map mapOf(String... values) { + Map hash = new HashMap<>(); + for (int i = 0; i < values.length; i += 2) { + hash.put(values[i], values[i + 1]); + } + return hash; + } +} diff --git a/modules/jooby-jasypt/pom.xml b/modules/jooby-jasypt/pom.xml index 741a18b13e..da4fd2dacc 100644 --- a/modules/jooby-jasypt/pom.xml +++ b/modules/jooby-jasypt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-jasypt jooby-jasypt diff --git a/modules/jooby-jdbi/pom.xml b/modules/jooby-jdbi/pom.xml index 2f9fd91b85..39dd6522f7 100644 --- a/modules/jooby-jdbi/pom.xml +++ b/modules/jooby-jdbi/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-jdbi jooby-jdbi diff --git a/modules/jooby-jetty/pom.xml b/modules/jooby-jetty/pom.xml index 659a383054..f0026fa07f 100644 --- a/modules/jooby-jetty/pom.xml +++ b/modules/jooby-jetty/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-jetty jooby-jetty diff --git a/modules/jooby-jstachio/pom.xml b/modules/jooby-jstachio/pom.xml index 5e69224d1e..a964fb17ff 100644 --- a/modules/jooby-jstachio/pom.xml +++ b/modules/jooby-jstachio/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-jstachio jooby-jstachio diff --git a/modules/jooby-jte/pom.xml b/modules/jooby-jte/pom.xml index 20639635fb..48aa7ccfa2 100644 --- a/modules/jooby-jte/pom.xml +++ b/modules/jooby-jte/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-jte jooby-jte diff --git a/modules/jooby-jwt/pom.xml b/modules/jooby-jwt/pom.xml index cbbf08cc1b..f678afdc39 100644 --- a/modules/jooby-jwt/pom.xml +++ b/modules/jooby-jwt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-jwt jooby-jwt diff --git a/modules/jooby-kafka/pom.xml b/modules/jooby-kafka/pom.xml index d60ddb258b..eab93e6182 100644 --- a/modules/jooby-kafka/pom.xml +++ b/modules/jooby-kafka/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-kafka jooby-kafka diff --git a/modules/jooby-kotlin/pom.xml b/modules/jooby-kotlin/pom.xml index 3d26f8fba9..24a195024a 100644 --- a/modules/jooby-kotlin/pom.xml +++ b/modules/jooby-kotlin/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-kotlin jooby-kotlin diff --git a/modules/jooby-log4j/pom.xml b/modules/jooby-log4j/pom.xml index 4282d7e726..4d8d916f5a 100644 --- a/modules/jooby-log4j/pom.xml +++ b/modules/jooby-log4j/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-log4j jooby-log4j diff --git a/modules/jooby-logback/pom.xml b/modules/jooby-logback/pom.xml index 27baecd5bc..b82e84ed39 100644 --- a/modules/jooby-logback/pom.xml +++ b/modules/jooby-logback/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-logback jooby-logback diff --git a/modules/jooby-maven-plugin/pom.xml b/modules/jooby-maven-plugin/pom.xml index b226646ce7..41844833ae 100644 --- a/modules/jooby-maven-plugin/pom.xml +++ b/modules/jooby-maven-plugin/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-maven-plugin jooby-maven-plugin diff --git a/modules/jooby-metrics/pom.xml b/modules/jooby-metrics/pom.xml index 0905ce3b65..49def3c1b3 100644 --- a/modules/jooby-metrics/pom.xml +++ b/modules/jooby-metrics/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-metrics jooby-metrics diff --git a/modules/jooby-mutiny/pom.xml b/modules/jooby-mutiny/pom.xml index 6407b95a83..8395662b4c 100644 --- a/modules/jooby-mutiny/pom.xml +++ b/modules/jooby-mutiny/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-mutiny jooby-mutiny @@ -26,7 +26,7 @@ io.smallrye.reactive mutiny - 3.1.0 + 3.1.1 diff --git a/modules/jooby-netty/pom.xml b/modules/jooby-netty/pom.xml index 3a9031fb97..f1e56b022c 100644 --- a/modules/jooby-netty/pom.xml +++ b/modules/jooby-netty/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-netty jooby-netty diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/Http2Extension.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/Http2Extension.java deleted file mode 100644 index 1ff0bb2d4d..0000000000 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/Http2Extension.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.netty; - -import java.util.function.BiConsumer; -import java.util.function.Consumer; -import java.util.function.Function; -import java.util.function.Supplier; - -import io.netty.channel.ChannelOutboundHandler; -import io.netty.channel.ChannelPipeline; -import io.netty.handler.codec.http.HttpServerUpgradeHandler; - -public class Http2Extension { - - private Http2Settings settings; - - private Consumer http11; - - private BiConsumer> - http11Upgrade; - - private BiConsumer> http2; - - private BiConsumer> http2c; - - public Http2Extension( - Http2Settings settings, - Consumer http11, - BiConsumer> http11Upgrade, - BiConsumer> http2, - BiConsumer> http2c) { - this.settings = settings; - this.http11 = http11; - this.http11Upgrade = http11Upgrade; - this.http2 = http2; - this.http2c = http2c; - } - - public boolean isSecure() { - return settings.isSecure(); - } - - public void http11(ChannelPipeline pipeline) { - this.http11.accept(pipeline); - } - - public void http2( - ChannelPipeline pipeline, Function factory) { - this.http2.accept(pipeline, () -> factory.apply(settings)); - } - - public void http2c( - ChannelPipeline pipeline, Function factory) { - this.http2c.accept(pipeline, () -> factory.apply(settings)); - } - - public void http11Upgrade( - ChannelPipeline pipeline, - Function factory) { - this.http11Upgrade.accept(pipeline, () -> factory.apply(settings)); - } -} diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/Http2Settings.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/Http2Settings.java deleted file mode 100644 index 4a99f63d5d..0000000000 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/Http2Settings.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.netty; - -public class Http2Settings { - private final int maxRequestSize; - private final boolean secure; - - public Http2Settings(long maxRequestSize, boolean secure) { - this.maxRequestSize = (int) maxRequestSize; - this.secure = secure; - } - - public boolean isSecure() { - return secure; - } - - public int getMaxRequestSize() { - return maxRequestSize; - } -} diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyContext.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyContext.java index 0f1c34d959..91d2496e92 100644 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyContext.java +++ b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyContext.java @@ -46,12 +46,7 @@ import io.jooby.value.Value; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; -import io.netty.channel.ChannelFuture; -import io.netty.channel.ChannelFutureListener; -import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.ChannelPipeline; -import io.netty.channel.ChannelPromise; -import io.netty.channel.DefaultFileRegion; +import io.netty.channel.*; import io.netty.handler.codec.http.*; import io.netty.handler.codec.http.cookie.ServerCookieDecoder; import io.netty.handler.codec.http.multipart.*; @@ -320,7 +315,7 @@ public String getProtocol() { @NonNull @Override public List getClientCertificates() { - SslHandler sslHandler = (SslHandler) ctx.channel().pipeline().get("ssl"); + var sslHandler = ssl(); if (sslHandler != null) { try { return List.of(sslHandler.engine().getSession().getPeerCertificates()); @@ -334,11 +329,22 @@ public List getClientCertificates() { @NonNull @Override public String getScheme() { if (scheme == null) { - scheme = ctx.pipeline().get("ssl") == null ? "http" : "https"; + scheme = ssl() == null ? "http" : "https"; } return scheme; } + private SslHandler ssl() { + return (SslHandler) + Stream.of(ctx.channel(), ctx.channel().parent()) + .filter(Objects::nonNull) + .map(Channel::pipeline) + .map(it -> it.get("ssl")) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + } + @NonNull @Override public Context setScheme(@NonNull String scheme) { this.scheme = scheme; @@ -416,7 +422,7 @@ public Context upgrade(WebSocket.Initializer handler) { ? conf.getBytes("websocket.maxSize").intValue() : WebSocket.MAX_BUFFER_SIZE; String webSocketURL = getProtocol() + "://" + req.headers().get(HttpHeaderNames.HOST) + path; - WebSocketDecoderConfig config = + var config = WebSocketDecoderConfig.newBuilder() .allowExtensions(true) .allowMaskMismatch(false) @@ -425,7 +431,7 @@ public Context upgrade(WebSocket.Initializer handler) { .build(); webSocket = new NettyWebSocket(this); handler.init(Context.readOnly(this), webSocket); - FullHttpRequest webSocketRequest = + var webSocketRequest = new DefaultFullHttpRequest( HTTP_1_1, req.method(), @@ -433,6 +439,8 @@ public Context upgrade(WebSocket.Initializer handler) { Unpooled.EMPTY_BUFFER, req.headers(), EmptyHttpHeaders.INSTANCE); + var codec = ctx.pipeline().get(NettyServerCodec.class); + codec.webSocketHandshake(ctx); WebSocketServerHandshakerFactory factory = new WebSocketServerHandshakerFactory(webSocketURL, null, config); WebSocketServerHandshaker handshaker = factory.newHandshaker(webSocketRequest); @@ -856,15 +864,9 @@ private long responseLength() { private void prepareChunked() { responseStarted = true; // remove flusher, doesn't play well with streaming/chunked responses - ChannelPipeline pipeline = ctx.pipeline(); + var pipeline = ctx.pipeline(); if (pipeline.get("chunker") == null) { - String base = - Stream.of("compressor", "encoder", "codec", "http2") - .filter(name -> pipeline.get(name) != null) - .findFirst() - .orElseThrow( - () -> new IllegalStateException("No available handler for chunk writer")); - pipeline.addAfter(base, "chunker", new ChunkedWriteHandler()); + pipeline.addBefore("handler", "chunker", new ChunkedWriteHandler()); } if (!setHeaders.contains(CONTENT_LENGTH)) { setHeaders.set(TRANSFER_ENCODING, CHUNKED); diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyPipeline.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyPipeline.java index 1fd488d2be..61437319a0 100644 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyPipeline.java +++ b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyPipeline.java @@ -5,20 +5,23 @@ */ package io.jooby.internal.netty; +import java.util.List; import java.util.concurrent.ScheduledExecutorService; -import java.util.function.Supplier; import io.jooby.Context; -import io.jooby.internal.netty.http2.NettyHttp2Configurer; -import io.netty.channel.ChannelInitializer; -import io.netty.channel.ChannelOutboundHandler; -import io.netty.channel.ChannelPipeline; +import io.netty.buffer.ByteBuf; +import io.netty.channel.*; import io.netty.channel.socket.SocketChannel; +import io.netty.handler.codec.ByteToMessageDecoder; import io.netty.handler.codec.http.*; +import io.netty.handler.codec.http2.*; +import io.netty.handler.ssl.ApplicationProtocolNames; +import io.netty.handler.ssl.ApplicationProtocolNegotiationHandler; import io.netty.handler.ssl.SslContext; public class NettyPipeline extends ChannelInitializer { private static final String H2_HANDSHAKE = "h2-handshake"; + private final SslContext sslContext; private final HttpDecoderConfig decoderConfig; private final Context.Selector contextSelector; @@ -53,45 +56,61 @@ public NettyPipeline( this.compressionLevel = compressionLevel; } - private NettyHandler createHandler(ScheduledExecutorService executor) { - return new NettyHandler( - new NettyDateService(executor), - contextSelector, - maxRequestSize, - maxFormFields, - bufferSize, - defaultHeaders, - http2); - } - @Override public void initChannel(SocketChannel ch) { - var p = ch.pipeline(); + ChannelPipeline p = ch.pipeline(); + if (sslContext != null) { p.addLast("ssl", sslContext.newHandler(ch.alloc())); } - // https://github.com/jooby-project/jooby/issues/3433: - // using new FlushConsolidationHandler(DEFAULT_EXPLICIT_FLUSH_AFTER_FLUSHES, true) - // cause the bug, for now I'm going to remove flush consolidating handler... doesn't seem to - // help much - // p.addLast(new FlushConsolidationHandler(DEFAULT_EXPLICIT_FLUSH_AFTER_FLUSHES, false)); + if (http2) { - var settings = new Http2Settings(maxRequestSize, sslContext != null); - var extension = - new Http2Extension( - settings, this::http11, this::http11Upgrade, this::http2, this::http2c); - var configurer = new NettyHttp2Configurer(); - var handshake = configurer.configure(extension); - - p.addLast(H2_HANDSHAKE, handshake); - additionalHandlers(p); - p.addLast("handler", createHandler(ch.eventLoop())); + p.addLast(H2_HANDSHAKE, setupHttp2Handshake(sslContext != null)); } else { - http11(p); + setupHttp11(p); + } + } + + private void setupHttp11(ChannelPipeline p) { + p.addLast("codec", createServerCodec()); + addCommonHandlers(p); + p.addLast("handler", createHandler(p.channel().eventLoop())); + } + + private void setupHttp2(ChannelPipeline pipeline) { + var frameCodec = + Http2FrameCodecBuilder.forServer() + .initialSettings(Http2Settings.defaultSettings().maxFrameSize((int) maxRequestSize)) + .build(); + + pipeline.addLast("http2-codec", frameCodec); + pipeline.addLast( + "http2-multiplex", new Http2MultiplexHandler(new Http2StreamInitializer(this))); + } + + private void setupHttp11Upgrade(ChannelPipeline pipeline) { + var serverCodec = createServerCodec(); + pipeline.addLast("codec", serverCodec); + + pipeline.addLast( + "h2upgrade", + new HttpServerUpgradeHandler( + serverCodec, + protocol -> "h2c".equals(protocol.toString()) ? createH2CUpgradeCodec() : null, + (int) maxRequestSize)); + + addCommonHandlers(pipeline); + pipeline.addLast("handler", createHandler(pipeline.channel().eventLoop())); + } + + private ChannelInboundHandler setupHttp2Handshake(boolean secure) { + if (secure) { + return new AlpnHandler(this); } + return new Http2PrefaceOrHttpHandler(this); } - private void additionalHandlers(ChannelPipeline p) { + private void addCommonHandlers(ChannelPipeline p) { if (expectContinue) { p.addLast("expect-continue", new HttpServerExpectContinueHandler()); } @@ -101,32 +120,80 @@ private void additionalHandlers(ChannelPipeline p) { } } - private void http2(ChannelPipeline pipeline, Supplier factory) { - pipeline.addAfter(H2_HANDSHAKE, "http2", factory.get()); + private Http2ServerUpgradeCodec createH2CUpgradeCodec() { + return new Http2ServerUpgradeCodec( + Http2FrameCodecBuilder.forServer().build(), + new Http2MultiplexHandler(new Http2StreamInitializer(this))); } - private void http2c(ChannelPipeline pipeline, Supplier factory) { - pipeline.addAfter(H2_HANDSHAKE, "http2", factory.get()); + private NettyHandler createHandler(ScheduledExecutorService executor) { + return new NettyHandler( + new NettyDateService(executor), + contextSelector, + maxRequestSize, + maxFormFields, + bufferSize, + defaultHeaders, + http2); } - private void http11Upgrade( - ChannelPipeline pipeline, Supplier factory) { - // direct http1 to h2c - HttpServerCodec serverCodec = new HttpServerCodec(decoderConfig); - pipeline.addAfter(H2_HANDSHAKE, "codec", serverCodec); - pipeline.addAfter( - "codec", - "h2upgrade", - new HttpServerUpgradeHandler( - serverCodec, - protocol -> protocol.toString().equals("h2c") ? factory.get() : null, - (int) maxRequestSize)); + private NettyServerCodec createServerCodec() { + return new NettyServerCodec(decoderConfig); } - private void http11(ChannelPipeline p) { - p.addLast("decoder", new NettyRequestDecoder(decoderConfig)); - p.addLast("encoder", new NettyResponseEncoder()); - additionalHandlers(p); - p.addLast("handler", createHandler(p.channel().eventLoop())); + /** Handles the transition from ALPN to H1 or H2 */ + private static class AlpnHandler extends ApplicationProtocolNegotiationHandler { + private final NettyPipeline pipeline; + + AlpnHandler(NettyPipeline pipeline) { + super(ApplicationProtocolNames.HTTP_1_1); + this.pipeline = pipeline; + } + + @Override + protected void configurePipeline(ChannelHandlerContext ctx, String protocol) { + if (ApplicationProtocolNames.HTTP_2.equals(protocol)) { + pipeline.setupHttp2(ctx.pipeline()); + } else { + pipeline.setupHttp11(ctx.pipeline()); + } + } + } + + /** Detects HTTP/2 connection preface or upgrades to H1/H2C */ + private static class Http2PrefaceOrHttpHandler extends ByteToMessageDecoder { + private static final int PRI = 0x50524920; // "PRI " + private final NettyPipeline pipeline; + + Http2PrefaceOrHttpHandler(NettyPipeline pipeline) { + this.pipeline = pipeline; + } + + @Override + protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { + if (in.readableBytes() < 4) return; + + if (in.getInt(in.readerIndex()) == PRI) { + pipeline.setupHttp2(ctx.pipeline()); + } else { + pipeline.setupHttp11Upgrade(ctx.pipeline()); + } + ctx.pipeline().remove(this); + } + } + + /** Initializes the child channels created for each HTTP/2 stream */ + private static class Http2StreamInitializer extends ChannelInitializer { + private final NettyPipeline pipeline; + + Http2StreamInitializer(NettyPipeline pipeline) { + this.pipeline = pipeline; + } + + @Override + protected void initChannel(Channel ch) { + ch.pipeline().addLast("http2", new Http2StreamFrameToHttpObjectCodec(true)); + ch.pipeline().addLast("handler", pipeline.createHandler(ch.eventLoop())); + } } } diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyRequestDecoder.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyRequestDecoder.java deleted file mode 100644 index 71788e2c3a..0000000000 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyRequestDecoder.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.netty; - -import io.netty.handler.codec.http.*; - -public class NettyRequestDecoder extends HttpRequestDecoder { - - private static final String GET = HttpMethod.GET.name(); - private static final String POST = HttpMethod.POST.name(); - private static final String PUT = HttpMethod.PUT.name(); - private static final String DELETE = HttpMethod.DELETE.name(); - - public NettyRequestDecoder(HttpDecoderConfig config) { - super(config); - } - - @Override - protected HttpMessage createMessage(String[] initialLine) throws Exception { - return new DefaultHttpRequest( - HttpVersion.valueOf(initialLine[2]), - valueOf(initialLine[0]), - initialLine[1], - headersFactory); - } - - @Override - protected boolean isContentAlwaysEmpty(HttpMessage msg) { - return false; - } - - private static HttpMethod valueOf(String name) { - // fast-path - if (name == GET) { - return HttpMethod.GET; - } - if (name == POST) { - return HttpMethod.POST; - } - if (name == DELETE) { - return HttpMethod.DELETE; - } - if (name == PUT) { - return HttpMethod.PUT; - } - // "slow"-path: ensure method is on upper case - return HttpMethod.valueOf(name.toUpperCase()); - } -} diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyResponseEncoder.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyResponseEncoder.java deleted file mode 100644 index c8c26cb2b1..0000000000 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyResponseEncoder.java +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.netty; - -import io.netty.buffer.ByteBuf; -import io.netty.handler.codec.http.HttpHeaders; -import io.netty.handler.codec.http.HttpResponseEncoder; - -public class NettyResponseEncoder extends HttpResponseEncoder { - @Override - protected void encodeHeaders(HttpHeaders headers, ByteBuf buf) { - if (headers.getClass() == HeadersMultiMap.class) { - ((HeadersMultiMap) headers).encode(buf); - } else { - super.encodeHeaders(headers, buf); - } - } -} diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyServerCodec.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyServerCodec.java new file mode 100644 index 0000000000..25544286dd --- /dev/null +++ b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyServerCodec.java @@ -0,0 +1,136 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.netty; + +import java.util.ArrayDeque; +import java.util.List; +import java.util.Queue; + +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.CombinedChannelDuplexHandler; +import io.netty.handler.codec.http.*; + +/** + * Copy of {@link HttpServerCodec} with a custom request method parser and optimized header response + * writer. + */ +public class NettyServerCodec + extends CombinedChannelDuplexHandler + implements HttpServerUpgradeHandler.SourceCodec { + + /** A queue that is used for correlating a request and a response. */ + private final Queue queue = new ArrayDeque(); + + private final HttpDecoderConfig decoderConfig; + + /** Creates a new instance with the specified decoder configuration. */ + public NettyServerCodec(HttpDecoderConfig decoderConfig) { + this.decoderConfig = decoderConfig; + init(new HttpServerRequestDecoder(decoderConfig), new HttpServerResponseEncoder()); + } + + /** + * Web socket looks for these two component while doing the upgrade. + * + * @param ctx Channel context. + */ + /*package*/ void webSocketHandshake(ChannelHandlerContext ctx) { + var p = ctx.pipeline(); + var codec = p.context(getClass()).name(); + p.addBefore(codec, "encoder", new HttpServerResponseEncoder()); + p.addBefore(codec, "decoder", new HttpServerRequestDecoder(decoderConfig)); + p.remove(this); + } + + /** + * Upgrades to another protocol from HTTP. Removes the {@link HttpRequestDecoder} and {@link + * HttpResponseEncoder} from the pipeline. + */ + @Override + public void upgradeFrom(ChannelHandlerContext ctx) { + ctx.pipeline().remove(this); + } + + private final class HttpServerRequestDecoder extends HttpRequestDecoder { + HttpServerRequestDecoder(HttpDecoderConfig config) { + super(config); + } + + @Override + protected HttpMessage createMessage(String[] initialLine) { + return new DefaultHttpRequest( + // Do strict version checking + HttpVersion.valueOf(initialLine[2]), + httpMethod(initialLine[0]), + initialLine[1], + headersFactory); + } + + public static HttpMethod httpMethod(String name) { + return switch (name) { + case "OPTIONS" -> HttpMethod.OPTIONS; + case "GET" -> HttpMethod.GET; + case "HEAD" -> HttpMethod.HEAD; + case "POST" -> HttpMethod.POST; + case "PUT" -> HttpMethod.PUT; + case "PATCH" -> HttpMethod.PATCH; + case "DELETE" -> HttpMethod.DELETE; + case "TRACE" -> HttpMethod.TRACE; + case "CONNECT" -> HttpMethod.CONNECT; + default -> new HttpMethod(name.toUpperCase()); + }; + } + + @Override + protected void decode(ChannelHandlerContext ctx, ByteBuf buffer, List out) + throws Exception { + int oldSize = out.size(); + super.decode(ctx, buffer, out); + int size = out.size(); + for (int i = oldSize; i < size; i++) { + Object obj = out.get(i); + if (obj instanceof HttpRequest) { + queue.add(((HttpRequest) obj).method()); + } + } + } + } + + private final class HttpServerResponseEncoder extends HttpResponseEncoder { + + private HttpMethod method; + + @Override + protected void sanitizeHeadersBeforeEncode(HttpResponse msg, boolean isAlwaysEmpty) { + if (!isAlwaysEmpty + && HttpMethod.CONNECT.equals(method) + && msg.status().codeClass() == HttpStatusClass.SUCCESS) { + // Stripping Transfer-Encoding: + // See https://tools.ietf.org/html/rfc7230#section-3.3.1 + msg.headers().remove(HttpHeaderNames.TRANSFER_ENCODING); + return; + } + + super.sanitizeHeadersBeforeEncode(msg, isAlwaysEmpty); + } + + @Override + protected void encodeHeaders(HttpHeaders headers, ByteBuf buf) { + if (headers.getClass() == HeadersMultiMap.class) { + ((HeadersMultiMap) headers).encode(buf); + } else { + super.encodeHeaders(headers, buf); + } + } + + @Override + protected boolean isContentAlwaysEmpty(@SuppressWarnings("unused") HttpResponse msg) { + method = queue.poll(); + return HttpMethod.HEAD.equals(method) || super.isContentAlwaysEmpty(msg); + } + } +} diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/Http2OrHttp11Handler.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/Http2OrHttp11Handler.java deleted file mode 100644 index 0af06a6f70..0000000000 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/Http2OrHttp11Handler.java +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.netty.http2; - -import java.util.function.Consumer; - -import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.ChannelPipeline; -import io.netty.handler.ssl.ApplicationProtocolNames; -import io.netty.handler.ssl.ApplicationProtocolNegotiationHandler; - -class Http2OrHttp11Handler extends ApplicationProtocolNegotiationHandler { - - private final Consumer http2; - private final Consumer http1; - - public Http2OrHttp11Handler(Consumer http1, Consumer http2) { - super(ApplicationProtocolNames.HTTP_1_1); - this.http2 = http2; - this.http1 = http1; - } - - @Override - public void configurePipeline(final ChannelHandlerContext ctx, final String protocol) { - if (ApplicationProtocolNames.HTTP_1_1.equals(protocol)) { - http1.accept(ctx.pipeline()); - } else if (ApplicationProtocolNames.HTTP_2.equals(protocol)) { - http2.accept(ctx.pipeline()); - } else { - throw new IllegalStateException("Unknown protocol: " + protocol); - } - } -} diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/Http2PrefaceOrHttpHandler.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/Http2PrefaceOrHttpHandler.java deleted file mode 100644 index 51900925d3..0000000000 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/Http2PrefaceOrHttpHandler.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.netty.http2; - -import java.util.List; -import java.util.function.Consumer; - -import io.netty.buffer.ByteBuf; -import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.ChannelPipeline; -import io.netty.handler.codec.ByteToMessageDecoder; - -class Http2PrefaceOrHttpHandler extends ByteToMessageDecoder { - - private static final int PRI = 0x50524920; - - private Consumer http1; - - private Consumer http2; - - public Http2PrefaceOrHttpHandler( - Consumer http1, Consumer http2) { - this.http1 = http1; - this.http2 = http2; - } - - @Override - protected void decode(final ChannelHandlerContext ctx, final ByteBuf in, final List out) { - if (in.readableBytes() < 4) { - return; - } - - if (in.getInt(in.readerIndex()) == PRI) { - http2.accept(ctx.pipeline()); - } else { - http1.accept(ctx.pipeline()); - } - - ctx.pipeline().remove(this); - } -} diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/NettyHttp2Configurer.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/NettyHttp2Configurer.java deleted file mode 100644 index 3e0de59faf..0000000000 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/NettyHttp2Configurer.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.netty.http2; - -import static io.netty.handler.codec.http.HttpScheme.HTTP; - -import io.jooby.internal.netty.Http2Extension; -import io.netty.channel.ChannelInboundHandler; -import io.netty.handler.codec.http.HttpScheme; -import io.netty.handler.codec.http2.DefaultHttp2Connection; -import io.netty.handler.codec.http2.Http2ConnectionHandler; -import io.netty.handler.codec.http2.Http2FrameLogger; -import io.netty.handler.codec.http2.Http2ServerUpgradeCodec; -import io.netty.handler.codec.http2.HttpToHttp2ConnectionHandlerBuilder; -import io.netty.handler.codec.http2.InboundHttp2ToHttpAdapter; -import io.netty.handler.codec.http2.InboundHttp2ToHttpAdapterBuilder; -import io.netty.handler.logging.LogLevel; - -public class NettyHttp2Configurer { - - public ChannelInboundHandler configure(Http2Extension extension) { - if (extension.isSecure()) { - return new Http2OrHttp11Handler( - extension::http11, - pipeline -> - extension.http2( - pipeline, - settings -> newHttp2Handler(settings.getMaxRequestSize(), HttpScheme.HTTPS))); - } else { - return new Http2PrefaceOrHttpHandler( - pipeline -> - extension.http11Upgrade( - pipeline, - settings -> - new Http2ServerUpgradeCodec( - newHttp2Handler(settings.getMaxRequestSize(), HTTP))), - pipeline -> - extension.http2c( - pipeline, settings -> newHttp2Handler(settings.getMaxRequestSize(), HTTP))); - } - } - - private Http2ConnectionHandler newHttp2Handler(int maxRequestSize, HttpScheme scheme) { - DefaultHttp2Connection connection = new DefaultHttp2Connection(true); - InboundHttp2ToHttpAdapter listener = - new InboundHttp2ToHttpAdapterBuilder(connection) - .propagateSettings(false) - .validateHttpHeaders(true) - .maxContentLength(maxRequestSize) - .build(); - - return new HttpToHttp2ConnectionHandlerBuilder() - .frameListener(listener) - .frameLogger(new Http2FrameLogger(LogLevel.DEBUG)) - .connection(connection) - .httpScheme(scheme) - .build(); - } -} diff --git a/modules/jooby-openapi/pom.xml b/modules/jooby-openapi/pom.xml index 6deaaca0bf..89fb3ca557 100644 --- a/modules/jooby-openapi/pom.xml +++ b/modules/jooby-openapi/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-openapi jooby-openapi @@ -53,7 +53,7 @@ com.puppycrawl.tools checkstyle - 13.0.0 + 13.3.0 @@ -69,12 +69,12 @@ net.datafaker datafaker - 2.5.3 + 2.5.4 commons-codec commons-codec - 1.20.0 + 1.21.0 @@ -110,7 +110,7 @@ io.avaje avaje-inject - 12.2 + 12.4 test @@ -156,7 +156,7 @@ net.bytebuddy byte-buddy - 1.18.3 + 1.18.7 test @@ -167,7 +167,7 @@ org.assertj assertj-core - 3.27.6 + 3.27.7 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 30a0b82a73..27edcd6b71 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 @@ -122,6 +122,20 @@ public Optional getDefaultValue(List annotations) { } } } + return getJakartaDefaultValue(annotations); + } + + public Optional getJakartaDefaultValue(List annotations) { + List names = + Stream.of(annotations()).filter(it -> it.getName().startsWith("jakarta.ws.rs")).toList(); + for (var a : annotations) { + if (a.values != null) { + var matches = names.stream().anyMatch(it -> "Ljakarta/ws/rs/DefaultValue;".equals(a.desc)); + if (matches) { + return AnnotationUtils.findAnnotationValue(a, "value").map(Objects::toString); + } + } + } return Optional.empty(); } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ClassSource.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ClassSource.java index 375b632b2c..e1597ea654 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ClassSource.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ClassSource.java @@ -5,8 +5,6 @@ */ package io.jooby.internal.openapi; -import java.io.FileNotFoundException; -import java.io.IOException; import java.io.InputStream; import io.jooby.SneakyThrows; @@ -33,13 +31,4 @@ public byte[] loadClass(String classname) { throw SneakyThrows.propagate(x); } } - - public byte[] loadResource(String path) throws IOException { - try (InputStream stream = classLoader.getResourceAsStream(path)) { - if (stream == null) { - throw new FileNotFoundException(path); - } - return stream.readAllBytes(); - } - } } 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 0004a40ab1..8e10b49b49 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 @@ -14,6 +14,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import io.jooby.Router; import io.swagger.v3.oas.models.*; +import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.parameters.Parameter; import io.swagger.v3.oas.models.security.SecurityScheme; @@ -39,12 +40,24 @@ public void setSource(String classname) { } public void addSecuritySchemes(String name, SecurityScheme scheme) { - var components = getComponents(); - if (components == null) { - components = new Components(); - setComponents(components); + getRequiredComponents().addSecuritySchemes(name, scheme); + } + + @JsonIgnore + public Components getRequiredComponents() { + if (getComponents() == null) { + setComponents(new Components()); + } + return getComponents(); + } + + @JsonIgnore + public Map getRequiredSchemas() { + var components = getRequiredComponents(); + if (components.getSchemas() == null) { + components.setSchemas(new LinkedHashMap<>()); } - components.addSecuritySchemes(name, scheme); + return components.getSchemas(); } @Override 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 f04c8d3ab2..55436e68af 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 @@ -232,6 +232,23 @@ public List getAllAnnotations() { .collect(Collectors.toList()); } + @JsonIgnore + public String getProjection() { + return getAllAnnotations().stream() + .filter( + it -> + Router.METHODS.stream() + .map(method -> "Lio/jooby/annotation/" + method + ";") + .anyMatch(it.desc::equals) + && it.values != null) + .map(it -> AnnotationUtils.findAnnotationValue(it, "projection")) + .filter(Optional::isPresent) + .map(Optional::get) + .findFirst() + .map(Object::toString) + .orElse(null); + } + public OperationExt copy(String pattern) { OperationExt copy = new OperationExt(node, method, pattern, getParameters(), defaultResponse); copy.setTags(getTags()); @@ -275,4 +292,9 @@ public OperationExt copy(String pattern) { public String getPath(Map pathParams) { return Router.reverse(getPath(), pathParams); } + + @JsonIgnore + public boolean isScript() { + return getController() == null; + } } 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 f9ccb3102e..a699d77b55 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 @@ -12,7 +12,6 @@ import static java.util.Arrays.asList; import java.io.File; -import java.io.IOException; import java.io.InputStream; import java.io.PrintWriter; import java.io.Reader; @@ -54,10 +53,7 @@ import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.databind.type.SimpleType; import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; -import io.jooby.Context; -import io.jooby.FileUpload; -import io.jooby.SneakyThrows; -import io.jooby.StatusCode; +import io.jooby.*; import io.jooby.internal.openapi.javadoc.JavaDocParser; import io.jooby.openapi.DebugOption; import io.swagger.v3.core.util.RefUtils; @@ -173,6 +169,9 @@ public Schema schema(Class type) { if (isVoid(type.getName())) { return null; } + if (type == Projected.class) { + return new ObjectSchema().name("Projected"); + } if (type == String.class) { return new StringSchema(); } @@ -427,10 +426,14 @@ public Schema schema(String type) { if (schema != null) { return schema.toSchema(); } + return schema(javaType(type)); + } + + public JavaType javaType(String type) { String json = "{\"type\":\"" + type + "\"}"; try { TypeLiteral literal = json().readValue(json, TypeLiteral.class); - return schema(literal.type); + return literal.type; } catch (Exception x) { throw SneakyThrows.propagate(x); } @@ -493,6 +496,13 @@ public MethodNode findMethodNode(Type type, String name) { .orElseThrow(() -> new IllegalArgumentException("Method not found: " + type + "." + name)); } + public MethodNode findMethodNode(Type type, String name, String desc) { + return nodes.computeIfAbsent(type, this::newClassNode).methods.stream() + .filter(it -> it.name.equals(name) && it.desc.equals(desc)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Method not found: " + type + "." + name)); + } + public ClassNode classNodeOrNull(Type type) { try { return nodes.computeIfAbsent(type, this::newClassNode); @@ -501,10 +511,6 @@ public ClassNode classNodeOrNull(Type type) { } } - public byte[] loadResource(String path) throws IOException { - return source.loadResource(path); - } - private ClassNode newClassNode(Type type) { ClassReader reader = new ClassReader(source.loadClass(type.getClassName())); if (debug.contains(DebugOption.ALL)) { 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 95d6c45ba5..ca5dc2ab1b 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 @@ -271,7 +271,7 @@ private String patternToOperationId(String pattern) { return ""; } return Stream.of(pattern.split("\\W+")) - .filter(s -> s.length() > 0) + .filter(s -> !s.isEmpty()) .map( segment -> Character.toUpperCase(segment.charAt(0)) diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/SchemaPurger.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/SchemaPurger.java new file mode 100644 index 0000000000..73b0663f44 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/SchemaPurger.java @@ -0,0 +1,230 @@ +/* + * 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.*; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.Schema; + +public class SchemaPurger { + + public static void purgeUnused(OpenAPI openAPI) { + if (openAPI == null + || openAPI.getComponents() == null + || openAPI.getComponents().getSchemas() == null) { + return; + } + + Set visitedSchemas = new HashSet<>(); + Queue queue = new LinkedList<>(); + + // 1. Gather Roots (Using your visitor/parser) + // Scan Paths, Parameters, Responses, and RequestBodies for $refs pointing to schemas. + Set rootSchemaNames = extractRootSchemaNames(openAPI); + + for (String schemaName : rootSchemaNames) { + if (visitedSchemas.add(schemaName)) { + queue.add(schemaName); + } + } + + Map allSchemas = openAPI.getComponents().getSchemas(); + + // 2. Traverse Graph (BFS) + while (!queue.isEmpty()) { + String currentName = queue.poll(); + Schema currentSchema = allSchemas.get(currentName); + + if (currentSchema == null) continue; + + // Scan this schema for nested $refs (properties, items, allOf, anyOf, oneOf) + Set nestedSchemaNames = extractSchemaNamesFromSchema(currentSchema); + + for (String nestedName : nestedSchemaNames) { + // CIRCULAR REFERENCE CHECK: + // visitedSchemas.add() returns false if the element is already present. + // If it's already visited, we ignore it, breaking the cycle. + if (visitedSchemas.add(nestedName)) { + queue.add(nestedName); + } + } + } + + // 3. Purge Unused + // retainAll efficiently drops any key from the components map that isn't in our visited set. + allSchemas.keySet().retainAll(visitedSchemas); + } + + // --- Helper Methods (To be integrated with your OpenAPI visitor) --- + + private static Set extractRootSchemaNames(OpenAPI openAPI) { + Set roots = new HashSet<>(); + + // 1. Scan Paths for schemas used in operations + if (openAPI.getPaths() != null) { + openAPI + .getPaths() + .values() + .forEach( + pathItem -> { + + // Check path-level parameters + if (pathItem.getParameters() != null) { + pathItem + .getParameters() + .forEach( + param -> roots.addAll(extractSchemaNamesFromSchema(param.getSchema()))); + } + + if (pathItem.readOperations() != null) { + pathItem + .readOperations() + .forEach( + operation -> { + + // Check operation parameters (Query, Path, Header, etc.) + if (operation.getParameters() != null) { + operation + .getParameters() + .forEach( + param -> + roots.addAll( + extractSchemaNamesFromSchema(param.getSchema()))); + } + + // Check Request Bodies + if (operation.getRequestBody() != null + && operation.getRequestBody().getContent() != null) { + operation + .getRequestBody() + .getContent() + .values() + .forEach( + mediaType -> + roots.addAll( + extractSchemaNamesFromSchema(mediaType.getSchema()))); + } + + // Check Responses + if (operation.getResponses() != null) { + operation + .getResponses() + .values() + .forEach( + response -> { + if (response.getContent() != null) { + response + .getContent() + .values() + .forEach( + mediaType -> + roots.addAll( + extractSchemaNamesFromSchema( + mediaType.getSchema()))); + } + }); + } + }); + } + }); + } + + // 2. Scan Components for shared non-schema objects that reference schemas + if (openAPI.getComponents() != null) { + + // Shared Parameters + if (openAPI.getComponents().getParameters() != null) { + openAPI + .getComponents() + .getParameters() + .values() + .forEach(param -> roots.addAll(extractSchemaNamesFromSchema(param.getSchema()))); + } + + // Shared Responses + if (openAPI.getComponents().getResponses() != null) { + openAPI + .getComponents() + .getResponses() + .values() + .forEach( + response -> { + if (response.getContent() != null) { + response + .getContent() + .values() + .forEach( + mediaType -> + roots.addAll(extractSchemaNamesFromSchema(mediaType.getSchema()))); + } + }); + } + + // Shared RequestBodies + if (openAPI.getComponents().getRequestBodies() != null) { + openAPI + .getComponents() + .getRequestBodies() + .values() + .forEach( + requestBody -> { + if (requestBody.getContent() != null) { + requestBody + .getContent() + .values() + .forEach( + mediaType -> + roots.addAll(extractSchemaNamesFromSchema(mediaType.getSchema()))); + } + }); + } + } + + return roots; + } + + private static Set extractSchemaNamesFromSchema(Schema schema) { + Set refs = new HashSet<>(); + + // 1. Check direct ref + if (schema.get$ref() != null) { + refs.add(extractName(schema.get$ref())); + } + + // 2. Check properties + if (schema.getProperties() != null) { + schema + .getProperties() + .values() + .forEach(prop -> refs.addAll(extractSchemaNamesFromSchema(prop))); + } + + // 3. Check arrays (items) + if (schema.getItems() != null) { + refs.addAll(extractSchemaNamesFromSchema(schema.getItems())); + } + + // 4. Check compositions + if (schema.getAllOf() != null) + schema.getAllOf().forEach(s -> refs.addAll(extractSchemaNamesFromSchema(s))); + if (schema.getAnyOf() != null) + schema.getAnyOf().forEach(s -> refs.addAll(extractSchemaNamesFromSchema(s))); + if (schema.getOneOf() != null) + schema.getOneOf().forEach(s -> refs.addAll(extractSchemaNamesFromSchema(s))); + + // 5. Check additionalProperties (Maps) + if (schema.getAdditionalProperties() instanceof Schema) { + refs.addAll(extractSchemaNamesFromSchema((Schema) schema.getAdditionalProperties())); + } + + return refs; + } + + private static String extractName(String ref) { + return ref != null ? ref.substring(ref.lastIndexOf('/') + 1) : null; + } +} 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 c599382a7d..e4963682fb 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 @@ -163,11 +163,8 @@ public Map getGlobalVariables() { .toList(); openapiRoot.put("tags", tags); // Schemas - var components = context.openapi.getComponents(); - if (components != null && components.getSchemas() != null) { - var schemas = components.getSchemas(); - openapiRoot.put("schemas", new ArrayList<>(schemas.values())); - } + var schemas = context.openapi.getRequiredSchemas(); + openapiRoot.put("schemas", new ArrayList<>(schemas.values())); // make in to work without literal openapiRoot.put("query", "query"); @@ -466,9 +463,6 @@ public Schema resolveSchema(String path) { 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_REF)) { name = name.substring(COMPONENTS_SCHEMAS_REF.length()); } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/projection/AsmProjectionParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/projection/AsmProjectionParser.java new file mode 100644 index 0000000000..59b8ac3788 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/projection/AsmProjectionParser.java @@ -0,0 +1,308 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.projection; + +import java.util.Optional; + +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.LdcInsnNode; +import org.objectweb.asm.tree.LocalVariableNode; +import org.objectweb.asm.tree.MethodInsnNode; +import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.TypeInsnNode; +import org.objectweb.asm.tree.VarInsnNode; + +import io.jooby.internal.openapi.InsnSupport; +import io.jooby.internal.openapi.ParserContext; + +/** + * Parses bytecode to find Projected.wrap(...).include("...") definitions. Extracts the target class + * (with generics when available) and the view string. + */ +public class AsmProjectionParser { + + public static class ProjectionDef { + public final String targetClass; + public final String viewString; + + public ProjectionDef(String targetClass, String viewString) { + this.targetClass = targetClass; + this.viewString = viewString; + } + } + + /** + * Scans a method's instructions to find a Projection definition. + * + * @param methodNode The MethodNode containing the local variable table (LVTT). + * @return The parsed ProjectionDef, or empty if not found. + */ + public static Optional parse(ParserContext ctx, MethodNode methodNode) { + if (methodNode == null + || methodNode.instructions == null + || methodNode.instructions.size() == 0) { + return Optional.empty(); + } + + // Start from the first instruction in the method + AbstractInsnNode methodStart = methodNode.instructions.getFirst(); + + // 1. Find `Projected.include(String)` + MethodInsnNode includeNode = + (MethodInsnNode) + InsnSupport.next(methodStart) + .filter(n -> n.getOpcode() == Opcodes.INVOKEVIRTUAL) + .filter(n -> n instanceof MethodInsnNode) + .filter( + n -> { + MethodInsnNode mn = (MethodInsnNode) n; + return "io/jooby/Projected".equals(mn.owner) && "include".equals(mn.name); + }) + .findFirst() + .orElse(null); + + if (includeNode == null) { + return Optional.empty(); + } + + // 2. Extract View String (e.g., "(id, name)") + String viewString = + InsnSupport.prev(includeNode) + .filter(n -> n.getOpcode() == Opcodes.LDC && ((LdcInsnNode) n).cst instanceof String) + .map(n -> (String) ((LdcInsnNode) n).cst) + .findFirst() + .orElse(null); + + if (viewString == null) { + return Optional.empty(); + } + + // 3. Find `wrap(...)` and trace back to its payload + MethodInsnNode wrapNode = findWrapNode(includeNode); + String targetClass = null; + + if (wrapNode != null) { + AbstractInsnNode payloadNode = wrapNode.getPrevious(); + + // If it's a variable: `Projected.wrap(myList)` + if (payloadNode.getOpcode() == Opcodes.ALOAD) { + int varIndex = ((VarInsnNode) payloadNode).var; + + // Try to get the generic signature from the Local Variable Type Table (LVTT) + targetClass = extractFromLocalVariables(methodNode, varIndex); + + if (targetClass == null) { + // Fallback: trace to ASTORE if LVTT is missing or doesn't have generics + payloadNode = findAstore(wrapNode, varIndex); + } + } + + // If it wasn't a variable, or LVTT failed, fall back to instruction heuristics + if (targetClass == null && payloadNode != null) { + targetClass = extractTypeWithGenerics(ctx, payloadNode); + } + } + + return Optional.of(new ProjectionDef(targetClass, viewString)); + } + + // --- Data-Flow Helpers --- + + private static MethodInsnNode findWrapNode(AbstractInsnNode includeNode) { + AbstractInsnNode prev = includeNode.getPrevious(); + while (prev != null) { + if (isWrapCall(prev)) { + return (MethodInsnNode) prev; + } + if (prev.getOpcode() == Opcodes.ALOAD) { + int varIndex = ((VarInsnNode) prev).var; + AbstractInsnNode assignment = findAstore(prev, varIndex); + if (assignment != null && isWrapCall(assignment.getPrevious())) { + return (MethodInsnNode) assignment.getPrevious(); + } + } + prev = prev.getPrevious(); + } + return null; + } + + private static boolean isWrapCall(AbstractInsnNode n) { + if (n != null && n.getOpcode() == Opcodes.INVOKESTATIC && n instanceof MethodInsnNode) { + MethodInsnNode mn = (MethodInsnNode) n; + return "io/jooby/Projected".equals(mn.owner) && "wrap".equals(mn.name); + } + return false; + } + + private static AbstractInsnNode findAstore(AbstractInsnNode start, int varIndex) { + AbstractInsnNode prev = start.getPrevious(); + while (prev != null) { + if (prev.getOpcode() == Opcodes.ASTORE && ((VarInsnNode) prev).var == varIndex) { + return prev.getPrevious(); + } + prev = prev.getPrevious(); + } + return null; + } + + // --- Local Variable Type Table (LVTT) Resolution --- + + private static String extractFromLocalVariables(MethodNode methodNode, int varIndex) { + if (methodNode.localVariables == null) { + return null; + } + + for (LocalVariableNode lvn : methodNode.localVariables) { + if (lvn.index == varIndex) { + // Prefer the full generic signature if available + if (lvn.signature != null) { + return parseAsmSignature(lvn.signature); + } + // Fallback to the erased descriptor (e.g., Ljava/util/List;) + if (lvn.desc != null) { + return Type.getType(lvn.desc).getClassName(); + } + } + } + return null; + } + + /** + * Converts an ASM generic signature to a clean Java class name. Input: + * Ljava/util/List; Output: java.util.List + */ + private static String parseAsmSignature(String signature) { + if (signature == null) return null; + + // 1. Detect and erase unresolved Type Variables (like TE; or TId;) + // In JVM signatures, type variables always start with 'T', end with ';', + // and are immediately preceded by '<', ';', '[', or ')' + signature = signature.replaceAll("(?<=[<;\\[)])T[^;]+;", "Ljava/lang/Object;"); + + // 2. Convert slashes to dots + String cleaned = signature.replace('/', '.'); + + // 3. Strip the outermost 'L' and ';' + if (cleaned.startsWith("L")) { + cleaned = cleaned.substring(1); + } + if (cleaned.endsWith(";")) { + cleaned = cleaned.substring(0, cleaned.length() - 1); + } + + // 4. Handle multiple generic arguments (separated by ;L) + cleaned = cleaned.replace(";L", ", "); + + // 5. Strip the 'L' that appears right after the opening bracket + cleaned = cleaned.replace(" signature. + if (isContainerClass(className) && mn.getOpcode() == Opcodes.INVOKESTATIC) { + String genericParam = inferGenericArgument(mn); + if (genericParam != null) { + return className + "<" + genericParam + ">"; + } + } + + // --- 2. SECOND PRIORITY: Deep Method Signature Resolution --- + // If it's an instance method like service.list(), try to look up its generic + // return type using ParserContext. + if (ctx != null) { + try { + Type ownerType = Type.getObjectType(mn.owner); + MethodNode targetMethod = ctx.findMethodNode(ownerType, mn.name, mn.desc); + + if (targetMethod != null && targetMethod.signature != null) { + // Find the return type portion of the signature + int returnTypeStart = targetMethod.signature.lastIndexOf(')'); + if (returnTypeStart != -1) { + String returnSignature = targetMethod.signature.substring(returnTypeStart + 1); + String parsed = parseAsmSignature(returnSignature); + + // Only return this if it actually gave us something better than Object. + // If it just gave us List because of a erasure, we might + // as well fall through to the base class name anyway. + if (parsed != null && !parsed.contains("")) { + return parsed; + } + } + } + } catch (Exception e) { + // Ignore + } + } + + // --- 3. FALLBACK: Return the erased class name --- + if (!"java.lang.Object".equals(className)) { + return className; + } + } + return null; + } + + private static boolean isContainerClass(String className) { + return "java.util.List".equals(className) + || "java.util.Set".equals(className) + || "java.util.Collection".equals(className) + || "java.util.Optional".equals(className); + } + + /** + * Scans backwards to find the type of the argument passed to a container method. Useful for + * inline calls like: List.of(U3853.createUser()) + */ + private static String inferGenericArgument(MethodInsnNode methodCall) { + Type[] argTypes = Type.getArgumentTypes(methodCall.desc); + + if (argTypes.length == 0) { + return null; + } + + AbstractInsnNode prev = methodCall.getPrevious(); + + while (prev != null) { + if (prev.getOpcode() == Opcodes.NEW) { + return Type.getObjectType(((TypeInsnNode) prev).desc).getClassName(); + } + if (prev instanceof MethodInsnNode) { + MethodInsnNode argMethod = (MethodInsnNode) prev; + String returnClass = Type.getReturnType(argMethod.desc).getClassName(); + if (!"java.lang.Object".equals(returnClass)) { + return returnClass; + } + } + prev = prev.getPrevious(); + } + + return null; + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/projection/ProjectionParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/projection/ProjectionParser.java new file mode 100644 index 0000000000..529d10a483 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/projection/ProjectionParser.java @@ -0,0 +1,117 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.projection; + +import static io.jooby.internal.openapi.AsmUtils.*; + +import java.util.Map; + +import com.fasterxml.jackson.databind.JavaType; +import io.jooby.Projection; +import io.jooby.annotation.Project; +import io.jooby.internal.openapi.OpenAPIExt; +import io.jooby.internal.openapi.OperationExt; +import io.jooby.internal.openapi.ParserContext; +import io.jooby.value.ValueFactory; +import io.swagger.v3.oas.models.media.ArraySchema; +import io.swagger.v3.oas.models.media.MediaType; + +public class ProjectionParser { + private OpenAPIExt openapi; + private ParserContext ctx; + + private ProjectionParser(ParserContext ctx, OpenAPIExt openapi) { + this.ctx = ctx; + this.openapi = openapi; + } + + public static void parse(ParserContext ctx, OpenAPIExt openapi) { + var parser = new ProjectionParser(ctx, openapi); + for (OperationExt operation : openapi.getOperations()) { + parser.parseOperation(operation); + } + } + + private void parseOperation(OperationExt operation) { + var annotations = operation.getAllAnnotations(); + + if (operation.isScript()) { + AsmProjectionParser.parse(ctx, operation.getNode()) + .ifPresent( + projectionDef -> { + projection(operation, projectionDef.targetClass, projectionDef.viewString); + }); + } else { + var projection = operation.getProjection(); + if (projection != null) { + projection(operation, projection); + } else { + findAnnotationByType(annotations, Project.class).stream() + .map(it -> stringValue(toMap(it), "value")) + .forEach(projectionView -> projection(operation, projectionView)); + } + } + } + + private void projection(OperationExt operation, String viewString) { + projection(operation, operation.getDefaultResponse().getJavaType(), viewString); + } + + private void projection(OperationExt operation, String responseType, String viewString) { + projection(operation, ctx.javaType(responseType), viewString); + } + + private void projection(OperationExt operation, JavaType responseType, String viewString) { + var response = operation.getDefaultResponse(); + var contentType = responseType; + if (responseType.isArrayType() || responseType.isCollectionLikeType()) { + contentType = responseType.getContentType(); + } + var valueFactory = new ValueFactory(); + var isSimple = valueFactory.get(contentType) != null; + if (isSimple) { + return; + } + if (operation.isScript()) { + prepareScript(operation, responseType); + } + var projection = Projection.of(contentType.getRawClass()).include(viewString); + var content = response.getContent(); + for (var mediaTypes : content.entrySet()) { + var prune = + SchemaPruner.prune( + mediaTypes.getValue().getSchema(), projection, openapi.getRequiredComponents()); + mediaTypes.getValue().setSchema(prune); + } + } + + private void prepareScript(OperationExt operation, JavaType responseType) { + var response = operation.getDefaultResponse(); + var contentType = + (responseType.isArrayType() || responseType.isCollectionLikeType()) + ? responseType.getContentType() + : responseType; + var schemas = openapi.getRequiredSchemas(); + var schema = + schemas.computeIfAbsent( + contentType.getRawClass().getSimpleName(), + schemaName -> { + // 1.Initialize + ctx.schema(contentType.getRawClass()); + //noinspection OptionalGetWithoutIsPresent + var schemaRef = ctx.schemaRef(contentType.getRawClass().getName()); + return schemaRef.get().schema; + }); + // Save schemas after projection in case a new one was created + ctx.schemas().forEach(it -> openapi.schema(it.getName(), it)); + if (responseType.isArrayType() || responseType.isCollectionLikeType()) { + schema = new ArraySchema().items(schema); + } + for (Map.Entry e : response.getContent().entrySet()) { + e.getValue().setSchema(schema); + } + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/projection/SchemaPruner.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/projection/SchemaPruner.java new file mode 100644 index 0000000000..f54de228ff --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/projection/SchemaPruner.java @@ -0,0 +1,143 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.projection; + +import java.util.List; +import java.util.Map; + +import io.jooby.Projection; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.media.ArraySchema; +import io.swagger.v3.oas.models.media.ObjectSchema; +import io.swagger.v3.oas.models.media.Schema; + +/** + * Utility to create a pruned OpenAPI Schema based on a Jooby Projection. + * + * @since 4.0.0 + */ +public class SchemaPruner { + + public static Schema prune( + Schema original, Projection projection, Components components) { + if (original == null || projection == null) { + return original; + } + + // 1. Deep wildcard (e.g., address(*)). Return the original schema/ref untouched. + if (projection.getChildren().isEmpty()) { + return original; + } + + // 2. Handle Arrays: Recursively prune the items. + if (original instanceof ArraySchema) { + ArraySchema arraySchema = (ArraySchema) original; + Schema prunedItems = prune(arraySchema.getItems(), projection, components); + + ArraySchema newArraySchema = new ArraySchema(); + copyMetadata(original, newArraySchema); + newArraySchema.setItems(prunedItems); + return newArraySchema; + } + + // --- AGGRESSIVE ROOT RESOLUTION --- + // We MUST resolve the actual object schema if 'original' is just a $ref pointer, + // otherwise we can't see the properties we need to prune. + Schema actualSchema = resolveSchema(original, components); + if (actualSchema == null || actualSchema.getProperties() == null) { + return original; + } + + // --- THE CACHE CHECK (Early Exit) --- + // We use the base name of the $ref (if it had one) to name our new component. + String baseName = getBaseName(original); + String newComponentName = null; + + if (baseName != null) { + newComponentName = generateProjectedName(baseName, projection); + + if (components.getSchemas().containsKey(newComponentName)) { + return new Schema<>().$ref("#/components/schemas/" + newComponentName); + } + } + + // --- THE PRUNING --- + Schema prunedSchema = new ObjectSchema(); + copyMetadata(actualSchema, prunedSchema); + Map originalProps = actualSchema.getProperties(); + + for (Map.Entry> entry : projection.getChildren().entrySet()) { + String propName = entry.getKey(); + Projection childNode = entry.getValue(); + Schema originalPropSchema = originalProps.get(propName); + + if (originalPropSchema != null) { + Schema prunedProp = prune(originalPropSchema, childNode, components); + prunedSchema.addProperty(propName, prunedProp); + } + } + + // --- REGISTER IN COMPONENTS CACHE --- + if (newComponentName != null && components != null) { + components.addSchemas(newComponentName, prunedSchema); + return new Schema<>().$ref("#/components/schemas/" + newComponentName); + } + + return prunedSchema; + } + + /** + * Resolves a $ref to its actual Schema object in the Components map. If the schema is not a $ref, + * it returns the schema itself. + */ + private static Schema resolveSchema(Schema schema, Components components) { + if (schema.get$ref() != null) { + // Extract "User" from "#/components/schemas/User" + String refName = schema.get$ref().substring(schema.get$ref().lastIndexOf('/') + 1); + Schema resolved = components.getSchemas().get(refName); + return resolved != null ? resolved : schema; + } + return schema; + } + + private static String getBaseName(Schema schema) { + if (schema.get$ref() != null) { + return schema.get$ref().substring(schema.get$ref().lastIndexOf('/') + 1); + } + return schema.getName(); + } + + private static String generateProjectedName(String baseName, Projection projection) { + String shortHash = Integer.toString(Math.abs(projection.toView().hashCode()), 36); + return baseName + "_" + shortHash; + } + + private static void copyMetadata(Schema source, Schema target) { + target.setName(source.getName()); + target.setTitle(source.getTitle()); + target.setDescription(source.getDescription()); + target.setFormat(source.getFormat()); + target.setDefault(source.getDefault()); + if (source.getExample() != null) { + target.setExample(source.getExample()); + } + target.setEnum((List) source.getEnum()); + target.setRequired(source.getRequired()); + + target.setMaximum(source.getMaximum()); + target.setMinimum(source.getMinimum()); + target.setMaxLength(source.getMaxLength()); + target.setMinLength(source.getMinLength()); + target.setPattern(source.getPattern()); + target.setMaxItems(source.getMaxItems()); + target.setMinItems(source.getMinItems()); + target.setUniqueItems(source.getUniqueItems()); + + if (source.getExtensions() != null) { + source.getExtensions().forEach(target::addExtension); + } + } +} 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 8eaf5a02ec..8f9046e07f 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 @@ -23,11 +23,9 @@ import io.jooby.internal.openapi.*; import io.jooby.internal.openapi.asciidoc.AsciiDocContext; import io.jooby.internal.openapi.javadoc.JavaDocParser; +import io.jooby.internal.openapi.projection.ProjectionParser; import io.swagger.v3.core.util.*; -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.PathItem; -import io.swagger.v3.oas.models.Paths; -import io.swagger.v3.oas.models.SpecVersion; +import io.swagger.v3.oas.models.*; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.servers.Server; import io.swagger.v3.oas.models.tags.Tag; @@ -262,8 +260,6 @@ public OpenAPIGenerator() {} defaults(classname, contextPath, openapi); - ctx.schemas().forEach(schema -> openapi.schema(schema.getName(), schema)); - Map globalTags = new LinkedHashMap<>(); var paths = new Paths(); for (var operation : operations) { @@ -318,9 +314,22 @@ public OpenAPIGenerator() {} openapi.setJsonSchemaDialect(null); } + // Put schemas so far + ctx.schemas().forEach(schema -> openapi.schema(schema.getName(), schema)); + + ProjectionParser.parse(ctx, openapi); + + // Save schemas after projection in case a new one was created + ctx.schemas().forEach(schema -> openapi.schema(schema.getName(), schema)); + + finish(openapi); return openapi; } + private void finish(OpenAPIExt openapi) { + SchemaPurger.purgeUnused(openapi); + } + ObjectMapper yamlMapper() { return specVersion == SpecVersion.V30 ? Yaml.mapper() : Yaml31.mapper(); } diff --git a/modules/jooby-openapi/src/main/java/module-info.java b/modules/jooby-openapi/src/main/java/module-info.java index 9f190460b7..8322c5f6d5 100644 --- a/modules/jooby-openapi/src/main/java/module-info.java +++ b/modules/jooby-openapi/src/main/java/module-info.java @@ -20,7 +20,6 @@ requires io.pebbletemplates; requires jdk.jshell; requires com.google.common; - requires org.checkerframework.checker.qual; requires org.asciidoctor.asciidoctorj.api; requires jakarta.data; requires io.swagger.annotations; diff --git a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIYamlTest.java b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIYamlTest.java index 223566efda..83463b0c9f 100644 --- a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIYamlTest.java +++ b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIYamlTest.java @@ -161,21 +161,7 @@ public void shouldGenerateMinApp(OpenAPIResult result) { + " type: integer\n" + " format: int64\n" + " name:\n" - + " type: string\n" - + " PetQuery:\n" - + " type: object\n" - + " properties:\n" - + " id:\n" - + " type: integer\n" - + " format: int64\n" - + " name:\n" - + " type: string\n" - + " start:\n" - + " type: integer\n" - + " format: int32\n" - + " max:\n" - + " type: integer\n" - + " format: int32\n", + + " type: string\n", result.toYaml()); } @@ -326,21 +312,7 @@ public void shouldGenerateKtMinApp(OpenAPIResult result) { + " type: integer\n" + " format: int64\n" + " name:\n" - + " type: string\n" - + " PetQuery:\n" - + " type: object\n" - + " properties:\n" - + " id:\n" - + " type: integer\n" - + " format: int64\n" - + " name:\n" - + " type: string\n" - + " start:\n" - + " type: integer\n" - + " format: int32\n" - + " max:\n" - + " type: integer\n" - + " format: int32\n", + + " type: string\n", result.toYaml()); } 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 d8bc609cc7..f5bbfbb0db 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 @@ -566,21 +566,6 @@ private void checkResult(OpenAPIResult result) { 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: diff --git a/modules/jooby-openapi/src/test/java/issues/i3835/App3835Jakarta.java b/modules/jooby-openapi/src/test/java/issues/i3835/App3835Jakarta.java new file mode 100644 index 0000000000..300ae3631c --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3835/App3835Jakarta.java @@ -0,0 +1,16 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3835; + +import io.jooby.Jooby; + +import static io.jooby.openapi.MvcExtensionGenerator.toMvcExtension; + +public class App3835Jakarta extends Jooby { + { + mvc(toMvcExtension(C3835Jakarta.class)); + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3835/C3835Jakarta.java b/modules/jooby-openapi/src/test/java/issues/i3835/C3835Jakarta.java new file mode 100644 index 0000000000..1734cef8bc --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3835/C3835Jakarta.java @@ -0,0 +1,33 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3835; + +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; + +import java.util.List; +import java.util.Map; + +@Path("/3835") +public class C3835Jakarta { + + /** + * Search/scan index. + * + * @param q Search string. Defaults to * + * @return Search result. + */ + @GET + public Map search( + @QueryParam("q") @DefaultValue("*") String q, + @QueryParam("pageSize") @DefaultValue("20") int pageSize, + @QueryParam("page") @DefaultValue("1") int page, + @QueryParam("options") @DefaultValue("--a") List options) { + return null; + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3835/Issue3835.java b/modules/jooby-openapi/src/test/java/issues/i3835/Issue3835.java index a14c78f23e..ad0ff6aeb8 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3835/Issue3835.java +++ b/modules/jooby-openapi/src/test/java/issues/i3835/Issue3835.java @@ -63,4 +63,56 @@ public void shouldGenerateCorrectName(OpenAPIResult result) { type: object\ """); } + + @OpenAPITest(value = App3835Jakarta.class) + public void shouldGenerateJakartaDefaultValues(OpenAPIResult result) { + assertThat(result.toYaml()) + .isEqualToIgnoringNewLines( + """ + openapi: 3.0.1 + info: + title: 3835Jakarta API + description: 3835Jakarta API description + version: "1.0" + paths: + /3835: + get: + summary: Search/scan index. + operationId: search + parameters: + - name: q + in: query + description: Search string. Defaults to * + schema: + type: string + default: '*' + - name: pageSize + in: query + schema: + type: integer + format: int32 + default: 20 + - name: page + in: query + schema: + type: integer + format: int32 + default: 1 + - name: options + in: query + schema: + type: array + items: + type: string + responses: + "200": + description: Search result. + content: + application/json: + schema: + type: object + additionalProperties: + type: object\ + """); + } } diff --git a/modules/jooby-openapi/src/test/java/issues/i3853/A3853.java b/modules/jooby-openapi/src/test/java/issues/i3853/A3853.java new file mode 100644 index 0000000000..0d8bb754cb --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3853/A3853.java @@ -0,0 +1,24 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3853; + +public class A3853 { + private final String city; + private final L3853 loc; + + public A3853(String city, L3853 loc) { + this.city = city; + this.loc = loc; + } + + public String getCity() { + return city; + } + + public L3853 getLoc() { + return loc; + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3853/App3853.java b/modules/jooby-openapi/src/test/java/issues/i3853/App3853.java new file mode 100644 index 0000000000..7e0a285093 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3853/App3853.java @@ -0,0 +1,16 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3853; + +import static io.jooby.openapi.MvcExtensionGenerator.toMvcExtension; + +import io.jooby.Jooby; + +public class App3853 extends Jooby { + { + mvc(toMvcExtension(C3853.class)); + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3853/App3853Script.java b/modules/jooby-openapi/src/test/java/issues/i3853/App3853Script.java new file mode 100644 index 0000000000..e0a68f7300 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3853/App3853Script.java @@ -0,0 +1,79 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3853; + +import java.util.ArrayList; +import java.util.List; + +import io.jooby.Jooby; +import io.jooby.Projected; + +public class App3853Script extends Jooby { + public static class Service { + public List list() { + return null; + } + } + + { + get( + "/3853/{id}", + ctx -> { + var id = ctx.path("id").intValue(); + return Projected.wrap(U3853.createUser()).include("(id, name)"); + }); + + get( + "/3853/list-variable", + ctx -> { + List list = new ArrayList<>(); + list.add(U3853.createUser()); + return Projected.wrap(list).include("(id, name)"); + }); + + get( + "/3853/list-variable-var", + ctx -> { + var list = new ArrayList(); + list.add(U3853.createUser()); + return Projected.wrap(list).include("(id, name)"); + }); + + get( + "/3853/list-call", + ctx -> { + var service = ctx.require(Service.class); + return Projected.wrap(service.list()).include("(id, name)"); + }); + + get( + "/3853/list-call-variable", + ctx -> { + var service = ctx.require(Service.class); + var result = service.list(); + + return Projected.wrap(result).include("(id, name)"); + }); + + get( + "/3853/list", + ctx -> { + return Projected.wrap(List.of(U3853.createUser())).include("(id, name)"); + }); + + get( + "/3853/address", + ctx -> { + return Projected.wrap(U3853.createUser()).include("(id, address(*))"); + }); + + get( + "/3853/address/partial", + ctx -> { + return Projected.wrap(U3853.createUser()).include("(id, address(city))"); + }); + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3853/C3853.java b/modules/jooby-openapi/src/test/java/issues/i3853/C3853.java new file mode 100644 index 0000000000..d656b0f53e --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3853/C3853.java @@ -0,0 +1,44 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3853; + +import java.util.List; +import java.util.Optional; + +import io.jooby.annotation.*; + +@Path("/3853") +public class C3853 { + + @GET("/{id}") + @Project("(id, name)") + public U3853 findUser(@PathParam String id) { + return null; + } + + @GET(value = "/", projection = "(id, name)") + public List findUsers() { + return null; + } + + @GET("/optional") + @Project("(id)") + public Optional findUserIdOnly() { + return null; + } + + @GET("/full-address/{id}") + @Project("(id, address(*))") + public U3853 userIdWithFullAddress(@PathParam String id) { + return null; + } + + @GET("/partial-address/{id}") + @Project("(id, address(city))") + public U3853 userIdWithAddressCity(@PathParam String id) { + return null; + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3853/Issue3853.java b/modules/jooby-openapi/src/test/java/issues/i3853/Issue3853.java new file mode 100644 index 0000000000..b233d8d3e3 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3853/Issue3853.java @@ -0,0 +1,653 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3853; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +import io.jooby.openapi.OpenAPIResult; +import io.jooby.openapi.OpenAPITest; +import io.swagger.v3.oas.models.SpecVersion; + +public class Issue3853 { + + @OpenAPITest(value = App3853Script.class) + public void shouldDetectProjectAnnotationScript(OpenAPIResult result) { + assertThat(result.toYaml()) + .isEqualToIgnoringNewLines( + """ + openapi: 3.0.1 + info: + title: 3853Script API + description: 3853Script API description + version: "1.0" + paths: + /3853/{id}: + get: + operationId: get3853Id + parameters: + - name: id + in: path + required: true + schema: + type: integer + format: int32 + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/U3853_heab3f" + /3853/list-variable: + get: + operationId: get3853ListVariable + responses: + "200": + description: Success + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/U3853_heab3f" + /3853/list-variable-var: + get: + operationId: get3853ListVariableVar + responses: + "200": + description: Success + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/U3853_heab3f" + /3853/list-call: + get: + operationId: get3853ListCall + responses: + "200": + description: Success + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/U3853_heab3f" + /3853/list-call-variable: + get: + operationId: get3853ListCallVariable + responses: + "200": + description: Success + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/U3853_heab3f" + /3853/list: + get: + operationId: get3853List + responses: + "200": + description: Success + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/U3853_heab3f" + /3853/address: + get: + operationId: get3853Address + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/U3853_ff52rt" + /3853/address/partial: + get: + operationId: get3853AddressPartial + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/U3853_xsv0o" + components: + schemas: + L3853: + type: object + properties: + lat: + type: number + format: double + lon: + type: number + format: double + A3853: + type: object + properties: + city: + type: string + loc: + $ref: "#/components/schemas/L3853" + U3853_heab3f: + type: object + properties: + id: + type: string + description: Retrieves the unique identifier for the user. + name: + type: string + description: Retrieves the name of the user. + description: "Represents a user entity identified by an ID and name, with associated\\ + \\ address details, roles, and metadata. This class is immutable, ensuring\\ + \\ the integrity of its fields." + U3853_ff52rt: + type: object + properties: + id: + type: string + description: Retrieves the unique identifier for the user. + address: + $ref: "#/components/schemas/A3853" + description: "Represents a user entity identified by an ID and name, with associated\\ + \\ address details, roles, and metadata. This class is immutable, ensuring\\ + \\ the integrity of its fields." + A3853_1tgff: + type: object + properties: + city: + type: string + U3853_xsv0o: + type: object + properties: + id: + type: string + description: Retrieves the unique identifier for the user. + address: + $ref: "#/components/schemas/A3853_1tgff" + description: "Represents a user entity identified by an ID and name, with associated\\ + \\ address details, roles, and metadata. This class is immutable, ensuring\\ + \\ the integrity of its fields." + """); + } + + @OpenAPITest(value = App3853Script.class, version = SpecVersion.V31) + public void shouldDetectProjectAnnotationScriptV31(OpenAPIResult result) { + assertThat(result.toYaml()) + .isEqualToIgnoringNewLines( + """ + openapi: 3.1.0 + info: + title: 3853Script API + description: 3853Script API description + version: "1.0" + paths: + /3853/{id}: + get: + operationId: get3853Id + parameters: + - name: id + in: path + required: true + schema: + type: integer + format: int32 + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/U3853_heab3f" + /3853/list-variable: + get: + operationId: get3853ListVariable + responses: + "200": + description: Success + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/U3853_heab3f" + /3853/list-variable-var: + get: + operationId: get3853ListVariableVar + responses: + "200": + description: Success + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/U3853_heab3f" + /3853/list-call: + get: + operationId: get3853ListCall + responses: + "200": + description: Success + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/U3853_heab3f" + /3853/list-call-variable: + get: + operationId: get3853ListCallVariable + responses: + "200": + description: Success + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/U3853_heab3f" + /3853/list: + get: + operationId: get3853List + responses: + "200": + description: Success + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/U3853_heab3f" + /3853/address: + get: + operationId: get3853Address + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/U3853_ff52rt" + /3853/address/partial: + get: + operationId: get3853AddressPartial + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/U3853_xsv0o" + components: + schemas: + L3853: + type: object + properties: + lat: + type: number + format: double + lon: + type: number + format: double + A3853: + type: object + properties: + city: + type: string + loc: + $ref: "#/components/schemas/L3853" + U3853_heab3f: + type: object + description: "Represents a user entity identified by an ID and name, with associated\\ + \\ address details, roles, and metadata. This class is immutable, ensuring\\ + \\ the integrity of its fields." + properties: + id: + type: string + description: Retrieves the unique identifier for the user. + name: + type: string + description: Retrieves the name of the user. + U3853_ff52rt: + type: object + description: "Represents a user entity identified by an ID and name, with associated\\ + \\ address details, roles, and metadata. This class is immutable, ensuring\\ + \\ the integrity of its fields." + properties: + id: + type: string + description: Retrieves the unique identifier for the user. + address: + $ref: "#/components/schemas/A3853" + description: Retrieves the address associated with the user. + A3853_1tgff: + type: object + properties: + city: + type: string + U3853_xsv0o: + type: object + description: "Represents a user entity identified by an ID and name, with associated\\ + \\ address details, roles, and metadata. This class is immutable, ensuring\\ + \\ the integrity of its fields." + properties: + id: + type: string + description: Retrieves the unique identifier for the user. + address: + $ref: "#/components/schemas/A3853_1tgff" + """); + } + + @OpenAPITest(value = App3853.class) + public void shouldDetectProjectAnnotation(OpenAPIResult result) { + assertThat(result.toYaml()) + .isEqualToIgnoringNewLines( + """ + openapi: 3.0.1 + info: + title: 3853 API + description: 3853 API description + version: "1.0" + paths: + /3853/{id}: + get: + operationId: findUser + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/U3853_heab3f" + /3853: + get: + operationId: findUsers + responses: + "200": + description: Success + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/U3853_heab3f" + /3853/optional: + get: + operationId: findUserIdOnly + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/U3853_2l7" + /3853/full-address/{id}: + get: + operationId: userIdWithFullAddress + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/U3853_ff52rt" + /3853/partial-address/{id}: + get: + operationId: userIdWithAddressCity + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/U3853_xsv0o" + components: + schemas: + L3853: + type: object + properties: + lat: + type: number + format: double + lon: + type: number + format: double + A3853: + type: object + properties: + city: + type: string + loc: + $ref: "#/components/schemas/L3853" + U3853_heab3f: + type: object + properties: + id: + type: string + description: Retrieves the unique identifier for the user. + name: + type: string + description: Retrieves the name of the user. + description: "Represents a user entity identified by an ID and name, with associated\\ + \\ address details, roles, and metadata. This class is immutable, ensuring\\ + \\ the integrity of its fields." + U3853_2l7: + type: object + properties: + id: + type: string + description: Retrieves the unique identifier for the user. + description: "Represents a user entity identified by an ID and name, with associated\\ + \\ address details, roles, and metadata. This class is immutable, ensuring\\ + \\ the integrity of its fields." + U3853_ff52rt: + type: object + properties: + id: + type: string + description: Retrieves the unique identifier for the user. + address: + $ref: "#/components/schemas/A3853" + description: "Represents a user entity identified by an ID and name, with associated\\ + \\ address details, roles, and metadata. This class is immutable, ensuring\\ + \\ the integrity of its fields." + A3853_1tgff: + type: object + properties: + city: + type: string + U3853_xsv0o: + type: object + properties: + id: + type: string + description: Retrieves the unique identifier for the user. + address: + $ref: "#/components/schemas/A3853_1tgff" + description: "Represents a user entity identified by an ID and name, with associated\\ + \\ address details, roles, and metadata. This class is immutable, ensuring\\ + \\ the integrity of its fields." + """); + } + + @OpenAPITest(value = App3853.class, version = SpecVersion.V31) + public void shouldDetectProjectAnnotationV31(OpenAPIResult result) { + assertThat(result.toYaml()) + .isEqualToIgnoringNewLines( + """ + openapi: 3.1.0 + info: + title: 3853 API + description: 3853 API description + version: "1.0" + paths: + /3853/{id}: + get: + operationId: findUser + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/U3853_heab3f" + /3853: + get: + operationId: findUsers + responses: + "200": + description: Success + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/U3853_heab3f" + /3853/optional: + get: + operationId: findUserIdOnly + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/U3853_2l7" + /3853/full-address/{id}: + get: + operationId: userIdWithFullAddress + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/U3853_ff52rt" + /3853/partial-address/{id}: + get: + operationId: userIdWithAddressCity + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/U3853_xsv0o" + components: + schemas: + L3853: + type: object + properties: + lat: + type: number + format: double + lon: + type: number + format: double + A3853: + type: object + properties: + city: + type: string + loc: + $ref: "#/components/schemas/L3853" + U3853_heab3f: + type: object + description: "Represents a user entity identified by an ID and name, with associated\\ + \\ address details, roles, and metadata. This class is immutable, ensuring\\ + \\ the integrity of its fields." + properties: + id: + type: string + description: Retrieves the unique identifier for the user. + name: + type: string + description: Retrieves the name of the user. + U3853_2l7: + type: object + description: "Represents a user entity identified by an ID and name, with associated\\ + \\ address details, roles, and metadata. This class is immutable, ensuring\\ + \\ the integrity of its fields." + properties: + id: + type: string + description: Retrieves the unique identifier for the user. + U3853_ff52rt: + type: object + description: "Represents a user entity identified by an ID and name, with associated\\ + \\ address details, roles, and metadata. This class is immutable, ensuring\\ + \\ the integrity of its fields." + properties: + id: + type: string + description: Retrieves the unique identifier for the user. + address: + $ref: "#/components/schemas/A3853" + description: Retrieves the address associated with the user. + A3853_1tgff: + type: object + properties: + city: + type: string + U3853_xsv0o: + type: object + description: "Represents a user entity identified by an ID and name, with associated\\ + \\ address details, roles, and metadata. This class is immutable, ensuring\\ + \\ the integrity of its fields." + properties: + id: + type: string + description: Retrieves the unique identifier for the user. + address: + $ref: "#/components/schemas/A3853_1tgff" + """); + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3853/L3853.java b/modules/jooby-openapi/src/test/java/issues/i3853/L3853.java new file mode 100644 index 0000000000..aab676e966 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3853/L3853.java @@ -0,0 +1,8 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3853; + +public record L3853(double lat, double lon) {} diff --git a/modules/jooby-openapi/src/test/java/issues/i3853/R3853.java b/modules/jooby-openapi/src/test/java/issues/i3853/R3853.java new file mode 100644 index 0000000000..d5f1407b1f --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3853/R3853.java @@ -0,0 +1,8 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3853; + +public record R3853(String name, int level) {} diff --git a/modules/jooby-openapi/src/test/java/issues/i3853/U3853.java b/modules/jooby-openapi/src/test/java/issues/i3853/U3853.java new file mode 100644 index 0000000000..846f136ac1 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3853/U3853.java @@ -0,0 +1,100 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3853; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Represents a user entity identified by an ID and name, with associated address details, roles, + * and metadata. This class is immutable, ensuring the integrity of its fields. + */ +public class U3853 { + private final String id; + private final String name; + private final A3853 address; + private final List roles; + private final Map meta; + + public U3853(String id, String name, A3853 address, List roles, Map meta) { + this.id = id; + this.name = name; + this.address = address; + this.roles = roles; + this.meta = meta; + } + + /** + * Retrieves the unique identifier for the user. + * + * @return the user ID as a string. + */ + public String getId() { + return id; + } + + /** + * Retrieves the name of the user. + * + * @return the name as a string. + */ + public String getName() { + return name; + } + + /** + * Retrieves the address associated with the user. + * + * @return the user's address as an instance of A3853. + */ + public A3853 getAddress() { + return address; + } + + /** + * Retrieves the list of roles associated with the user. + * + * @return a list of R3853 instances representing the roles of the user. + */ + public List getRoles() { + return roles; + } + + /** + * Retrieves the metadata associated with the user. + * + * @return a map containing metadata as key-value pairs, where both keys and values are strings. + */ + public Map getMeta() { + return meta; + } + + public static U3853 createUser() { + // Nested Location: The Fortress in the Snow (Level 3) + L3853 fortress = new L3853(80.0, -20.0); + + // Address: Represents the "Dream Layer" + A3853 dreamLayer = new A3853("Snow Fortress (Level 3)", fortress); + + // Roles: The Extraction Team + List roles = + List.of( + new R3853("The Extractor", 10), + new R3853("The Architect", 9), + new R3853("The Point Man", 8), + new R3853("The Forger", 8)); + + // Metadata: Mission specs + Map meta = new LinkedHashMap<>(); + meta.put("target", "Robert Fischer"); + meta.put("objective", "Inception"); + meta.put("status", "Synchronizing Kicks"); + + // Root User: Dom Cobb + return new U3853("cobb-001", "Dom Cobb", dreamLayer, roles, meta); + } +} diff --git a/modules/jooby-pac4j/pom.xml b/modules/jooby-pac4j/pom.xml index 949d2acd4f..41b609c9b8 100644 --- a/modules/jooby-pac4j/pom.xml +++ b/modules/jooby-pac4j/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-pac4j jooby-pac4j diff --git a/modules/jooby-pebble/pom.xml b/modules/jooby-pebble/pom.xml index 834f47792e..b6614d5b40 100644 --- a/modules/jooby-pebble/pom.xml +++ b/modules/jooby-pebble/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-pebble jooby-pebble diff --git a/modules/jooby-quartz/pom.xml b/modules/jooby-quartz/pom.xml index 22b521f0cc..07ab7c2b5d 100644 --- a/modules/jooby-quartz/pom.xml +++ b/modules/jooby-quartz/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-quartz jooby-quartz diff --git a/modules/jooby-reactor/pom.xml b/modules/jooby-reactor/pom.xml index f80e683e5d..0f07fbbd89 100644 --- a/modules/jooby-reactor/pom.xml +++ b/modules/jooby-reactor/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-reactor jooby-reactor @@ -26,7 +26,7 @@ io.projectreactor reactor-core - 3.8.1 + 3.8.3 diff --git a/modules/jooby-redis/pom.xml b/modules/jooby-redis/pom.xml index a31ffeceda..4cab040da8 100644 --- a/modules/jooby-redis/pom.xml +++ b/modules/jooby-redis/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-redis jooby-redis diff --git a/modules/jooby-redoc/pom.xml b/modules/jooby-redoc/pom.xml index 8592be6a29..5e41fc411b 100644 --- a/modules/jooby-redoc/pom.xml +++ b/modules/jooby-redoc/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-redoc jooby-redoc diff --git a/modules/jooby-rocker/pom.xml b/modules/jooby-rocker/pom.xml index 0250eadecf..f3b894163c 100644 --- a/modules/jooby-rocker/pom.xml +++ b/modules/jooby-rocker/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-rocker jooby-rocker diff --git a/modules/jooby-run/pom.xml b/modules/jooby-run/pom.xml index 49e3355f83..1de34adc7f 100644 --- a/modules/jooby-run/pom.xml +++ b/modules/jooby-run/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-run jooby-run diff --git a/modules/jooby-rxjava3/pom.xml b/modules/jooby-rxjava3/pom.xml index 582e90d54b..e053dbcf5b 100644 --- a/modules/jooby-rxjava3/pom.xml +++ b/modules/jooby-rxjava3/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-rxjava3 jooby-rxjava3 diff --git a/modules/jooby-stork/pom.xml b/modules/jooby-stork/pom.xml index adc6e01cfa..04e7fb33e8 100644 --- a/modules/jooby-stork/pom.xml +++ b/modules/jooby-stork/pom.xml @@ -4,7 +4,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-stork @@ -20,7 +20,7 @@ io.repaint.maven tiles-maven-plugin - 2.42 + 2.43 true true diff --git a/modules/jooby-swagger-ui/package-lock.json b/modules/jooby-swagger-ui/package-lock.json index 4416d58c38..00948a416e 100644 --- a/modules/jooby-swagger-ui/package-lock.json +++ b/modules/jooby-swagger-ui/package-lock.json @@ -9,7 +9,7 @@ "version": "4.0.0", "license": "ASF", "dependencies": { - "swagger-ui-dist": "^5.31.0" + "swagger-ui-dist": "^5.31.2" } }, "node_modules/@scarf/scarf": { @@ -20,9 +20,9 @@ "license": "Apache-2.0" }, "node_modules/swagger-ui-dist": { - "version": "5.31.0", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.31.0.tgz", - "integrity": "sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==", + "version": "5.31.2", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.31.2.tgz", + "integrity": "sha512-uIoesCjDcxnAKj/C/HG5pjHZMQs2K/qmqpUlwLxxaVryGKlgm8Ri+VOza5xywAqf//pgg/hW16RYa6dDuTCOSg==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" diff --git a/modules/jooby-swagger-ui/package.json b/modules/jooby-swagger-ui/package.json index 0ddbf68e46..ce24a7a792 100644 --- a/modules/jooby-swagger-ui/package.json +++ b/modules/jooby-swagger-ui/package.json @@ -4,7 +4,7 @@ "private": true, "license": "ASF", "dependencies": { - "swagger-ui-dist": "^5.31.0" + "swagger-ui-dist": "^5.31.2" }, "scarfSettings": { "enabled": false diff --git a/modules/jooby-swagger-ui/pom.xml b/modules/jooby-swagger-ui/pom.xml index c269902298..93935c2ffe 100644 --- a/modules/jooby-swagger-ui/pom.xml +++ b/modules/jooby-swagger-ui/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-swagger-ui jooby-swagger-ui diff --git a/modules/jooby-test/pom.xml b/modules/jooby-test/pom.xml index d4603bd494..0552096161 100644 --- a/modules/jooby-test/pom.xml +++ b/modules/jooby-test/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-test jooby-test diff --git a/modules/jooby-thymeleaf/pom.xml b/modules/jooby-thymeleaf/pom.xml index 30b6a341cf..755986cba1 100644 --- a/modules/jooby-thymeleaf/pom.xml +++ b/modules/jooby-thymeleaf/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-thymeleaf jooby-thymeleaf diff --git a/modules/jooby-undertow/pom.xml b/modules/jooby-undertow/pom.xml index 6dd7648e29..99493b903f 100644 --- a/modules/jooby-undertow/pom.xml +++ b/modules/jooby-undertow/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-undertow jooby-undertow @@ -28,7 +28,7 @@ org.jboss.logging jboss-logging - 3.6.1.Final + 3.6.2.Final diff --git a/modules/jooby-vertx-mysql-client/pom.xml b/modules/jooby-vertx-mysql-client/pom.xml index 8ae32ff54a..2fd0564a9f 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.15 + 4.0.16 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 80a903abf1..685056b626 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.15 + 4.0.16 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 0ed5bf53ce..d7f0ac67d6 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.15 + 4.0.16 jooby-vertx-sql-client jooby-vertx-sql-client diff --git a/modules/jooby-vertx/pom.xml b/modules/jooby-vertx/pom.xml index 167eb62c56..e091423053 100644 --- a/modules/jooby-vertx/pom.xml +++ b/modules/jooby-vertx/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-vertx jooby-vertx diff --git a/modules/jooby-whoops/pom.xml b/modules/jooby-whoops/pom.xml index 45a39b784f..42614abab1 100644 --- a/modules/jooby-whoops/pom.xml +++ b/modules/jooby-whoops/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-whoops jooby-whoops diff --git a/modules/jooby-yasson/pom.xml b/modules/jooby-yasson/pom.xml index f60d011ed4..6f9a5f4681 100644 --- a/modules/jooby-yasson/pom.xml +++ b/modules/jooby-yasson/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.15 + 4.0.16 jooby-yasson jooby-yasson diff --git a/modules/pom.xml b/modules/pom.xml index 3d86995753..9510e9ab68 100644 --- a/modules/pom.xml +++ b/modules/pom.xml @@ -4,7 +4,7 @@ io.jooby jooby-project - 4.0.15 + 4.0.16 modules @@ -48,6 +48,7 @@ jooby-caffeine jooby-jackson + jooby-jackson3 jooby-gson jooby-avaje-jsonb jooby-yasson diff --git a/pom.xml b/pom.xml index d53ff154d1..6aa5a44b26 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.jooby jooby-project - 4.0.15 + 4.0.16 pom jooby-project @@ -60,35 +60,36 @@ 2.3.34 4.5.0 1.3.7 - 4.1.0 - 2.20.1 + 4.1.1 + 2.21.1 + 3.1.0 2.13.2 3.0.1 3.0.4 2.4.0 3.1.3.RELEASE - 3.2.1 + 3.2.3 7.0.2 1.2 7.0.4.Final - 17.2.0 + 17.3.0 3.51.0 11.20.1 25.0 - 7.2.1.RELEASE + 7.4.0.RELEASE 2.13.1 - 4.1.1 + 4.2.0 3.2.3 - 1.4.5 + 1.4.6 7.0.0 - 1.5.24 + 1.5.32 2.25.3 2.0.17 @@ -96,31 +97,31 @@ 1.6.0 - 4.2.37 + 4.2.38 - 2.2.0.Final + 2.3.0 9.9.1 - 2.3.21.Final - 12.1.5 - 4.2.9.Final - 5.0.6 + 2.3.23.Final + 12.1.6 + 4.2.10.Final + 5.0.8 - 2.2.41 - 2.1.37 + 2.2.43 + 2.1.38 2.0.0-rc.20 2.5.2 - 12.2 - 3.9 + 12.4 + 3.11 2.16 @@ -135,7 +136,7 @@ 6.3.1 2.5.2 9.2.1 - 8.16.0 + 8.16.1 1.12.797 4.17.0 1.9.3 @@ -143,16 +144,16 @@ 2.21.0 - 2.3.0 + 2.3.10 1.10.2 true 0.8.14 - 6.0.2 + 6.0.3 6.0.0 - 3.27.6 - 5.21.0 + 3.27.7 + 5.22.0 ${user.home}${file.separator}.m2${file.separator}repository org${file.separator}mockito${file.separator}mockito-core${file.separator}${mockito.version}${file.separator}mockito-core-${mockito.version}.jar -javaagent:${maven.m2.repo}${file.separator}${mockito.agent} @@ -171,8 +172,8 @@ 3.2.0 3.2.0 3.8.0 - 2.42 - 3.14.1 + 2.43 + 3.15.0 3.9.12 3.6.2 3.2.8 @@ -187,11 +188,11 @@ 3.6.1 3.8.2 3.4.0 - 3.5.4 + 3.5.5 2.3.1 4.0.2 3.2.0 - 2.20.1 + 2.21.0 3.6.3 3.1.2 2.0.0 @@ -210,7 +211,7 @@ 21 21 yyyy-MM-dd HH:mm:ssa - 2026-01-14T14:25:56Z + 2026-03-02T16:38:34Z UTF-8 etc${file.separator}source${file.separator}formatter.sh @@ -254,6 +255,13 @@ pom import + + tools.jackson + jackson-bom + ${jackson3.version} + pom + import + io.jooby @@ -310,6 +318,12 @@ ${jooby.version} + + io.jooby + jooby-jackson3 + ${jooby.version} + + io.jooby jooby-gson @@ -1519,7 +1533,7 @@ com.diffplug.spotless spotless-maven-plugin - 3.1.0 + 3.2.1 true @@ -1654,7 +1668,7 @@ org.codehaus.mojo versions-maven-plugin - 2.20.1 + 2.21.0 diff --git a/tests/pom.xml b/tests/pom.xml index 9cb452738a..9a69f4e060 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -6,7 +6,7 @@ io.jooby jooby-project - 4.0.15 + 4.0.16 tests tests @@ -36,6 +36,11 @@ jooby-jackson ${jooby.version} + + io.jooby + jooby-jackson3 + ${jooby.version} + io.jooby jooby-gson @@ -206,7 +211,7 @@ io.vertx vertx-pg-client - 5.0.6 + 5.0.8 @@ -238,13 +243,13 @@ org.asynchttpclient async-http-client - 3.0.6 + 3.0.7 commons-codec commons-codec - 1.20.0 + 1.21.0 @@ -312,6 +317,11 @@ avaje-validator-generator ${avaje.validator.version} + + io.avaje + avaje-jsonb-generator + ${avaje.jsonb.version} + org.openjdk.jmh jmh-generator-annprocess diff --git a/tests/src/test/java/io/jooby/i3853/A3853.java b/tests/src/test/java/io/jooby/i3853/A3853.java new file mode 100644 index 0000000000..9fc02a142e --- /dev/null +++ b/tests/src/test/java/io/jooby/i3853/A3853.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.i3853; + +import io.avaje.jsonb.Json; + +@Json +public class A3853 { + private final String city; + private final L3853 loc; + + public A3853(String city, L3853 loc) { + this.city = city; + this.loc = loc; + } + + public String getCity() { + return city; + } + + public L3853 getLoc() { + return loc; + } +} diff --git a/tests/src/test/java/io/jooby/i3853/C3853.java b/tests/src/test/java/io/jooby/i3853/C3853.java new file mode 100644 index 0000000000..234f5f4381 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3853/C3853.java @@ -0,0 +1,45 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3853; + +import java.util.List; +import java.util.Optional; + +import io.jooby.Projected; +import io.jooby.annotation.GET; +import io.jooby.annotation.Path; +import io.jooby.annotation.Project; + +@Path("/3853") +public class C3853 { + @GET(value = "/stub", projection = "(id, name)") + public U3853 projectUser() { + return U3853.createUser(); + } + + @GET("/optional") + @Project("(id, name)") + public Optional findUser() { + return Optional.of(U3853.createUser()); + } + + @GET("/list") + @Project("(id)") + public List findUsers() { + return List.of(U3853.createUser()); + } + + @GET("/projected") + @Project("(id, name)") + public Projected projected() { + return Projected.wrap(U3853.createUser()).include("(id, name)"); + } + + @GET(value = "/projectedProjection", projection = "(id, name)") + public Projected projectedProjection() { + return Projected.wrap(U3853.createUser()).include("(id, name)"); + } +} diff --git a/tests/src/test/java/io/jooby/i3853/Issue3853.java b/tests/src/test/java/io/jooby/i3853/Issue3853.java new file mode 100644 index 0000000000..62e663d8a0 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3853/Issue3853.java @@ -0,0 +1,241 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3853; + +import static io.jooby.i3853.U3853.createUser; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.*; + +import io.jooby.Extension; +import io.jooby.Projected; +import io.jooby.Projection; +import io.jooby.avaje.jsonb.AvajeJsonbModule; +import io.jooby.jackson.JacksonModule; +import io.jooby.jackson3.Jackson3Module; +import io.jooby.junit.ServerTest; +import io.jooby.junit.ServerTestRunner; + +public class Issue3853 { + + Projection STUB = Projection.of(U3853.class).include("(id, name)"); + + @ServerTest + public void shouldProjectJackson2Data(ServerTestRunner runner) { + shouldProjectData(runner, new JacksonModule()); + } + + @ServerTest + public void shouldProjectJackson3Data(ServerTestRunner runner) { + shouldProjectData(runner, new Jackson3Module()); + } + + @ServerTest + public void shouldProjectAvajeData(ServerTestRunner runner) { + shouldProjectData(runner, new AvajeJsonbModule()); + } + + public void shouldProjectData(ServerTestRunner runner, Extension extension) { + runner + .define( + app -> { + app.install(extension); + + app.get( + "/stub", + ctx -> { + return Projected.wrap(createUser(), STUB); + }); + + app.get( + "/stub-list", + ctx -> { + return Projected.wrap(List.of(createUser())).include("(id, name)"); + }); + + app.get( + "/stub-empty-list", + ctx -> { + return Projected.wrap(List.of()).include("(id, name)"); + }); + + app.get( + "/stub-set", + ctx -> { + return Projected.wrap(Set.of(createUser())).include("(id, name)"); + }); + app.get( + "/stub-optional", + ctx -> { + return Projected.wrap(Optional.of(createUser())).include("(id, name)"); + }); + app.get( + "/stub-optional-null", + ctx -> { + return Projected.wrap(Optional.empty()).include("(id, name)"); + }); + app.get( + "/stub/meta", + ctx -> { + return Projected.wrap(createUser()).include("(id, meta(target))"); + }); + app.get( + "/stub/roles", + ctx -> { + return Projected.wrap(createUser()).include("(id, roles(name))"); + }); + app.get( + "/stub/address", + ctx -> { + return Projected.wrap(createUser()).include("(id, name, address(*))"); + }); + app.get( + "/stub/address-stub", + ctx -> { + return Projected.wrap(createUser()).include("(id, name, address(city))"); + }); + app.get( + "/stub/address-loc-lat", + ctx -> { + return Projected.wrap(createUser()).include("(id, name, address(loc(lat)))"); + }); + }) + .ready( + http -> { + http.get( + "/stub/meta", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"id":"cobb-001","meta":{"target":"Robert Fischer","objective":"Inception","status":"Synchronizing Kicks"}} + """); + }); + http.get( + "/stub", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"id":"cobb-001","name":"Dom Cobb"} + """); + }); + http.get( + "/stub-list", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + [{"id":"cobb-001","name":"Dom Cobb"}] + """); + }); + http.get( + "/stub-set", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + [{"id":"cobb-001","name":"Dom Cobb"}] + """); + }); + http.get( + "/stub-optional", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"id":"cobb-001","name":"Dom Cobb"} + """); + }); + http.get( + "/stub-empty-list", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + [] + """); + }); + http.get( + "/stub-optional-null", + rsp -> { + assertThat(rsp.body().string()).isEqualTo("null"); + }); + http.get( + "/stub/address", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"id":"cobb-001","name":"Dom Cobb","address":{"city":"Snow Fortress (Level 3)","loc":{"lat":80.0,"lon":-20.0}}} + """); + }); + http.get( + "/stub/address-stub", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"id":"cobb-001","name":"Dom Cobb","address":{"city":"Snow Fortress (Level 3)"}} + """); + }); + http.get( + "/stub/address-loc-lat", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"id":"cobb-001","name":"Dom Cobb","address":{"loc":{"lat":80.0}}} + """); + }); + http.get( + "/stub/roles", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"id":"cobb-001","roles":[{"name":"The Extractor"},{"name":"The Architect"},{"name":"The Point Man"},{"name":"The Forger"}]} + """); + }); + }); + } + + @ServerTest + public void jackson2ShouldNotThrowInvalidDefinitionException(ServerTestRunner runner) { + jacksonShouldNotThrowInvalidDefinitionException(runner, new JacksonModule()); + } + + @ServerTest + public void jackson3ShouldNotThrowInvalidDefinitionException(ServerTestRunner runner) { + jacksonShouldNotThrowInvalidDefinitionException(runner, new Jackson3Module()); + } + + public void jacksonShouldNotThrowInvalidDefinitionException( + ServerTestRunner runner, Extension extension) { + runner + .define( + app -> { + app.install(extension); + app.get( + "/user", + ctx -> { + return createUser(); + }); + }) + .ready( + http -> { + http.get( + "/user", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"id":"cobb-001","name":"Dom Cobb","address":{"city":"Snow Fortress (Level 3)","loc":{"lat":80.0,"lon":-20.0}},"roles":[{"name":"The Extractor","level":10},{"name":"The Architect","level":9},{"name":"The Point Man","level":8},{"name":"The Forger","level":8}],"meta":{"target":"Robert Fischer","objective":"Inception","status":"Synchronizing Kicks"}} + """); + }); + }); + } +} diff --git a/tests/src/test/java/io/jooby/i3853/Issue3853Mvc.java b/tests/src/test/java/io/jooby/i3853/Issue3853Mvc.java new file mode 100644 index 0000000000..b563a088dd --- /dev/null +++ b/tests/src/test/java/io/jooby/i3853/Issue3853Mvc.java @@ -0,0 +1,91 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3853; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.jooby.Extension; +import io.jooby.avaje.jsonb.AvajeJsonbModule; +import io.jooby.jackson.JacksonModule; +import io.jooby.jackson3.Jackson3Module; +import io.jooby.junit.ServerTest; +import io.jooby.junit.ServerTestRunner; + +public class Issue3853Mvc { + + @ServerTest + public void shouldProjectJackson2Data(ServerTestRunner runner) { + shouldProjectData(runner, new JacksonModule()); + } + + @ServerTest + public void shouldProjectJackson3Data(ServerTestRunner runner) { + shouldProjectData(runner, new Jackson3Module()); + } + + @ServerTest + public void shouldProjectAvajeData(ServerTestRunner runner) { + shouldProjectData(runner, new AvajeJsonbModule()); + } + + public void shouldProjectData(ServerTestRunner runner, Extension extension) { + runner + .define( + app -> { + app.install(extension); + + app.mvc(new C3853_()); + }) + .ready( + http -> { + http.get( + "/3853/stub", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"id":"cobb-001","name":"Dom Cobb"} + """); + }); + http.get( + "/3853/optional", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"id":"cobb-001","name":"Dom Cobb"} + """); + }); + http.get( + "/3853/list", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + [{"id":"cobb-001"}] + """); + }); + http.get( + "/3853/projected", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"id":"cobb-001","name":"Dom Cobb"} + """); + }); + http.get( + "/3853/projectedProjection", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"id":"cobb-001","name":"Dom Cobb"} + """); + }); + }); + } +} diff --git a/tests/src/test/java/io/jooby/i3853/L3853.java b/tests/src/test/java/io/jooby/i3853/L3853.java new file mode 100644 index 0000000000..918c6b79f5 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3853/L3853.java @@ -0,0 +1,11 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3853; + +import io.avaje.jsonb.Json; + +@Json +public record L3853(double lat, double lon) {} diff --git a/tests/src/test/java/io/jooby/i3853/R3853.java b/tests/src/test/java/io/jooby/i3853/R3853.java new file mode 100644 index 0000000000..52fb11294b --- /dev/null +++ b/tests/src/test/java/io/jooby/i3853/R3853.java @@ -0,0 +1,11 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3853; + +import io.avaje.jsonb.Json; + +@Json +public record R3853(String name, int level) {} diff --git a/tests/src/test/java/io/jooby/i3853/U3853.java b/tests/src/test/java/io/jooby/i3853/U3853.java new file mode 100644 index 0000000000..45b66e1644 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3853/U3853.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.i3853; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import io.avaje.jsonb.Json; + +@Json +public class U3853 { + private final String id; + private final String name; + private final A3853 address; + private final List roles; + private final Map meta; + + public U3853(String id, String name, A3853 address, List roles, Map meta) { + this.id = id; + this.name = name; + this.address = address; + this.roles = roles; + this.meta = meta; + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public A3853 getAddress() { + return address; + } + + public List getRoles() { + return roles; + } + + public Map getMeta() { + return meta; + } + + public static U3853 createUser() { + // Nested Location: The Fortress in the Snow (Level 3) + L3853 fortress = new L3853(80.0, -20.0); + + // Address: Represents the "Dream Layer" + A3853 dreamLayer = new A3853("Snow Fortress (Level 3)", fortress); + + // Roles: The Extraction Team + List roles = + List.of( + new R3853("The Extractor", 10), + new R3853("The Architect", 9), + new R3853("The Point Man", 8), + new R3853("The Forger", 8)); + + // Metadata: Mission specs + Map meta = new LinkedHashMap<>(); + meta.put("target", "Robert Fischer"); + meta.put("objective", "Inception"); + meta.put("status", "Synchronizing Kicks"); + + // Root User: Dom Cobb + return new U3853("cobb-001", "Dom Cobb", dreamLayer, roles, meta); + } +} diff --git a/tests/src/test/java/io/jooby/test/FeaturedTest.java b/tests/src/test/java/io/jooby/test/FeaturedTest.java index de84f0a75f..84352b972f 100644 --- a/tests/src/test/java/io/jooby/test/FeaturedTest.java +++ b/tests/src/test/java/io/jooby/test/FeaturedTest.java @@ -67,6 +67,7 @@ import io.jooby.handler.TraceHandler; import io.jooby.handler.WebVariables; import io.jooby.jackson.JacksonModule; +import io.jooby.jackson3.Jackson3Module; import io.jooby.junit.ServerTest; import io.jooby.junit.ServerTestRunner; import io.jooby.netty.NettyServer; @@ -272,7 +273,7 @@ public void rawPath(ServerTestRunner runner) { runner .define( app -> { - app.get("/{code}", ctx -> ctx.getRequestPath()); + app.get("/{code}", Context::getRequestPath); }) .ready( client -> { @@ -912,7 +913,7 @@ public void decoder(ServerTestRunner runner) { runner .define( app -> { - app.install(new JacksonModule()); + app.install(new Jackson3Module()); app.post("/map", ctx -> ctx.body(Map.class)); @@ -971,7 +972,7 @@ public void jsonVsRawOutput(ServerTestRunner runner) { runner .define( app -> { - app.install(new JacksonModule()); + app.install(new Jackson3Module()); app.path( "/api/pets", @@ -1211,7 +1212,7 @@ public void errorHandler(ServerTestRunner runner) { runner .define( app -> { - app.install(new JacksonModule()); + app.install(new Jackson3Module()); app.get( "/", diff --git a/tests/src/test/java/io/jooby/test/Http2Test.java b/tests/src/test/java/io/jooby/test/Http2Test.java index 09594ab77b..f0405aeda1 100644 --- a/tests/src/test/java/io/jooby/test/Http2Test.java +++ b/tests/src/test/java/io/jooby/test/Http2Test.java @@ -5,9 +5,13 @@ */ package io.jooby.test; +import static io.jooby.test.TestUtil._19kb; +import static okhttp3.RequestBody.create; import static org.junit.jupiter.api.Assertions.assertEquals; import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.concurrent.Phaser; @@ -26,19 +30,62 @@ import org.junit.jupiter.api.BeforeAll; import com.google.common.collect.ImmutableMap; -import io.jooby.ServerOptions; -import io.jooby.SneakyThrows; -import io.jooby.StatusCode; +import io.jooby.*; +import io.jooby.jackson.JacksonModule; import io.jooby.junit.ServerTest; import io.jooby.junit.ServerTestRunner; +import okhttp3.*; import okhttp3.MediaType; -import okhttp3.Protocol; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; public class Http2Test { + @ServerTest + public void h2body(ServerTestRunner runner) { + runner + .options(new ServerOptions().setHttp2(true).setSecurePort(8443)) + .define( + app -> { + app.install(new JacksonModule()); + app.post( + "/h2/multipart", + ctx -> { + try (var f = ctx.file("f")) { + return ctx.getScheme() + + ":" + + ctx.getProtocol() + + ":" + + new String(f.bytes(), StandardCharsets.UTF_8); + } + }); + + app.post( + "/h2/body", + ctx -> { + return ctx.getScheme() + ":" + ctx.getProtocol() + ":" + ctx.body(Map.class); + }); + }) + .ready( + (http, https) -> { + https.post( + "/h2/multipart", + new MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart( + "f", "19kb.txt", create(_19kb, MediaType.parse("text/plain"))) + .build(), + rsp -> { + assertEquals("https:HTTP/2.0:" + _19kb, rsp.body().string()); + }); + + https.post( + "/h2/body", + create("{\"foo\": \"bar\"}", MediaType.parse("application/json")), + rsp -> { + assertEquals("https:HTTP/2.0:" + "{foo=bar}", rsp.body().string()); + }); + }); + } + @ServerTest public void http2(ServerTestRunner runner) { runner