diff --git a/docs/asciidoc/modules/openapi-ascii.adoc b/docs/asciidoc/modules/openapi-ascii.adoc new file mode 100644 index 0000000000..2034d6fafc --- /dev/null +++ b/docs/asciidoc/modules/openapi-ascii.adoc @@ -0,0 +1,445 @@ +==== Ascii Doc + +===== Setup + +1) create your template: `doc/library.adoc` + +[source, asciidoc] +---- += 📚 {{info.title}} Guide +:source-highlighter: highlightjs + +{{ info.description }} + +== Base URL + +All requests start with: `{{ server(0).url }}/library` + +=== Summary + +{{ routes | table(grid="rows") }} +---- + +2) add to build process + +.pom.xml +[source, xml, role = "primary", subs="verbatim,attributes"] +---- +... + + ... + + io.jooby + jooby-maven-plugin + {joobyVersion} + + + + openapi + + + + doc/library.adoc + + + + + + +---- + +.build.gradle +[source, groovy, role = "secondary", subs="verbatim,attributes"] +---- +openAPI { + adoc = ["doc/library.adoc"] +} +---- + +3) The output directory will have two files: + - library.adoc (final asciidoctor file) + - library.html (asciidoctor output) + +===== 1. Overview +The **Jooby OpenAPI Template Engine** is a tool designed to generate comprehensive **AsciiDoc (`.adoc`)** documentation directly from your Jooby application's OpenAPI model. + +It uses https://pebbletemplates.io[pebble] as a pre-processor to automate redundant tasks. Instead of manually writing repetitive documentation for every endpoint, you write a single template that pulls live data from your code (routes, schemas, javadoc). + +====== Pebble Syntax Primer +You mix standard AsciiDoc text with Pebble logic. + +* **`{{ expression }}`**: **Output.** Use this to print values to the file. ++ +_Example:_ `{{ info.title }}` prints the API title. + +* **`{% tag %}`**: **Logic.** Use this for control flow (loops, variables, if/else) without printing output. ++ +_Example:_ `{% for route in routes %} ... {% endfor %}` loops through your API routes. + +--- + +===== 2. The Pipeline Concept + +Data generation follows a flexible pipeline architecture. You start with a source and can optionally transform it before rendering. + +[source, subs="verbatim,quotes"] +---- +{{ *Source* | [*Mutator*] | *Display* }} +---- + +. **Source**: Finds an object in the OpenAPI model (e.g., a route or schema). +. **Mutator** _(Optional)_: Transforms or filters the data (e.g., extracting just the body, or filtering parameters). +. **Display**: Renders the final output (e.g., as JSON, a Table, or a cURL command). + +====== Examples +* **Simple:** Source -> Display ++ +`{{ info.description }}` + +* **Chained:** Source -> Mutator -> Display ++ +`{{ GET("/users") | request | body | example | json }}` + +--- + +===== 3. Data Sources (Lookups) +These functions are your entry points to locate objects within the OpenAPI definition. + +[cols="2m,3,3"] +|=== +|Function |Description |Example + +|operation(method, path) +|Generic lookup for an API operation. +|`{{ operation("GET", "/books") }}` + +|GET(path) +|Shorthand for `operation("GET", path)`. +|`{{ GET("/books") }}` + +|POST(path) +|Shorthand for `operation("POST", path)`. +|`{{ POST("/books") }}` + +|PUT / PATCH / DELETE +|Shorthand for respective HTTP methods. +|`{{ DELETE("/books/{id}") }}` + +|schema(name) +|Looks up a Schema/Model definition by name. +|`{{ schema("User") }}` + +|tag(name) +|Selects a specific Tag group (containing name, description, and routes). +|`{{ tag("Inventory") }}` + +|routes() +|Returns a collection of all available routes in the API. +|`{% for r in routes() %}...{% endfor %}` + +|server(index) +|Selects a server definition from the OpenAPI spec by index. +|`{{ server(0).url }}` + +|error(code) +|Generates an error response object. + +**Default:** `{statusCode, reason, message}`. + +**Custom:** Looks for a global `error` variable map and interpolates values. +|`{{ error(404) }}` + +|statusCode(input) +|Generates status code descriptions. Accepts: + +1. **Int:** Default reason. + +2. **List:** `[200, 404]` + +3. **Map:** `{200: "OK", 400: "Bad Syntax"}` (Overrides defaults). +|`{{ statusCode(200) }}` + +`{{ statusCode([200, 400]) }}` + +`{{ statusCode( {200: "OK", 400: "Bad Syntax"} ) }}` + +|=== + +--- + +===== 4. Mutators (Transformers) +Mutators modify the data stream. They are optional but powerful for drilling down into specific parts of an object. + +[cols="1m,2,2"] +|=== +|Filter |Description |Input Context + +|request +|Extracts the Request component from an operation. +|Operation + +|response(code) +|Extracts a specific Response component. + +**Default:** `200` (OK). +|Operation + +|body +|Extracts the Body payload definition. +|Operation / Request / Response + +|form +|Extracts form-data parameters specifically. +|Operation / Request + +|parameters(type) +|Extracts parameters. + +**Default:** Returns all parameters. + +**Filter Arguments:** `query`, `header`, `path`, `cookie`. +|Operation / Request + +|example +|Populates a Schema with example data. +|Schema / Body + +|truncate +|Takes a complex Schema and returns a new Schema containing **only direct fields**. Removes nested objects and deep relationships. +|Schema / Body +|=== + +--- + +===== 5. Display (Renderers) +The final step in the chain. These filters determine how the data is written to the AsciiDoc file. + +[cols="1m,3"] +|=== +|Filter |Description + +|curl +|Generates a ready-to-use **cURL** command. + +Includes method, headers, and body. + +|http +|Renders the raw **HTTP** Request/Response wire format. + +(Status line, Headers, Body). + +|path(params...) +|Renders the full relative URI. + +**Arguments:** Pass `key=value` pairs to override path variables or query parameters in the output. + +_Example:_ `path(id=123, sort="asc")` + +|json +|Renders the input object as a formatted JSON block. + +|yaml +|Renders the input object as a YAML block. + +|table +|Renders a standard AsciiDoc/Markdown table. + +Great for lists of parameters or schema fields. + +|list +|Renders a simple bulleted list. + +Used mostly for Status Codes or Enums. + +|link +|Renders an ascii doc on schema. + +Only for Schemas. + +|=== + +--- + +===== 6. Common Recipes + +====== A. Documenting a Route (The "Standard" Block) +Use this pattern to document a specific endpoint, separating path parameters from query parameters. + +[source, asciidoc] +---- +// 1. Define the route +{% set route = GET("/library/books/{isbn}") %} + +=== {{ route.summary }} + +{{ route.description }} + +// 2. Render Path Params +.Path Parameters +{{ route | parameters(path) | table }} + +// 3. Render Query Params +.Query Parameters +{{ route | parameters(query) | table }} + +// 4. Render Response + example +.Response +{{ route | response | body | example | json }} + +// 5. Render Response and Override Status Code +.Created(201) Response +{{ route | response(201) | http }} + +// 6. Render Response 400 +.Bad Request Response +{{ route | response(400) | http }} + +{{ route | response(400) | json }} +---- + +====== B. The "Try It" Button (cURL) +Provide a copy-paste command for developers. + +[source] +---- +{{ POST("/items") | curl }} +---- + +.Passing curl options +[source] +---- +{{ POST("/items") | curl("-i", "-H", "'Accept: application/xml'") }} +---- + +.Generate a bash source +[source, bash] +---- +{{ POST("/items") | curl(language="bash") }} +---- + +====== C. Simulating specific scenarios +Use the `path` filter to inject specific values into the URL, making the documentation more realistic. + +[source, pebble] +---- +// Scenario: Searching for Sci-Fi books on page 2 +GET {{ GET("/search") | path(q="Sci-Fi", page=2) }} +---- + +_Output:_ `GET /search?q=Sci-Fi&page=2` + +====== D. Simplifying Complex Objects +If your database entities have deep nesting (e.g., Book -> Author -> Address -> Country), use `truncate` to show a summary view. + +[source, pebble] +---- +// Full Graph (Huge JSON) +{{ schema("Book") | example | json }} + +// Summary (Flat JSON) +{{ schema("Book") | truncate | example | json }} +---- + +====== E. Error Response Reference +You can generate error examples using the standard format or a custom structure. + +**1. Default Structure** +Generates a JSON with `statusCode`, `reason`, and `message`. + +[source, pebble] +---- +.404 Not Found +{{ error(404) | json }} +---- + +**2. Custom Error Structure** +Define a variable named `error` with a map containing your fields. Use `{{code}}`, `{{message}}`, and `{{reason}}` placeholders which the engine will automatically populate. + +[source, pebble] +---- +// Define the custom error shape once +{%- set error = { + "code": "{{code}}", + "message": "{{message}}", + "reason": "{{reason}}", + "timestamp": "2025-01-01T12:00:00Z", + "support": "help@example.com" +} -%} + +// Now generate the error. It will use the map above. +.400 Bad Request +{{ error(400) | json }} +---- + +_Output:_ +[source, json] +---- +{ + "code": 400, + "message": "Bad Request", + "reason": "Bad Request", + "timestamp": "2025-01-01T12:00:00Z", + "support": "help@example.com" +} +---- + +====== F. Dynamic Tag Loop +Automatically document your API by iterating over tags defined in Java. + +[source, pebble] +---- +{% for tag in tags %} +== {{ tag.name }} + {% for route in tag.routes %} + === {{ route.summary }} + {{ route.method }} {{ route.path }} + {% endfor %} +{% endfor %} +---- + +--- + +===== 7. Advanced Patterns + +====== G. Reusable Macros (DRY) +As your template grows, use macros to create reusable UI components (like warning blocks or deprecated notices) to keep the main logic clean. + +[source, pebble] +---- +{# 1. Define the Macro at the top of your file #} +{% macro deprecationWarning(since) %} +[WARNING] +==== +This endpoint is deprecated since version {{ since }}. +Please use the newer version instead. +==== +{% endmacro %} + +{# 2. Use it inside your route loop #} +{% if route.deprecated %} + {{ deprecationWarning("v2.1") }} +{% endif %} +---- + +====== H. Security & Permissions +If your API uses authentication (OAuth2, API Keys), the `security` property on the route contains the requirements. + +[source, pebble] +---- +{% if route.security %} +.Required Permissions +[cols="1,3"] +|=== +|Type | Scopes + +{# Iterate through security schemes #} +{% for scheme in route.security %} + {% for req in scheme %} +| *{{ loop.key }}* | {{ req | join(", ") }} + {% endfor %} +{% endfor %} +|=== +{% endif %} +---- + +====== I. Linking to Schema Definitions +AsciiDoc supports internal anchors. You can automatically link a route's return type to its full Schema definition elsewhere in the document. + +[source, pebble] +---- +{# 1. Create Anchors in your Schema Loop #} +{% for s in schemas %} +[id="{{ s.name }}"] +== {{ s.name }} +{{ s | table }} +{% endfor %} + +{# 2. Link to them in your Route Loop #} +.Response Type +Returns a {{ route | response | link }} object. +---- diff --git a/docs/asciidoc/modules/openapi.adoc b/docs/asciidoc/modules/openapi.adoc index 773c3af54c..cd8125b2df 100644 --- a/docs/asciidoc/modules/openapi.adoc +++ b/docs/asciidoc/modules/openapi.adoc @@ -30,6 +30,12 @@ This library supports: openapi + + ... + + ... + + @@ -193,7 +199,9 @@ class Pets { The Maven plugin and Gradle task provide two filter properties `includes` and `excludes`. These properties filter routes by their path pattern. The filter is a regular expression. -=== JavaDoc comments +=== Documenting your API + +==== JavaDoc comments JavaDoc comments are supported on Java in script and MVC routes. @@ -384,6 +392,49 @@ Whitespaces (including new lines) are ignored. To introduce a new line, you must | | +|@securityScheme.name +|[x] +| +| +| + +|@securityScheme.in +|[x] +| +| +| + +|@securityScheme.paramName +|[x] +| +| +| + +|@securityScheme.flows.implicit.authorizationUrl +|[x] +| +| +| + +|@securityScheme.flows.implicit.scopes.name +|[x] +| +| +| + +|@securityScheme.flows.implicit.scopes.description +|[x] +| +| +| + +|@securityRequirement +| +|[x] +|[x] +|Name of the `securityScheme` and optionally scopes. Example: `myOauth2Security read:pets` + + |@server.description |[x] | @@ -443,7 +494,56 @@ Whitespaces (including new lines) are ignored. To introduce a new line, you must This feature is only available for Java routes. Kotlin source code is not supported. -=== Annotations +==== Documentation Template + +The OpenAPI output generates some default values for `info` and `server` section. It generates +the necessary to follow the specification and produces a valid output. These sections can be override +with better information/metadata. + +To do so just write an `openapi.yaml` file inside the `conf` directory to use it as template. + +.conf/openapi.yaml +[source, yaml] +---- +openapi: 3.0.1 +info: + title: My Super API + description: | + Nunc commodo ipsum vitae dignissim congue. Quisque convallis malesuada tortor, non + lacinia quam malesuada id. Curabitur nisi mi, lobortis non tempus vel, vestibulum et neque. + + ... + version: "1.0" + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + paths: + /api/pets: + get: + operationId: listPets + description: List and sort pets. + parameters: + name: page + descripton: Page number. + +---- + +All sections from template file are merged into the final output. + +The extension property: `x-merge-policy` controls how merge must be done: + +- ignore: Silently ignore a path or operation present in template but not found in generated output. This is the default value. +- keep: Add a path or operation to final output. Must be valid path or operation. +- fail: Throw an error when path or operation is present in template but not found in generated output. + +The extension property can be added at root/global level, paths, pathItem, operation or parameter level. + +[NOTE] +==== +Keep in mind that any section found here in the template overrides existing metadata. +==== + +=== Swagger Annotations Optionally this plugin depends on some OpenAPI annotations. To use them, you need to add a dependency to your project: @@ -679,56 +779,11 @@ You need the `ApiResponse` annotation: }) ---- -=== Documentation Template - -The OpenAPI output generates some default values for `info` and `server` section. It generates -the necessary to follow the specification and produces a valid output. These sections can be override -with better information/metadata. - -To do so just write an `openapi.yaml` file inside the `conf` directory to use it as template. - -.conf/openapi.yaml -[source, yaml] ----- -openapi: 3.0.1 -info: - title: My Super API - description: | - Nunc commodo ipsum vitae dignissim congue. Quisque convallis malesuada tortor, non - lacinia quam malesuada id. Curabitur nisi mi, lobortis non tempus vel, vestibulum et neque. - - ... - version: "1.0" - license: - name: Apache 2.0 - url: http://www.apache.org/licenses/LICENSE-2.0.html - paths: - /api/pets: - get: - operationId: listPets - description: List and sort pets. - parameters: - name: page - descripton: Page number. - ----- - -All sections from template file are merged into the final output. - -The extension property: `x-merge-policy` controls how merge must be done: - -- ignore: Silently ignore a path or operation present in template but not found in generated output. This is the default value. -- keep: Add a path or operation to final output. Must be valid path or operation. -- fail: Throw an error when path or operation is present in template but not found in generated output. - -The extension property can be added at root/global level, paths, pathItem, operation or parameter level. +=== Output/Display -[NOTE] -==== -Keep in mind that any section found here in the template overrides existing metadata. -==== +include::modules/openapi-ascii.adoc[] -=== Swagger UI +==== Swagger UI To use swagger-ui just add the dependency to your project: @@ -737,7 +792,7 @@ To use swagger-ui just add the dependency to your project: The swagger-ui application will be available at `/swagger`. To modify the default path, just call javadoc:OpenAPIModule[swaggerUI, java.lang.String] -=== Redoc +==== Redoc To use redoc just add the dependency to your project: diff --git a/docs/src/main/java/io/jooby/adoc/DocGenerator.java b/docs/src/main/java/io/jooby/adoc/DocGenerator.java index c2e0fa0d6c..2aa68f2be2 100644 --- a/docs/src/main/java/io/jooby/adoc/DocGenerator.java +++ b/docs/src/main/java/io/jooby/adoc/DocGenerator.java @@ -87,29 +87,29 @@ public static void generate(Path basedir, boolean publish, boolean v1, boolean d pb.step(); if (doAscii) { - Asciidoctor asciidoctor = Asciidoctor.Factory.create(); - - asciidoctor.convertFile( - asciidoc.resolve("index.adoc").toFile(), - createOptions(asciidoc, outdir, version, null, asciidoc.resolve("index.adoc"))); - var index = outdir.resolve("index.html"); - Files.writeString(index, hljs(Files.readString(index))); - pb.step(); - - Stream.of(treeDirs) - .forEach( - throwingConsumer( - name -> { - Path modules = outdir.resolve(name); - Files.createDirectories(modules); - Files.walk(asciidoc.resolve(name)) - .filter(Files::isRegularFile) - .forEach( - module -> { - processModule(asciidoctor, asciidoc, module, outdir, name, version); - pb.step(); - }); - })); + try (var asciidoctor = Asciidoctor.Factory.create()) { + asciidoctor.convertFile( + asciidoc.resolve("index.adoc").toFile(), + createOptions(asciidoc, outdir, version, null, asciidoc.resolve("index.adoc"))); + var index = outdir.resolve("index.html"); + Files.writeString(index, hljs(Files.readString(index))); + pb.step(); + + Stream.of(treeDirs) + .forEach( + throwingConsumer( + name -> { + Path modules = outdir.resolve(name); + Files.createDirectories(modules); + Files.walk(asciidoc.resolve(name)) + .filter(Files::isRegularFile) + .forEach( + module -> { + processModule(asciidoctor, asciidoc, module, outdir, name, version); + pb.step(); + }); + })); + } } // LICENSE @@ -280,7 +280,7 @@ private static Options createOptions(Path basedir, Path outdir, String version, attributes.attribute("date", DateTimeFormatter.ISO_INSTANT.format(Instant.now())); OptionsBuilder options = Options.builder(); - options.backend("html"); + options.backend("html5"); options.attributes(attributes.build()); options.baseDir(basedir.toAbsolutePath().toFile()); diff --git a/jooby/src/main/java/io/jooby/StatusCode.java b/jooby/src/main/java/io/jooby/StatusCode.java index 1dd1e9b1c5..0292bcf902 100644 --- a/jooby/src/main/java/io/jooby/StatusCode.java +++ b/jooby/src/main/java/io/jooby/StatusCode.java @@ -922,6 +922,11 @@ private StatusCode(final int value, final String reason) { this.reason = reason; } + private StatusCode(final int value) { + this.value = value; + this.reason = Integer.toString(value); + } + /** * Return the integer value of this status code. * @@ -971,129 +976,68 @@ public int hashCode() { * @throws IllegalArgumentException if this enum has no constant for the specified numeric value */ public static StatusCode valueOf(final int statusCode) { - switch (statusCode) { - case CONTINUE_CODE: - return CONTINUE; - case SWITCHING_PROTOCOLS_CODE: - return SWITCHING_PROTOCOLS; - case PROCESSING_CODE: - return PROCESSING; - case CHECKPOINT_CODE: - return CHECKPOINT; - case OK_CODE: - return OK; - case CREATED_CODE: - return CREATED; - case ACCEPTED_CODE: - return ACCEPTED; - case NON_AUTHORITATIVE_INFORMATION_CODE: - return NON_AUTHORITATIVE_INFORMATION; - case NO_CONTENT_CODE: - return NO_CONTENT; - case RESET_CONTENT_CODE: - return RESET_CONTENT; - case PARTIAL_CONTENT_CODE: - return PARTIAL_CONTENT; - case MULTI_STATUS_CODE: - return MULTI_STATUS; - case ALREADY_REPORTED_CODE: - return ALREADY_REPORTED; - case IM_USED_CODE: - return IM_USED; - case MULTIPLE_CHOICES_CODE: - return MULTIPLE_CHOICES; - case MOVED_PERMANENTLY_CODE: - return MOVED_PERMANENTLY; - case FOUND_CODE: - return FOUND; - case SEE_OTHER_CODE: - return SEE_OTHER; - case NOT_MODIFIED_CODE: - return NOT_MODIFIED; - case USE_PROXY_CODE: - return USE_PROXY; - case TEMPORARY_REDIRECT_CODE: - return TEMPORARY_REDIRECT; - case RESUME_INCOMPLETE_CODE: - return RESUME_INCOMPLETE; - case BAD_REQUEST_CODE: - return BAD_REQUEST; - case UNAUTHORIZED_CODE: - return UNAUTHORIZED; - case PAYMENT_REQUIRED_CODE: - return PAYMENT_REQUIRED; - case FORBIDDEN_CODE: - return FORBIDDEN; - case NOT_FOUND_CODE: - return NOT_FOUND; - case METHOD_NOT_ALLOWED_CODE: - return METHOD_NOT_ALLOWED; - case NOT_ACCEPTABLE_CODE: - return NOT_ACCEPTABLE; - case PROXY_AUTHENTICATION_REQUIRED_CODE: - return PROXY_AUTHENTICATION_REQUIRED; - case REQUEST_TIMEOUT_CODE: - return REQUEST_TIMEOUT; - case CONFLICT_CODE: - return CONFLICT; - case GONE_CODE: - return GONE; - case LENGTH_REQUIRED_CODE: - return LENGTH_REQUIRED; - case PRECONDITION_FAILED_CODE: - return PRECONDITION_FAILED; - case REQUEST_ENTITY_TOO_LARGE_CODE: - return REQUEST_ENTITY_TOO_LARGE; - case REQUEST_URI_TOO_LONG_CODE: - return REQUEST_URI_TOO_LONG; - case UNSUPPORTED_MEDIA_TYPE_CODE: - return UNSUPPORTED_MEDIA_TYPE; - case REQUESTED_RANGE_NOT_SATISFIABLE_CODE: - return REQUESTED_RANGE_NOT_SATISFIABLE; - case EXPECTATION_FAILED_CODE: - return EXPECTATION_FAILED; - case I_AM_A_TEAPOT_CODE: - return I_AM_A_TEAPOT; - case UNPROCESSABLE_ENTITY_CODE: - return UNPROCESSABLE_ENTITY; - case LOCKED_CODE: - return LOCKED; - case FAILED_DEPENDENCY_CODE: - return FAILED_DEPENDENCY; - case UPGRADE_REQUIRED_CODE: - return UPGRADE_REQUIRED; - case PRECONDITION_REQUIRED_CODE: - return PRECONDITION_REQUIRED; - case TOO_MANY_REQUESTS_CODE: - return TOO_MANY_REQUESTS; - case REQUEST_HEADER_FIELDS_TOO_LARGE_CODE: - return REQUEST_HEADER_FIELDS_TOO_LARGE; - case SERVER_ERROR_CODE: - return SERVER_ERROR; - case NOT_IMPLEMENTED_CODE: - return NOT_IMPLEMENTED; - case BAD_GATEWAY_CODE: - return BAD_GATEWAY; - case SERVICE_UNAVAILABLE_CODE: - return SERVICE_UNAVAILABLE; - case GATEWAY_TIMEOUT_CODE: - return GATEWAY_TIMEOUT; - case HTTP_VERSION_NOT_SUPPORTED_CODE: - return HTTP_VERSION_NOT_SUPPORTED; - case VARIANT_ALSO_NEGOTIATES_CODE: - return VARIANT_ALSO_NEGOTIATES; - case INSUFFICIENT_STORAGE_CODE: - return INSUFFICIENT_STORAGE; - case LOOP_DETECTED_CODE: - return LOOP_DETECTED; - case BANDWIDTH_LIMIT_EXCEEDED_CODE: - return BANDWIDTH_LIMIT_EXCEEDED; - case NOT_EXTENDED_CODE: - return NOT_EXTENDED; - case NETWORK_AUTHENTICATION_REQUIRED_CODE: - return NETWORK_AUTHENTICATION_REQUIRED; - default: - return new StatusCode(statusCode, Integer.toString(statusCode)); - } + return switch (statusCode) { + case CONTINUE_CODE -> CONTINUE; + case SWITCHING_PROTOCOLS_CODE -> SWITCHING_PROTOCOLS; + case PROCESSING_CODE -> PROCESSING; + case CHECKPOINT_CODE -> CHECKPOINT; + case OK_CODE -> OK; + case CREATED_CODE -> CREATED; + case ACCEPTED_CODE -> ACCEPTED; + case NON_AUTHORITATIVE_INFORMATION_CODE -> NON_AUTHORITATIVE_INFORMATION; + case NO_CONTENT_CODE -> NO_CONTENT; + case RESET_CONTENT_CODE -> RESET_CONTENT; + case PARTIAL_CONTENT_CODE -> PARTIAL_CONTENT; + case MULTI_STATUS_CODE -> MULTI_STATUS; + case ALREADY_REPORTED_CODE -> ALREADY_REPORTED; + case IM_USED_CODE -> IM_USED; + case MULTIPLE_CHOICES_CODE -> MULTIPLE_CHOICES; + case MOVED_PERMANENTLY_CODE -> MOVED_PERMANENTLY; + case FOUND_CODE -> FOUND; + case SEE_OTHER_CODE -> SEE_OTHER; + case NOT_MODIFIED_CODE -> NOT_MODIFIED; + case USE_PROXY_CODE -> USE_PROXY; + case TEMPORARY_REDIRECT_CODE -> TEMPORARY_REDIRECT; + case RESUME_INCOMPLETE_CODE -> RESUME_INCOMPLETE; + case BAD_REQUEST_CODE -> BAD_REQUEST; + case UNAUTHORIZED_CODE -> UNAUTHORIZED; + case PAYMENT_REQUIRED_CODE -> PAYMENT_REQUIRED; + case FORBIDDEN_CODE -> FORBIDDEN; + case NOT_FOUND_CODE -> NOT_FOUND; + case METHOD_NOT_ALLOWED_CODE -> METHOD_NOT_ALLOWED; + case NOT_ACCEPTABLE_CODE -> NOT_ACCEPTABLE; + case PROXY_AUTHENTICATION_REQUIRED_CODE -> PROXY_AUTHENTICATION_REQUIRED; + case REQUEST_TIMEOUT_CODE -> REQUEST_TIMEOUT; + case CONFLICT_CODE -> CONFLICT; + case GONE_CODE -> GONE; + case LENGTH_REQUIRED_CODE -> LENGTH_REQUIRED; + case PRECONDITION_FAILED_CODE -> PRECONDITION_FAILED; + case REQUEST_ENTITY_TOO_LARGE_CODE -> REQUEST_ENTITY_TOO_LARGE; + case REQUEST_URI_TOO_LONG_CODE -> REQUEST_URI_TOO_LONG; + case UNSUPPORTED_MEDIA_TYPE_CODE -> UNSUPPORTED_MEDIA_TYPE; + case REQUESTED_RANGE_NOT_SATISFIABLE_CODE -> REQUESTED_RANGE_NOT_SATISFIABLE; + case EXPECTATION_FAILED_CODE -> EXPECTATION_FAILED; + case I_AM_A_TEAPOT_CODE -> I_AM_A_TEAPOT; + case UNPROCESSABLE_ENTITY_CODE -> UNPROCESSABLE_ENTITY; + case LOCKED_CODE -> LOCKED; + case FAILED_DEPENDENCY_CODE -> FAILED_DEPENDENCY; + case UPGRADE_REQUIRED_CODE -> UPGRADE_REQUIRED; + case PRECONDITION_REQUIRED_CODE -> PRECONDITION_REQUIRED; + case TOO_MANY_REQUESTS_CODE -> TOO_MANY_REQUESTS; + case REQUEST_HEADER_FIELDS_TOO_LARGE_CODE -> REQUEST_HEADER_FIELDS_TOO_LARGE; + case SERVER_ERROR_CODE -> SERVER_ERROR; + case NOT_IMPLEMENTED_CODE -> NOT_IMPLEMENTED; + case BAD_GATEWAY_CODE -> BAD_GATEWAY; + case SERVICE_UNAVAILABLE_CODE -> SERVICE_UNAVAILABLE; + case GATEWAY_TIMEOUT_CODE -> GATEWAY_TIMEOUT; + case HTTP_VERSION_NOT_SUPPORTED_CODE -> HTTP_VERSION_NOT_SUPPORTED; + case VARIANT_ALSO_NEGOTIATES_CODE -> VARIANT_ALSO_NEGOTIATES; + case INSUFFICIENT_STORAGE_CODE -> INSUFFICIENT_STORAGE; + case LOOP_DETECTED_CODE -> LOOP_DETECTED; + case BANDWIDTH_LIMIT_EXCEEDED_CODE -> BANDWIDTH_LIMIT_EXCEEDED; + case NOT_EXTENDED_CODE -> NOT_EXTENDED; + case NETWORK_AUTHENTICATION_REQUIRED_CODE -> NETWORK_AUTHENTICATION_REQUIRED; + default -> new StatusCode(statusCode); + }; } } diff --git a/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/OpenAPITask.java b/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/OpenAPITask.java index 6940c6a5ad..9dc4ba6ba4 100644 --- a/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/OpenAPITask.java +++ b/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/OpenAPITask.java @@ -17,8 +17,11 @@ import java.io.File; import java.nio.file.Path; import java.util.List; +import java.util.Map; import java.util.Optional; +import static java.util.Optional.ofNullable; + /** * Generate an OpenAPI file from a jooby application. * @@ -37,6 +40,8 @@ public class OpenAPITask extends BaseTask { private String specVersion; + private List adoc; + /** * Creates an OpenAPI task. */ @@ -61,7 +66,8 @@ public void generate() throws Throwable { .map(File::toPath); }) .distinct() - .toList(); Path outputDir = classes(getProject(), false); + .toList(); + Path outputDir = classes(getProject(), false); ClassLoader classLoader = createClassLoader(projects); @@ -82,9 +88,10 @@ public void generate() throws Throwable { OpenAPI result = tool.generate(mainClass); - for (OpenAPIGenerator.Format format : OpenAPIGenerator.Format.values()) { - Path output = tool.export(result, format); - getLogger().info(" writing: " + output); + var adocPath = ofNullable(adoc).orElse(List.of()).stream().map(File::toPath).toList(); + for (var format : OpenAPIGenerator.Format.values()) { + tool.export(result, format, Map.of("adoc", adocPath)) + .forEach(output -> getLogger().info(" writing: " + output)); } } @@ -188,6 +195,26 @@ public void setSpecVersion(String specVersion) { this.specVersion = specVersion; } + /** + * Optionally generates adoc output. + * + * @return List of adoc templates. + */ + @Input + @org.gradle.api.tasks.Optional + public List getAdoc() { + return adoc; + } + + /** + * Set adoc templates to build. + * + * @param adoc Adoc templates to build. + */ + public void setAdoc(List adoc) { + this.adoc = adoc; + } + private Optional trim(String value) { if (value == null || value.trim().isEmpty()) { return Optional.empty(); diff --git a/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/OpenAPIMojo.java b/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/OpenAPIMojo.java index 6a2db33c23..35f8d41a1d 100644 --- a/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/OpenAPIMojo.java +++ b/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/OpenAPIMojo.java @@ -5,12 +5,15 @@ */ package io.jooby.maven; +import static java.util.Optional.ofNullable; import static org.apache.maven.plugins.annotations.LifecyclePhase.PROCESS_CLASSES; import static org.apache.maven.plugins.annotations.ResolutionScope.COMPILE_PLUS_RUNTIME; +import java.io.File; import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; +import java.util.Map; import java.util.Optional; import org.apache.maven.plugins.annotations.Mojo; @@ -20,7 +23,6 @@ import edu.umd.cs.findbugs.annotations.NonNull; import edu.umd.cs.findbugs.annotations.Nullable; import io.jooby.openapi.OpenAPIGenerator; -import io.swagger.v3.oas.models.OpenAPI; /** * Generate an OpenAPI file from a jooby application. @@ -47,6 +49,8 @@ public class OpenAPIMojo extends BaseMojo { @Parameter(property = "openAPI.specVersion") private String specVersion; + @Parameter private List adoc; + @Override protected void doExecute(@NonNull List projects, @NonNull String mainClass) throws Exception { @@ -73,16 +77,17 @@ protected void doExecute(@NonNull List projects, @NonNull String m trim(includes).ifPresent(tool::setIncludes); trim(excludes).ifPresent(tool::setExcludes); - OpenAPI result = tool.generate(mainClass); + var result = tool.generate(mainClass); - for (OpenAPIGenerator.Format format : OpenAPIGenerator.Format.values()) { - Path output = tool.export(result, format); - getLog().info(" writing: " + output); + var adocPath = ofNullable(adoc).orElse(List.of()).stream().map(File::toPath).toList(); + for (var format : OpenAPIGenerator.Format.values()) { + tool.export(result, format, Map.of("adoc", adocPath)) + .forEach(output -> getLog().info(" writing: " + output)); } } private Optional trim(String value) { - if (value == null || value.trim().length() == 0) { + if (value == null || value.trim().isEmpty()) { return Optional.empty(); } return Optional.of(value.trim()); @@ -131,4 +136,12 @@ public String getSpecVersion() { public void setSpecVersion(String specVersion) { this.specVersion = specVersion; } + + public List getAdoc() { + return adoc; + } + + public void setAdoc(List adoc) { + this.adoc = adoc; + } } diff --git a/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/RunMojo.java b/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/RunMojo.java index 924e23cc07..a535bf8e1b 100644 --- a/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/RunMojo.java +++ b/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/RunMojo.java @@ -24,6 +24,8 @@ import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; import org.apache.maven.project.MavenProject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import io.jooby.run.JoobyRun; import io.jooby.run.JoobyRunOptions; @@ -42,8 +44,10 @@ @Execute(phase = PROCESS_CLASSES) public class RunMojo extends BaseMojo { + private static final Logger log = LoggerFactory.getLogger(RunMojo.class); + static { - /** Turn off shutdown hook on Server. */ + /* Turn off shutdown hook on Server. */ System.setProperty("jooby.useShutdownHook", "false"); } @@ -97,7 +101,13 @@ protected void doExecute(List projects, String mainClass) throws T var error = result.hasExceptions(); // Success? if (error) { - getLog().debug("Compilation error found: " + path); + var filename = path.getFileName().toFile().toString(); + var isSource = filename.endsWith(".java") || filename.endsWith(".kt"); + for (Throwable exception : result.getExceptions()) { + if (!isSource) { + getLog().error(exception); + } + } } return !error; }); @@ -213,8 +223,7 @@ protected void setUseTestScope(boolean useTestScope) { * @return Request. */ private MavenExecutionRequest mavenRequest(String goal) { - return DefaultMavenExecutionRequest.copy(session.getRequest()) - .setGoals(Collections.singletonList(goal)); + return DefaultMavenExecutionRequest.copy(session.getRequest()).setGoals(List.of(goal)); } private Set sourceDirectories(MavenProject project, boolean useTestScope) { diff --git a/modules/jooby-openapi/pom.xml b/modules/jooby-openapi/pom.xml index 15ee4c1a6b..a4eeae25bc 100644 --- a/modules/jooby-openapi/pom.xml +++ b/modules/jooby-openapi/pom.xml @@ -51,6 +51,21 @@ 12.2.0 + + org.asciidoctor + asciidoctorj + 3.0.1 + + + io.pebbletemplates + pebble + + + + net.datafaker + datafaker + 2.5.3 + commons-codec commons-codec @@ -62,6 +77,17 @@ swagger-parser + + com.google.guava + guava + + + + jakarta.data + jakarta.data-api + 1.0.1 + + org.junit.jupiter @@ -128,6 +154,17 @@ 1.18.2 test + + org.mockito + mockito-core + test + + + org.assertj + assertj-core + 3.27.6 + test + diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AnnotationParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AnnotationParser.java index ae33cb8d56..349a47521d 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AnnotationParser.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AnnotationParser.java @@ -21,14 +21,7 @@ import org.objectweb.asm.tree.*; import io.jooby.*; -import io.jooby.annotation.ContextParam; -import io.jooby.annotation.CookieParam; -import io.jooby.annotation.FormParam; -import io.jooby.annotation.GET; -import io.jooby.annotation.HeaderParam; -import io.jooby.annotation.Path; -import io.jooby.annotation.PathParam; -import io.jooby.annotation.QueryParam; +import io.jooby.annotation.*; import io.swagger.v3.oas.models.media.Content; import io.swagger.v3.oas.models.media.ObjectSchema; import io.swagger.v3.oas.models.media.Schema; @@ -314,6 +307,7 @@ private static Map methods(ParserContext ctx, ClassNode node return methods; } + @SuppressWarnings("unchecked") private static List routerMethod( ParserContext ctx, String prefix, ClassNode classNode, MethodNode method) { @@ -330,6 +324,9 @@ private static List routerMethod( operation.setOperationId(method.name); Optional.ofNullable(requestBody.get()).ifPresent(operation::setRequestBody); + mediaType(classNode, method, produces(), operation::addProduces); + mediaType(classNode, method, consumes(), operation::addConsumes); + result.add(operation); } } @@ -337,6 +334,25 @@ private static List routerMethod( return result; } + @SuppressWarnings("unchecked") + public static void mediaType( + ClassNode classNode, MethodNode method, List types, Consumer consumer) { + mediaType(classNode, method, types).stream() + .map(AsmUtils::toMap) + .map(it -> it.get("value")) + .filter(Objects::nonNull) + .map(List.class::cast) + .flatMap(List::stream) + .distinct() + .forEach(it -> consumer.accept(it.toString())); + } + + public static List mediaType( + ClassNode classNode, MethodNode method, List types) { + var result = findAnnotationByType(method.visibleAnnotations, types); + return result.isEmpty() ? findAnnotationByType(classNode.visibleAnnotations, types) : result; + } + private static ResponseExt returnTypes(MethodNode method) { Signature signature = Signature.create(method); String desc = Optional.ofNullable(method.signature).orElse(method.desc); @@ -604,6 +620,14 @@ private static List httpMethods() { return annotationTypes; } + private static List produces() { + return List.of(Produces.class.getName(), jakarta.ws.rs.Produces.class.getName()); + } + + private static List consumes() { + return List.of(Consumes.class.getName(), jakarta.ws.rs.Consumes.class.getName()); + } + private static List httpMethod(String pkg, Class pathType) { List annotationTypes = Router.METHODS.stream().map(m -> pkg + "." + m).collect(Collectors.toList()); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ArrayLikeSchema.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ArrayLikeSchema.java new file mode 100644 index 0000000000..e853acf5b3 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ArrayLikeSchema.java @@ -0,0 +1,23 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.models.media.Schema; + +@JsonIgnoreProperties({"items"}) +public class ArrayLikeSchema extends Schema { + + public static ArrayLikeSchema create(Schema schema, Schema items) { + var arrayLikeSchema = new ArrayLikeSchema<>(); + arrayLikeSchema.setItems(items); + arrayLikeSchema.setProperties(schema.getProperties()); + arrayLikeSchema.setType(schema.getType()); + arrayLikeSchema.setTypes(schema.getTypes()); + arrayLikeSchema.setName(schema.getName()); + return arrayLikeSchema; + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/EnumSchema.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/EnumSchema.java new file mode 100644 index 0000000000..b9555d7102 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/EnumSchema.java @@ -0,0 +1,35 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi; + +import java.util.HashMap; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.models.media.StringSchema; + +public class EnumSchema extends StringSchema { + @JsonIgnore private final Map fields = new HashMap<>(); + @JsonIgnore private String summary; + + public EnumSchema() {} + + public String getSummary() { + return summary; + } + + public void setSummary(String summary) { + this.summary = summary; + } + + public void setDescription(String name, String description) { + fields.put(name, description); + } + + public String getDescription(String name) { + return fields.get(name); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/MixinHook.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/MixinHook.java new file mode 100644 index 0000000000..099080a5ad --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/MixinHook.java @@ -0,0 +1,75 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.swagger.annotations.ApiModelProperty; +import jakarta.data.page.Page; +import jakarta.data.page.PageRequest; + +public class MixinHook { + + @JsonPropertyOrder({ + "content", + "numberOfElements", + "totalElements", + "totalPages", + "pageRequest", + "nextPageRequest", + "previousPageRequest" + }) + public abstract static class PageMixin implements Page { + @JsonProperty("content") + @Override + public abstract List content(); + + @JsonProperty("numberOfElements") + @Override + public abstract int numberOfElements(); + + @Override + @JsonProperty("pageRequest") + public abstract PageRequest pageRequest(); + + @Override + @JsonProperty("nextPageRequest") + public abstract PageRequest nextPageRequest(); + + @Override + @JsonProperty("previousPageRequest") + public abstract PageRequest previousPageRequest(); + + @Override + @JsonProperty("totalElements") + public abstract long totalElements(); + + @Override + @JsonProperty("totalPages") + public abstract long totalPages(); + } + + @JsonPropertyOrder({"page", "size"}) + public abstract static class PageRequestMixin implements PageRequest { + @JsonProperty("page") + @Override + @ApiModelProperty("The page to be returned") + public abstract long page(); + + @JsonProperty("size") + @Override + @ApiModelProperty("The requested size of each page") + public abstract int size(); + } + + public static void mixin(ObjectMapper mapper) { + mapper.addMixIn(jakarta.data.page.Page.class, PageMixin.class); + mapper.addMixIn(jakarta.data.page.PageRequest.class, PageRequestMixin.class); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ModelConverterExt.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ModelConverterExt.java index 32bead9525..96b5351bfa 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ModelConverterExt.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ModelConverterExt.java @@ -11,10 +11,7 @@ import java.util.Set; import com.fasterxml.jackson.databind.ObjectMapper; -import io.jooby.FileUpload; -import io.jooby.Jooby; -import io.jooby.Router; -import io.jooby.ServiceRegistry; +import io.jooby.*; import io.swagger.v3.core.converter.AnnotatedType; import io.swagger.v3.core.converter.ModelConverter; import io.swagger.v3.core.converter.ModelConverterContext; diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIExt.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIExt.java index 43f5d38a81..0004a40ab1 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIExt.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIExt.java @@ -9,6 +9,7 @@ import java.util.function.BiConsumer; import java.util.function.Consumer; import java.util.function.Function; +import java.util.function.Predicate; import com.fasterxml.jackson.annotation.JsonIgnore; import io.jooby.Router; @@ -233,4 +234,18 @@ private void setProperty(S src, Function getter, S target, BiConsum } } } + + public OperationExt findOperation(String method, String pattern) { + Predicate filter = op -> op.getPath().equals(pattern); + filter = filter.and(op -> op.getMethod().equals(method)); + return getOperations().stream() + .filter(filter) + .findFirst() + .orElseThrow( + () -> new IllegalArgumentException("Operation not found: " + method + " " + pattern)); + } + + public List findOperationByTag(String tag) { + return getOperations().stream().filter(it -> it.isOnTag(tag)).toList(); + } } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIParser.java index 5eed7d7b73..58dc070599 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIParser.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OpenAPIParser.java @@ -604,7 +604,7 @@ private static void operationResponse( String name = stringValue(value, "name"); stringValue(value, "description", header::setDescription); - io.swagger.v3.oas.models.media.Schema schema = + var schema = annotationValue(value, "schema") .map(schemaMap -> toSchema(ctx, schemaMap).orElseGet(StringSchema::new)) .orElseGet(StringSchema::new); diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OperationExt.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OperationExt.java index 50426437dc..f04c8d3ab2 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OperationExt.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OperationExt.java @@ -27,7 +27,7 @@ public class OperationExt extends io.swagger.v3.oas.models.Operation { @JsonIgnore private final MethodNode node; @JsonIgnore private String method; - @JsonIgnore private final String pattern; + @JsonIgnore private final String path; @JsonIgnore private Boolean hidden; @JsonIgnore private LinkedList produces = new LinkedList<>(); @JsonIgnore private LinkedList consumes = new LinkedList<>(); @@ -41,10 +41,10 @@ public class OperationExt extends io.swagger.v3.oas.models.Operation { @JsonIgnore private ClassNode controller; public OperationExt( - MethodNode node, String method, String pattern, List arguments, ResponseExt response) { + MethodNode node, String method, String path, List arguments, ResponseExt response) { this.node = node; this.method = method.toUpperCase(); - this.pattern = pattern; + this.path = path; setParameters(arguments); this.defaultResponse = response; setResponses(apiResponses(Collections.singletonList(response))); @@ -87,8 +87,8 @@ public void setMethod(String method) { this.method = method; } - public String getPattern() { - return pattern; + public String getPath() { + return path; } public List getProduces() { @@ -120,7 +120,7 @@ public void setHidden(Boolean hidden) { } public String toString() { - return getMethod() + " " + getPattern(); + return getMethod() + " " + getPath(); } public Parameter getParameter(int i) { @@ -181,6 +181,10 @@ public void addTag(Tag tag) { addTagsItem(tag.getName()); } + public boolean isOnTag(String tag) { + return globalTags.stream().map(Tag::getName).anyMatch(tag::equals); + } + public List getGlobalTags() { return globalTags; } @@ -267,4 +271,8 @@ public OperationExt copy(String pattern) { copy.setPathExtensions(getPathExtensions()); return copy; } + + public String getPath(Map pathParams) { + return Router.reverse(getPath(), pathParams); + } } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParameterExt.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParameterExt.java index 16c05e9a1e..b8782d79d7 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParameterExt.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParameterExt.java @@ -8,8 +8,12 @@ import java.util.Objects; import com.fasterxml.jackson.annotation.JsonIgnore; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.parameters.Parameter; -public class ParameterExt extends io.swagger.v3.oas.models.parameters.Parameter { +public class ParameterExt extends Parameter { @JsonIgnore private String javaType; @JsonIgnore private Object defaultValue; @@ -54,4 +58,22 @@ public void setRequired(Boolean required) { public String toString() { return javaType + " " + getName(); } + + public static Parameter header(@NonNull String name, @Nullable String value) { + return basic(name, "header", value); + } + + public static Parameter cookie(@NonNull String name, @Nullable String value) { + return basic(name, "cookie", value); + } + + public static Parameter basic(@NonNull String name, @NonNull String in, @Nullable String value) { + ParameterExt param = new ParameterExt(); + param.setName(name); + param.setIn(in); + param.setDefaultValue(value); + param.setSchema(new StringSchema()); + param.setJavaType(String.class.getName()); + return param; + } } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java index fb1e47cfae..ecaa165cbb 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java @@ -30,20 +30,7 @@ import java.time.OffsetDateTime; import java.time.Period; import java.time.ZonedDateTime; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Currency; -import java.util.Date; -import java.util.EnumSet; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; +import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.function.Function; @@ -75,20 +62,9 @@ import io.jooby.openapi.DebugOption; import io.swagger.v3.core.util.RefUtils; import io.swagger.v3.oas.models.SpecVersion; -import io.swagger.v3.oas.models.media.ArraySchema; -import io.swagger.v3.oas.models.media.BinarySchema; -import io.swagger.v3.oas.models.media.BooleanSchema; -import io.swagger.v3.oas.models.media.ByteArraySchema; -import io.swagger.v3.oas.models.media.DateSchema; -import io.swagger.v3.oas.models.media.DateTimeSchema; -import io.swagger.v3.oas.models.media.FileSchema; -import io.swagger.v3.oas.models.media.IntegerSchema; -import io.swagger.v3.oas.models.media.MapSchema; -import io.swagger.v3.oas.models.media.NumberSchema; -import io.swagger.v3.oas.models.media.ObjectSchema; -import io.swagger.v3.oas.models.media.Schema; -import io.swagger.v3.oas.models.media.StringSchema; -import io.swagger.v3.oas.models.media.UUIDSchema; +import io.swagger.v3.oas.models.media.*; +import jakarta.data.page.Page; +import jakarta.data.page.PageRequest; public class ParserContext { @@ -147,7 +123,7 @@ private ParserContext( } private void jacksonModules(ClassLoader classLoader, List mappers) { - /** Kotlin module? */ + /* Kotlin module? */ List modules = new ArrayList<>(2); try { var kotlinModuleClass = @@ -162,7 +138,7 @@ private void jacksonModules(ClassLoader classLoader, List mappers) | InvocationTargetException x) { // Sshhhhh } - /** Ignore some conflictive setter in Jooby API: */ + /* Ignore some conflictive setter in Jooby API: */ modules.add( new SimpleModule("jooby-openapi") { @Override @@ -171,13 +147,14 @@ public void setupModule(SetupContext context) { context.insertAnnotationIntrospector(new ConflictiveSetter()); } }); - /** Java8/Optional: */ + /* Java8/Optional: */ modules.add(new Jdk8Module()); modules.forEach(module -> mappers.forEach(mapper -> mapper.registerModule(module))); - /** Set class loader: */ - mappers.stream() - .forEach( - mapper -> mapper.setTypeFactory(mapper.getTypeFactory().withClassLoader(classLoader))); + /* Set class loader: */ + mappers.forEach( + mapper -> mapper.setTypeFactory(mapper.getTypeFactory().withClassLoader(classLoader))); + /* Mixin */ + mappers.forEach(MixinHook::mixin); } public Collection schemas() { @@ -280,7 +257,7 @@ public Schema schema(Class type) { return new ObjectSchema(); } if (type.isEnum()) { - StringSchema schema = new StringSchema(); + var schema = new EnumSchema(); EnumSet.allOf(type).forEach(e -> schema.addEnumItem(((Enum) e).name())); return schema; } @@ -330,36 +307,57 @@ private void document(Class typeName, Schema schema, ResolvedSchemaExt resolvedS .ifPresent( javadoc -> { Optional.ofNullable(javadoc.getText()).ifPresent(schema::setDescription); + // make a copy Map properties = schema.getProperties(); if (properties != null) { - properties.forEach( - (key, value) -> { - var text = javadoc.getPropertyDoc(key); - var propertyType = getPropertyType(typeName, key); - var isEnum = - propertyType != null - && propertyType.isEnum() - && resolvedSchema.referencedSchemasByType.keySet().stream() - .map(this::toClass) - .anyMatch(it -> !it.equals(propertyType)); - if (isEnum) { - javadocParser - .parse(propertyType.getName()) - .ifPresent( - enumDoc -> { - var enumDesc = enumDoc.getEnumDescription(text); - if (enumDesc != null) { - value.setDescription(enumDesc); - } - }); - } else { - value.setDescription(text); - var example = javadoc.getPropertyExample(key); - if (example != null) { - value.setExample(example); - } - } - }); + new LinkedHashMap<>(properties) + .forEach( + (key, value) -> { + var text = javadoc.getPropertyDoc(key); + var propertyType = getPropertyType(typeName, key); + var isEnum = + propertyType != null + && propertyType.isEnum() + && resolvedSchema.referencedSchemasByType.keySet().stream() + .map(this::toClass) + .anyMatch(it -> !it.equals(propertyType)); + if (isEnum) { + javadocParser + .parse(propertyType.getName()) + .ifPresent( + enumDoc -> { + var enumDesc = enumDoc.getEnumDescription(text); + if (enumDesc != null) { + EnumSchema enumSchema; + if (!(value instanceof EnumSchema)) { + enumSchema = new EnumSchema(); + value.getEnum().stream() + .forEach( + enumValue -> + enumSchema.addEnumItemObject( + enumValue.toString())); + properties.put(key, enumSchema); + } else { + enumSchema = (EnumSchema) value; + } + for (var field : enumSchema.getEnum()) { + var enumItemDesc = enumDoc.getEnumItemDescription(field); + if (enumItemDesc != null) { + enumSchema.setDescription(field, enumItemDesc); + } + } + enumSchema.setSummary(enumDoc.getSummary()); + enumSchema.setDescription(enumDesc); + } + }); + } else { + value.setDescription(text); + var example = javadoc.getPropertyExample(key); + if (example != null) { + value.setExample(example); + } + } + }); } }); } @@ -403,12 +401,8 @@ public Optional schemaRef(String type) { } public Schema schema(Type type) { - if (isArray(type)) { - // For array we need internal name :S - return schema(type.getInternalName()); - } else { - return schema(type.getClassName()); - } + // For array we need internal name :S + return schema(isArray(type) ? type.getInternalName() : type.getClassName()); } private boolean isArray(Type type) { @@ -452,6 +446,27 @@ private Schema schema(JavaType type) { MapSchema mapSchema = new MapSchema(); mapSchema.setAdditionalProperties(schema(type.getContentType())); return mapSchema; + } else if (type.getRawClass() == Page.class) { + // must be embedded it mimics a List. This is bc it might have a different item type + // per operation. + var pageSchema = converters.read(type.getRawClass()).get("Page"); + + var pageRequestSchema = converters.read(PageRequest.class).get("PageRequest"); + pageSchema.getProperties().put("pageRequest", pageRequestSchema); + pageSchema.getProperties().put("nextPageRequest", pageRequestSchema); + pageSchema.getProperties().put("previousPageRequest", pageRequestSchema); + + var params = type.getBindings().getTypeParameters(); + Schema element; + if (params != null && !params.isEmpty()) { + element = schema(params.getFirst()); + Schema contentSchema = (Schema) pageSchema.getProperties().get("content"); + contentSchema.setItems(element); + } else { + element = new Schema<>(); + element.setType("object"); + } + return ArrayLikeSchema.create(pageSchema, element); } return schema(type.getRawClass()); } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RouteParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RouteParser.java index e66514b93e..bfc05fb7f3 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RouteParser.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/RouteParser.java @@ -63,7 +63,6 @@ public List parse(ParserContext ctx, OpenAPIExt openapi) { String applicationName = Optional.ofNullable(ctx.getMainClass()).orElse(ctx.getRouter().getClassName()); ClassNode application = ctx.classNode(Type.getObjectType(applicationName.replace(".", "/"))); - // JavaDoc addJavaDoc(ctx, ctx.getRouter().getClassName(), "", operations); @@ -75,7 +74,7 @@ public List parse(ParserContext ctx, OpenAPIExt openapi) { List result = new ArrayList<>(); for (OperationExt operation : operations) { - List patterns = Router.expandOptionalVariables(operation.getPattern()); + List patterns = Router.expandOptionalVariables(operation.getPath()); if (patterns.size() == 1) { result.add(operation); } else { @@ -112,8 +111,7 @@ private static void addJavaDoc( if (operation.getController() == null) { javaDoc .flatMap( - doc -> - doc.getScript(operation.getMethod(), operation.getPattern().substring(offset))) + doc -> doc.getScript(operation.getMethod(), operation.getPath().substring(offset))) .ifPresent( scriptDoc -> { if (scriptDoc.getPath() != null) { @@ -164,12 +162,11 @@ private void checkRequestBody(ParserContext ctx, OperationExt operation) { if (requestBody != null) { if (requestBody.getContent() == null) { // default content - io.swagger.v3.oas.models.media.MediaType mediaType = - new io.swagger.v3.oas.models.media.MediaType(); + var mediaType = new io.swagger.v3.oas.models.media.MediaType(); mediaType.setSchema(ctx.schema(requestBody.getJavaType())); String mediaTypeName = operation.getConsumes().stream().findFirst().orElseGet(requestBody::getContentType); - Content content = new Content(); + var content = new Content(); content.addMediaType(mediaTypeName, mediaType); requestBody.setContent(content); } @@ -250,8 +247,7 @@ private void uniqueOperationId(List operations) { private String operationId(OperationExt operation) { return Optional.ofNullable(operation.getOperationId()) .orElseGet( - () -> - operation.getMethod().toLowerCase() + patternToOperationId(operation.getPattern())); + () -> operation.getMethod().toLowerCase() + patternToOperationId(operation.getPath())); } private String patternToOperationId(String pattern) { diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java new file mode 100644 index 0000000000..c599382a7d --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AsciiDocContext.java @@ -0,0 +1,521 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import static io.swagger.v3.oas.models.Components.COMPONENTS_SCHEMAS_REF; +import static java.util.Optional.ofNullable; + +import java.io.IOException; +import java.io.StringWriter; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Instant; +import java.util.*; +import java.util.function.BiConsumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.asciidoctor.Asciidoctor; +import org.asciidoctor.Options; +import org.asciidoctor.SafeMode; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; +import io.jooby.SneakyThrows; +import io.jooby.StatusCode; +import io.jooby.internal.openapi.OpenAPIExt; +import io.pebbletemplates.pebble.PebbleEngine; +import io.pebbletemplates.pebble.error.PebbleException; +import io.pebbletemplates.pebble.extension.AbstractExtension; +import io.pebbletemplates.pebble.extension.Filter; +import io.pebbletemplates.pebble.extension.Function; +import io.pebbletemplates.pebble.lexer.Syntax; +import io.pebbletemplates.pebble.loader.ClasspathLoader; +import io.pebbletemplates.pebble.loader.DelegatingLoader; +import io.pebbletemplates.pebble.loader.FileLoader; +import io.pebbletemplates.pebble.loader.Loader; +import io.pebbletemplates.pebble.template.EvaluationContext; +import io.pebbletemplates.pebble.template.PebbleTemplate; +import io.swagger.v3.oas.models.media.BooleanSchema; +import io.swagger.v3.oas.models.media.NumberSchema; +import io.swagger.v3.oas.models.media.Schema; + +public class AsciiDocContext { + public static final BiConsumer> NOOP = (name, schema) -> {}; + + private ObjectMapper json; + + private ObjectMapper yamlOpenApi; + + private ObjectMapper yamlOutput; + + private PebbleEngine engine; + + private OpenAPIExt openapi; + + private final AutoDataFakerMapper faker = new AutoDataFakerMapper(); + + private final Map, Map> examples = new HashMap<>(); + + private final Instant now = Instant.now(); + + static { + // type vs types difference in v30 vs v31 + System.setProperty(Schema.BIND_TYPE_AND_TYPES, Boolean.TRUE.toString()); + } + + public AsciiDocContext(Path baseDir, ObjectMapper json, ObjectMapper yaml, OpenAPIExt openapi) { + this.json = json; + this.yamlOpenApi = yaml; + this.yamlOutput = newYamlOutput(); + this.openapi = openapi; + this.engine = createEngine(baseDir, json, this); + } + + public String generate(Path index) throws IOException { + var template = engine.getTemplate(index.getFileName().toString()); + var writer = new StringWriter(); + var context = new HashMap(); + template.evaluate(writer, context); + return writer.toString().trim(); + } + + public void export(Path input, Path outputDir) { + try (var asciidoctor = Asciidoctor.Factory.create()) { + + var options = + Options.builder() + .backend("html5") + .baseDir(input.getParent().toFile()) + .toDir(outputDir.toFile()) + .mkDirs(true) + .safe(SafeMode.UNSAFE) + .build(); + + // Perform the conversion + asciidoctor.convertFile(input.toFile(), options); + } + } + + public Instant getNow() { + return now; + } + + private ObjectMapper newYamlOutput() { + var factory = new YAMLFactory(); + factory.enable(YAMLGenerator.Feature.MINIMIZE_QUOTES); + factory.disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER); + return new ObjectMapper(factory); + } + + private static PebbleEngine createEngine( + Path baseDir, ObjectMapper json, AsciiDocContext context) { + List> loaders = + List.of(new FileLoader(baseDir.toAbsolutePath().toString()), new ClasspathLoader()); + return new PebbleEngine.Builder() + .autoEscaping(false) + .loader(new DelegatingLoader(loaders)) + .syntax(new Syntax.Builder().setEnableNewLineTrimming(false).build()) + .extension( + new AbstractExtension() { + @Override + public Map getGlobalVariables() { + Map openapiRoot = json.convertValue(context.openapi, Map.class); + openapiRoot.put("openapi", context.openapi); + openapiRoot.put("now", context.now); + + // Global/Default values: + openapiRoot.put( + "error", + Map.of( + "statusCode", + "{{statusCode.code}}", + "reason", + "{{statusCode.reason}}", + "message", + "...")); + // Routes + var operations = + new HttpRequestList( + context, + Optional.of(context.openapi.getOperations()).orElse(List.of()).stream() + .map(op -> new HttpRequest(context, op, Map.of())) + .toList()); + // so we can print routes without calling function: routes() vs routes + openapiRoot.put("routes", operations); + openapiRoot.put("operations", operations); + + // Tags + var tags = + Optional.ofNullable(context.openapi.getTags()).orElse(List.of()).stream() + .map( + tag -> + new TagExt( + tag, + context.openapi.findOperationByTag(tag.getName()).stream() + .map(op -> new HttpRequest(context, op, Map.of())) + .toList())) + .toList(); + openapiRoot.put("tags", tags); + // Schemas + var components = context.openapi.getComponents(); + if (components != null && components.getSchemas() != null) { + var schemas = components.getSchemas(); + openapiRoot.put("schemas", new ArrayList<>(schemas.values())); + } + + // make in to work without literal + openapiRoot.put("query", "query"); + openapiRoot.put("path", "path"); + openapiRoot.put("header", "header"); + openapiRoot.put("cookie", "cookie"); + + openapiRoot.put("_asciidocContext", context); + return openapiRoot; + } + + @Override + public Map getFunctions() { + return Stream.of(Lookup.values()) + .flatMap(it -> it.alias().stream().map(name -> Map.entry(name, it))) + .collect(Collectors.toMap(Map.Entry::getKey, it -> wrapFn(it.getValue()))); + } + + private static Function wrapFn(Lookup lookup) { + return new Function() { + @Override + public List getArgumentNames() { + return lookup.getArgumentNames(); + } + + @Override + public Object execute( + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) { + try { + return lookup.execute(args, self, context, lineNumber); + } catch (PebbleException rethrow) { + throw rethrow; + } catch (Throwable cause) { + var path = Paths.get(self.getName()); + throw new PebbleException( + cause, + "execution of `" + lookup.name() + "()` resulted in exception:", + lineNumber, + path.getFileName().toString().trim()); + } + } + }; + } + + private static Filter wrapFilter(String filterName, Filter filter) { + return new Filter() { + @Override + public List getArgumentNames() { + return filter.getArgumentNames(); + } + + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + try { + return filter.apply(input, args, self, context, lineNumber); + } catch (PebbleException rethrow) { + throw rethrow; + } catch (Throwable cause) { + var path = Paths.get(self.getName()); + throw new PebbleException( + cause, + "execution of `" + filterName + "()` resulted in exception:", + lineNumber, + path.getFileName().toString().trim()); + } + } + }; + } + + @Override + public Map getFilters() { + return Stream.concat(Stream.of(Mutator.values()), Stream.of(Display.values())) + .collect(Collectors.toMap(Enum::name, it -> wrapFilter(it.name(), it))); + } + }) + .build(); + } + + @SuppressWarnings("unchecked") + public Map error(EvaluationContext context, Map args) { + var error = context.getVariable("error"); + if (error instanceof Map errorMap) { + var mutableMap = new TreeMap((Map) errorMap); + args.forEach( + (key, value) -> { + if (mutableMap.containsKey(key)) { + mutableMap.put(key, value); + } + }); + var statusCode = + StatusCode.valueOf( + ((Number) + args.getOrDefault( + "code", args.getOrDefault("statusCode", StatusCode.SERVER_ERROR.value()))) + .intValue()); + for (var entry : errorMap.entrySet()) { + var value = entry.getValue(); + var template = String.valueOf(value); + if (template.startsWith("{{") && template.endsWith("}}")) { + var variable = template.substring(2, template.length() - 2).trim(); + value = + switch (variable) { + case "status.reason", + "statusCodeReason", + "statusCode.reason", + "code.reason", + "codeReason", + "reason" -> + statusCode.reason(); + case "status.code", "statusCode.code", "statusCode", "code" -> statusCode.value(); + default -> + Optional.ofNullable(args.getOrDefault(variable, context.getVariable(variable))) + .orElse(template); + }; + mutableMap.put((String) entry.getKey(), value); + } + } + return mutableMap; + } + throw new ClassCastException("Global error must be a map: " + error); + } + + public String schemaType(Schema schema) { + var resolved = resolveSchema(schema); + return Optional.ofNullable(resolved.getFormat()).orElse(resolved.getType()); + } + + public Schema resolveSchema(Schema schema) { + if (schema.get$ref() != null) { + return resolveSchemaInternal(schema.get$ref()) + .orElseThrow(() -> new NoSuchElementException("Schema not found: " + schema.get$ref())); + } + return schema; + } + + public Object schemaProperties(Schema schema) { + var resolved = resolveSchema(schema); + if ("array".equals(resolved.getType())) { + var items = resolveSchema(resolved.getItems()); + if (items.getName() == null) { + return List.of(basicTypeSample(items)); + } + return List.of(traverse(resolved.getItems(), NOOP)); + } + if (resolved.getName() == null) { + return basicTypeSample(resolved); + } + return traverse(schema, NOOP); + } + + private Object basicTypeSample(Schema items) { + return switch (items) { + case NumberSchema s -> 0; + case BooleanSchema s -> true; + default -> schemaType(items); + }; + } + + @SuppressWarnings("rawtypes") + public Schema reduceSchema(Schema schema) { + var truncated = emptySchema(schema); + var properties = new LinkedHashMap(); + traverse( + schema, + (name, value) -> { + var type = value.getType(); + if ("object".equals(type)) { + var object = new Schema<>(); + object.setType(type); + properties.put(name, object); + } else if ("array".equals(type)) { + var array = new Schema<>(); + array.setType(type); + array.setItems(new Schema<>()); + properties.put(name, array); + } else { + properties.put(name, value); + } + }); + truncated.setProperties(properties); + return truncated; + } + + public Schema emptySchema(Schema schema) { + var resolved = resolveSchema(schema); + var empty = new Schema<>(); + empty.setType(resolved.getType()); + empty.setName(resolved.getName()); + empty.setTypes(resolved.getTypes()); + return empty; + } + + public Object schemaExample(Schema schema) { + var resolved = resolveSchema(schema); + var target = resolved; + if ("array".equals(resolved.getType())) { + target = resolveSchema(resolved.getItems()); + } + var result = + examples.computeIfAbsent( + target, + key -> + traverse( + new HashSet<>(), + key, + (parent, property) -> { + var enumItems = property.getEnum(); + if (enumItems == null || enumItems.isEmpty()) { + var type = schemaType(property); + var gen = + faker.getGenerator(parent.getName(), property.getName(), type, type); + return gen.get(); + } else { + return enumItems.get(new Random().nextInt(enumItems.size())).toString(); + } + }, + NOOP)); + return "array".equals(resolved.getType()) ? List.of(result) : result; + } + + public void traverseSchema(Schema schema, BiConsumer> consumer) { + traverse(schema, consumer); + } + + private Map traverse(Schema schema, BiConsumer> consumer) { + return traverse(new HashSet<>(), schema, (parent, property) -> schemaType(property), consumer); + } + + private Map traverse( + Set visited, + Schema schema, + SneakyThrows.Function2, Schema, String> valueMapper, + BiConsumer> consumer) { + if (schema == null) { + return Map.of(); + } + var resolved = resolveSchema(schema); + if (visited.add(resolved)) { + var properties = resolved.getProperties(); + if (properties != null) { + Map result = new LinkedHashMap<>(); + properties.forEach( + (name, value) -> { + var resolvedValue = resolveSchema(value); + var valueType = resolvedValue.getType(); + consumer.accept(name, resolvedValue); + if ("object".equals(valueType)) { + result.put(name, traverse(visited, resolvedValue, valueMapper, NOOP)); + } else if ("array".equals(valueType)) { + var array = + ofNullable(resolvedValue.getItems()) + .map(items -> traverse(visited, resolveSchema(items), valueMapper, NOOP)) + .map(List::of) + .orElse(List.of()); + result.put(name, array); + } else { + result.put(name, valueMapper.apply(resolved, resolvedValue)); + } + }); + return result; + } + } + return Map.of(); + } + + public Schema resolveSchema(String path) { + var segments = path.split("\\."); + var schema = + resolveSchemaInternal(segments[0]) + .orElseThrow(() -> new NoSuchElementException("Schema not found: " + path)); + + for (int i = 1; i < segments.length; i++) { + Schema inner = (Schema) schema.getProperties().get(segments[i]); + if (inner == null) { + throw new IllegalArgumentException( + "Property not found: " + Stream.of(segments).limit(i).collect(Collectors.joining("."))); + } + if (inner.get$ref() != null) { + inner = + resolveSchemaInternal(inner.get$ref()) + .orElseThrow(() -> new NoSuchElementException("Schema not found: " + path)); + } + schema = inner; + } + return schema; + } + + private Optional> resolveSchemaInternal(String name) { + var components = openapi.getComponents(); + if (components == null || components.getSchemas() == null) { + throw new NoSuchElementException("No schema found"); + } + if (name.startsWith(COMPONENTS_SCHEMAS_REF)) { + name = name.substring(COMPONENTS_SCHEMAS_REF.length()); + } + return Optional.ofNullable((Schema) components.getSchemas().get(name)); + } + + public PebbleEngine getEngine() { + return engine; + } + + public String toJson(Object input, boolean pretty) { + try { + var writer = pretty ? json.writer().withDefaultPrettyPrinter() : json.writer(); + return writer.writeValueAsString(input); + } catch (JsonProcessingException e) { + throw SneakyThrows.propagate(e); + } + } + + public String toYaml(Object input) { + try { + return cleanYaml( + input instanceof Map + ? yamlOutput.writeValueAsString(input) + : yamlOpenApi.writeValueAsString(input)); + } catch (JsonProcessingException e) { + throw SneakyThrows.propagate(e); + } + } + + private String cleanYaml(String value) { + return value.trim(); + } + + public ObjectMapper getJson() { + return json; + } + + public ObjectMapper getYaml() { + return yamlOpenApi; + } + + public OpenAPIExt getOpenApi() { + return openapi; + } + + public static AsciiDocContext from(EvaluationContext context) { + return (AsciiDocContext) context.getVariable("_asciidocContext"); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AutoDataFakerMapper.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AutoDataFakerMapper.java new file mode 100644 index 0000000000..db347b2ad0 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/AutoDataFakerMapper.java @@ -0,0 +1,340 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.*; +import java.util.function.Supplier; + +import net.datafaker.Faker; +import net.datafaker.providers.base.AbstractProvider; + +public class AutoDataFakerMapper { + + private final Faker faker; + + // --- REGISTRIES (Functional) --- + private final Map> specificRegistry = new HashMap<>(); + private final Map> genericRegistry = new HashMap<>(); + private final Map> typeRegistry = new HashMap<>(); + + // --- SYNONYMS --- + private final Map synonymMap; + private static final Map DEFAULT_SYNONYMS = new HashMap<>(); + + static { + registerDefault("surname", "lastname"); + registerDefault("familyname", "lastname"); + registerDefault("login", "username"); + registerDefault("user", "username"); + registerDefault("fullname", "name"); + registerDefault("displayname", "name"); + registerDefault("social", "ssnvalid"); + registerDefault("ssn", "ssnvalid"); + registerDefault("mail", "emailaddress"); + registerDefault("email", "emailaddress"); + registerDefault("subject", "emailsubject"); + registerDefault("web", "url"); + registerDefault("homepage", "url"); + registerDefault("link", "url"); + registerDefault("uri", "url"); + registerDefault("avatar", "image"); + registerDefault("pic", "image"); + registerDefault("pwd", "password"); + registerDefault("pass", "password"); + registerDefault("cell", "cellphone"); + registerDefault("mobile", "cellphone"); + registerDefault("tel", "phonenumber"); + registerDefault("fax", "phonenumber"); + registerDefault("addr", "fulladdress"); + registerDefault("street", "streetaddress"); + registerDefault("postcode", "zipcode"); + registerDefault("postal", "zipcode"); + registerDefault("zip", "zipcode"); + registerDefault("town", "city"); + registerDefault("province", "state"); + registerDefault("region", "state"); + registerDefault("lat", "latitude"); + registerDefault("lon", "longitude"); + registerDefault("lng", "longitude"); + registerDefault("qty", "quantity"); + registerDefault("cost", "price"); + registerDefault("amount", "price"); + registerDefault("desc", "sentence"); + registerDefault("description", "paragraph"); + registerDefault("dept", "industry"); + registerDefault("role", "title"); + registerDefault("position", "title"); + registerDefault("dob", "birthday"); + registerDefault("born", "birthday"); + registerDefault("created", "date"); + registerDefault("modified", "date"); + registerDefault("timestamp", "date"); + registerDefault("guid", "uuid"); + } + + private static void registerDefault(String key, String value) { + DEFAULT_SYNONYMS.put(key, value); + } + + // --- CONSTRUCTORS --- + + public AutoDataFakerMapper() { + this.faker = new Faker(); + this.synonymMap = new HashMap<>(DEFAULT_SYNONYMS); + + initializeReflectionRegistry(); + initializeTypeRegistry(); + } + + public void synonyms(Map synonyms) { + synonyms.forEach((k, v) -> this.synonymMap.put(normalize(k), normalize(v))); + } + + private void initializeReflectionRegistry() { + Arrays.stream(Faker.class.getMethods()) + .filter(this::isProviderMethod) + .forEach(this::registerProvider); + } + + private void registerType(String type, Supplier supplier, String description) { + String cleanType = normalize(type); + typeRegistry.put(cleanType, fakeSupplier(supplier, description)); + } + + private static Supplier fakeSupplier(Supplier supplier, String signature) { + return new Supplier<>() { + @Override + public String get() { + return supplier.get(); + } + + @Override + public String toString() { + return signature; + } + }; + } + + private void initializeTypeRegistry() { + // domains + specificRegistry.put( + "book.isbn", fakeSupplier(() -> faker.code().isbn13(), "faker.code().isbn13()")); + + // We now register the Description alongside the Supplier + registerType("uuid", () -> faker.internet().uuid(), "faker.internet().uuid()"); + registerType("email", () -> faker.internet().emailAddress(), "faker.internet().emailAddress()"); + registerType( + "password", () -> faker.credentials().password(), "faker.credentials().password()"); + registerType("ipv4", () -> faker.internet().ipV4Address(), "faker.internet().ipV4Address()"); + registerType("ipv6", () -> faker.internet().ipV6Address(), "faker.internet().ipV6Address()"); + registerType("uri", () -> faker.internet().url(), "faker.internet().url()"); + registerType("url", () -> faker.internet().url(), "faker.internet().url()"); + registerType("hostname", () -> faker.internet().domainName(), "faker.internet().domainName()"); + + registerType( + "date", () -> faker.timeAndDate().birthday().toString(), "faker.timeAndDate().birthday()"); + registerType( + "datetime", + () -> faker.timeAndDate().past(365, java.util.concurrent.TimeUnit.DAYS).toString(), + "faker.timeAndDate().past()"); + registerType( + "time", + () -> faker.timeAndDate().birthday().toString().split(" ")[1], + "faker.timeAndDate().birthday() (time-part)"); + + registerType( + "integer", + () -> String.valueOf(faker.number().numberBetween(1, 100)), + "faker.number().numberBetween(1, 100)"); + registerType( + "int32", + () -> String.valueOf(faker.number().numberBetween(1, 100)), + "faker.number().numberBetween(1, 100)"); + registerType( + "int64", + () -> String.valueOf(faker.number().numberBetween(1, 100)), + "faker.number().numberBetween(1, 100)"); + registerType( + "float", + () -> String.valueOf(faker.number().randomDouble(2, 0, 100)), + "faker.number().randomDouble()"); + registerType( + "double", + () -> String.valueOf(faker.number().randomDouble(4, 0, 100)), + "faker.number().randomDouble()"); + registerType( + "number", + () -> String.valueOf(faker.number().numberBetween(0, 100)), + "faker.number().numberBetween(1, 100)"); + + registerType("boolean", () -> String.valueOf(faker.bool().bool()), "faker.bool().bool()"); + + registerType("string", () -> "string", "string"); + } + + private void registerProvider(Method providerMethod) { + try { + Object providerInstance = providerMethod.invoke(faker); + String domainName = normalize(providerMethod.getName()); + + Arrays.stream(providerInstance.getClass().getMethods()) + .filter(this::isValidGeneratorMethod) + .forEach(method -> registerMethod(domainName, providerInstance, method)); + + } catch (Exception ignored) { + } + } + + private void registerMethod(String domainName, Object providerInstance, Method method) { + String fieldName = normalize(method.getName()); + String signature = "faker.%s().%s()".formatted(domainName, method.getName()); + + Supplier rawGenerator = fakerSupplier(providerInstance, method, signature); + + // 1. Specific Registry + specificRegistry.put(domainName + "." + fieldName, rawGenerator); + + // add base generic only + if (method.getDeclaringClass().getPackage().equals(AbstractProvider.class.getPackage())) { + // 2. Generic Registry (First one wins) + genericRegistry.putIfAbsent(fieldName, rawGenerator); + } + } + + // --- CORE LOGIC (Unchanged) --- + public Supplier getGenerator( + String className, String fieldName, String fieldType, String defaultValue) { + var cleanClass = normalize(className); + var cleanField = normalize(fieldName); + var cleanType = normalize(fieldType); + + String resolvedField = synonymMap.getOrDefault(cleanField, cleanField); + + var specific = specificRegistry.get(cleanClass + "." + resolvedField); + if (specific != null) return wrap(specific, defaultValue); + + var generic = genericRegistry.get(resolvedField); + if (generic != null) return wrap(generic, defaultValue); + + var fuzzy = + genericRegistry.entrySet().stream() + .filter(entry -> resolvedField.contains(entry.getKey()) && entry.getKey().length() > 3) + .map(Map.Entry::getValue) + .findFirst() + .orElse(null); + + if (fuzzy != null) return wrap(fuzzy, defaultValue); + + if (!cleanType.isEmpty()) { + var typeGen = typeRegistry.get(cleanType); + if (typeGen != null) return wrap(typeGen, defaultValue); + } + + return () -> defaultValue; + } + + // --- CAPABILITY MAP (IMPROVED) --- + + /** Returns a structured map of all available capabilities with Source details. */ + public Map getCapabilityMap() { + // 1. Domains Tree: "book" -> { "title": "faker.book().title()" } + Map> domainsTree = new TreeMap<>(); + specificRegistry.forEach( + (key, signature) -> { + int dotIndex = key.indexOf('.'); + if (dotIndex > 0) { + String domain = key.substring(0, dotIndex); + String field = key.substring(dotIndex + 1); + domainsTree + .computeIfAbsent(domain, k -> new TreeMap<>()) + .put(field, signature.toString()); + } + }); + + // 2. Generics Map: "title" -> "faker.book().title()" + // (Using TreeMap for sorting) + Map genericsMap = new TreeMap<>(); + genericRegistry.forEach( + (key, signature) -> { + genericsMap.put(key, signature.toString()); + }); + + // 3. Types Map: "uuid" -> "faker.internet().uuid()" + Map typesMap = new TreeMap<>(); + typeRegistry.forEach( + (key, signature) -> { + typesMap.put(key, signature.toString()); + }); + + // 4. Synonyms Copy + Map synonymsCopy = new TreeMap<>(synonymMap); + + return Map.of( + "domains", domainsTree, + "generics", genericsMap, + "types", typesMap, + "synonyms", synonymsCopy); + } + + private static Supplier fakerSupplier(Object instance, Method method, String signature) { + return new Supplier<>() { + @Override + public String get() { + try { + return (String) method.invoke(instance); + } catch (Exception ignored) { + return null; + } + } + + @Override + public String toString() { + return signature; + } + }; + } + + private static Supplier wrap(Supplier supplier, String defaultValue) { + return new Supplier<>() { + @Override + public String get() { + try { + String res = supplier.get(); + return res != null ? res : defaultValue; + } catch (Exception e) { + return defaultValue; + } + } + + @Override + public String toString() { + return supplier.toString(); + } + }; + } + + private boolean isProviderMethod(Method m) { + return m.getParameterCount() == 0 && AbstractProvider.class.isAssignableFrom(m.getReturnType()); + } + + private boolean isValidGeneratorMethod(Method m) { + return Modifier.isPublic(m.getModifiers()) + && m.getParameterCount() == 0 + && m.getReturnType().equals(String.class) + && !isStandardMethod(m.getName()); + } + + private boolean isStandardMethod(String name) { + return "toString".equals(name); + } + + private String normalize(String input) { + if (input == null || input.isBlank()) return ""; + return input.toLowerCase().trim().replaceAll("[^a-z0-9]", ""); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.java new file mode 100644 index 0000000000..d47f7c0e7e --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Display.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.asciidoc; + +import java.util.*; + +import io.jooby.internal.openapi.OperationExt; +import io.jooby.internal.openapi.asciidoc.display.*; +import io.pebbletemplates.pebble.error.PebbleException; +import io.pebbletemplates.pebble.extension.Filter; +import io.pebbletemplates.pebble.extension.escaper.SafeString; +import io.pebbletemplates.pebble.template.EvaluationContext; +import io.pebbletemplates.pebble.template.PebbleTemplate; +import io.swagger.v3.oas.models.media.Schema; + +public enum Display implements Filter { + json { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + var asciidoc = AsciiDocContext.from(context); + var pretty = args.getOrDefault("pretty", true) == Boolean.TRUE; + return wrap( + asciidoc.toJson(toJson(asciidoc, input), pretty), + args.getOrDefault("wrap", Boolean.TRUE) == Boolean.TRUE, + "[source, json]\n----\n", + "\n----"); + } + + @Override + public List getArgumentNames() { + return List.of("wrap"); + } + }, + yaml { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + var asciidoc = AsciiDocContext.from(context); + return wrap( + asciidoc.toYaml(toJson(asciidoc, input)), + args.getOrDefault("wrap", Boolean.TRUE) == Boolean.TRUE, + "[source, yaml]\n----\n", + "\n----"); + } + + @Override + public List getArgumentNames() { + return List.of("wrap"); + } + }, + table { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + var asciidoc = AsciiDocContext.from(context); + return new SafeString(toAsciidoc(asciidoc, input).table(new TreeMap<>(args))); + } + }, + list { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + var asciidoc = AsciiDocContext.from(context); + return new SafeString(toAsciidoc(asciidoc, input).list(new TreeMap<>(args))); + } + }, + link { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + var schema = + switch (input) { + case Schema s -> s; + case HttpMessage msg -> msg.getBody(); + default -> throw new IllegalArgumentException("Can't render: " + input); + }; + var asciidoc = AsciiDocContext.from(context); + var resolved = asciidoc.resolveSchema(schema); + if (resolved.getItems() == null) { + if (resolved.getName() == null) { + return resolved.getType(); + } + return new SafeString("<<" + resolved.getName() + ">>"); + } else { + var item = asciidoc.resolveSchema(resolved.getItems()); + if (item.getName() == null) { + // primitives + return new SafeString(item.getType() + "[]"); + } else { + if ("array".equals(resolved.getType())) { + return new SafeString("<<" + item.getName() + ">>[]"); + } + return new SafeString(resolved.getName() + "[<<" + item.getName() + ">>]"); + } + } + } + }, + curl { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + var asciidoc = AsciiDocContext.from(context); + var curl = + switch (input) { + case OperationExt op -> + new RequestToCurl(asciidoc, new HttpRequest(asciidoc, op, args)); + case HttpRequest req -> new RequestToCurl(asciidoc, req); + default -> throw new IllegalArgumentException("Can't render: " + input); + }; + return curl.render(args); + } + }, + path { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + var asciidoc = AsciiDocContext.from(context); + var request = + switch (input) { + case OperationExt op -> new HttpRequest(asciidoc, op, args); + case HttpRequest req -> req; + default -> throw new IllegalArgumentException("Can't render: " + input); + }; + var pathParams = new HashMap(); + request + .getParameters(List.of("path"), List.of()) + .forEach( + p -> { + pathParams.put( + p.getName(), args.getOrDefault(p.getName(), "{" + p.getName() + "}")); + }); + // QueryString + pathParams.keySet().forEach(args::remove); + var queryString = request.getQueryString(args); + return request.operation().getPath(pathParams) + queryString; + } + }, + http { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + var asciidoc = AsciiDocContext.from(context); + return toHttp(asciidoc, input, args).render(args); + } + + private ToSnippet toHttp(AsciiDocContext context, Object input, Map options) { + return switch (input) { + case OperationExt op -> new RequestToHttp(context, new HttpRequest(context, op, options)); + case HttpRequest req -> new RequestToHttp(context, req); + case HttpResponse rsp -> new ResponseToHttp(context, rsp); + default -> throw new IllegalArgumentException("Can't render: " + input); + }; + } + }; + + protected ToAsciiDoc toAsciidoc(AsciiDocContext context, Object input) { + return switch (input) { + case HttpRequest req -> OpenApiToAsciiDoc.parameters(context, req.getAllParameters()); + case HttpResponse rsp -> OpenApiToAsciiDoc.schema(context, rsp.getBody()); + case Schema schema -> OpenApiToAsciiDoc.schema(context, schema); + case ParameterList paramList -> OpenApiToAsciiDoc.parameters(context, paramList); + case ToAsciiDoc asciiDoc -> asciiDoc; + case Map map -> new MapToAsciiDoc(List.of(map)); + default -> throw new IllegalArgumentException("Can't render: " + input); + }; + } + + protected Object toJson(AsciiDocContext context, Object input) { + return switch (input) { + case Schema schema -> context.schemaProperties(schema); + case HttpResponse rsp -> toJson(context, rsp.getSucessOrError()); + case StatusCodeList codeList -> + codeList.codes().size() == 1 ? codeList.codes().getFirst() : codeList.codes(); + default -> input; + }; + } + + protected SafeString wrap(String content, boolean wrap, String prefix, String suffix) { + return new SafeString(wrap ? prefix + content + suffix : content); + } + + @Override + public List getArgumentNames() { + return List.of(); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpMessage.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpMessage.java new file mode 100644 index 0000000000..3fa39d3c08 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpMessage.java @@ -0,0 +1,50 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.List; + +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.Schema; + +public interface HttpMessage { + + ParameterList getHeaders(); + + ParameterList getCookies(); + + Schema getBody(); + + AsciiDocContext context(); + + default Schema selectBody(Schema body, String modifier) { + if (body != null) { + return switch (modifier) { + case "full" -> body; + case "simple" -> context().reduceSchema(body); + default -> context().emptySchema(body); + }; + } + return body; + } + + default Schema toSchema(Content content, List contentType) { + if (content == null || content.isEmpty()) { + return null; + } + if (contentType.isEmpty()) { + // first response + return content.values().iterator().next().getSchema(); + } + for (var key : contentType) { + var mediaType = content.get(key); + if (mediaType != null) { + return mediaType.getSchema(); + } + } + return null; + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequest.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequest.java new file mode 100644 index 0000000000..ac2ce2b8fd --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequest.java @@ -0,0 +1,283 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; +import java.util.function.Predicate; + +import com.fasterxml.jackson.annotation.JsonIncludeProperties; +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.ListMultimap; +import com.google.common.net.UrlEscapers; +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.Router; +import io.jooby.internal.openapi.OperationExt; +import io.jooby.internal.openapi.ParameterExt; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.parameters.Parameter; +import io.swagger.v3.oas.models.security.SecurityRequirement; + +@JsonIncludeProperties({"path", "method"}) +public record HttpRequest( + AsciiDocContext context, OperationExt operation, Map options) + implements HttpMessage { + + private static final Predicate NOOP = p -> true; + + private List allParameters() { + var parameters = new ArrayList<>(getImplicitHeaders()); + parameters.addAll(Optional.ofNullable(operation.getParameters()).orElse(List.of())); + return parameters; + } + + private List getImplicitHeaders() { + var implicitHeaders = new ArrayList(); + operation + .getProduces() + .forEach(value -> implicitHeaders.add(ParameterExt.header("Accept", value))); + if (Set.of(Router.PATCH, Router.PUT, Router.POST, Router.DELETE) + .contains(operation.getMethod())) { + operation + .getConsumes() + .forEach(value -> implicitHeaders.add(ParameterExt.header("Content-Type", value))); + } + return implicitHeaders; + } + + public String getMethod() { + return operation.getMethod(); + } + + public String getPath() { + return operation.getPath(); + } + + public String getDescription() { + return operation.getDescription(); + } + + public String getSummary() { + return operation.getSummary(); + } + + public List getProduces() { + return operation.getProduces(); + } + + public List getConsumes() { + return operation.getConsumes(); + } + + public Map getExtensions() { + return operation.getExtensions(); + } + + @Override + public ParameterList getHeaders() { + return new ParameterList( + allParameters().stream().filter(inFilter("header")).toList(), ParameterList.NAME_DESC); + } + + @Override + public ParameterList getCookies() { + return new ParameterList( + allParameters().stream().filter(inFilter("cookie")).toList(), ParameterList.NAME_DESC); + } + + public ParameterList getQuery() { + return new ParameterList( + allParameters().stream().filter(inFilter("query")).toList(), ParameterList.NAME_TYPE_DESC); + } + + public ParameterList getParameters() { + return getParameterList(NOOP, ParameterList.PARAM); + } + + public ParameterList getParameters(List in, List includes) { + var show = + in.isEmpty() || in.contains("*") + ? ParameterList.PARAM + : (in.size() == 1 && in.contains("cookie") || in.contains("header")) + ? ParameterList.NAME_DESC + : ParameterList.NAME_TYPE_DESC; + return getParameterList(toFilter(in, includes), show); + } + + private Predicate toFilter(List in, List includes) { + Predicate inFilter; + if (in.isEmpty()) { + inFilter = NOOP; + } else { + inFilter = null; + for (var type : in) { + var itFilter = inFilter(type); + if (inFilter == null) { + inFilter = itFilter; + } else { + inFilter = inFilter.or(itFilter); + } + } + } + Predicate paramFilter = NOOP; + if (!includes.isEmpty()) { + paramFilter = p -> includes.contains(p.getName()); + } + return inFilter.and(paramFilter); + } + + public String getQueryString() { + return getQueryString(Map.of()); + } + + public String getQueryString(Map filter) { + var sb = new StringBuilder("?"); + + for (var param : getParameters(List.of("query"), filter.keySet().stream().toList())) { + encode( + param.getName(), + param.getSchema(), + (schema, e) -> + Map.entry( + e.getKey(), + UrlEscapers.urlFragmentEscaper() + .escape(filter.getOrDefault(e.getKey(), e.getValue()).toString())), + (name, value) -> sb.append(name).append("=").append(value).append("&")); + } + if (sb.length() > 1) { + sb.setLength(sb.length() - 1); + return sb.toString(); + } + return ""; + } + + private Schema getBody(List contentType) { + var body = + Optional.ofNullable(operation.getRequestBody()) + .map(it -> toSchema(it.getContent(), contentType)) + .map(context::resolveSchema) + .orElse(null); + + return selectBody(body, options.getOrDefault("body", "full").toString()); + } + + public Schema getForm() { + return getBody(List.of("application/x-www-form-urlencoded)", "multipart/form-data")); + } + + @NonNull public ListMultimap formUrlEncoded( + BiFunction, Map.Entry, Map.Entry> formatter) { + var output = ArrayListMultimap.create(); + var form = getForm(); + if (form != null) { + traverseSchema(null, form, formatter, output::put); + } + return output; + } + + private void traverseSchema( + String path, + Schema schema, + BiFunction, Map.Entry, Map.Entry> formatter, + BiConsumer consumer) { + context.traverseSchema( + schema, + (propertyName, value) -> { + var propertyPath = path == null ? propertyName : path + "." + propertyName; + if (value.getType().equals("object")) { + traverseSchema(propertyPath, value, formatter, consumer); + } else if (value.getType().equals("array")) { + traverseSchema(propertyPath + "[0]", value.getItems(), formatter, consumer); + } else { + encode(propertyPath, value, formatter, consumer); + } + }); + } + + private void encode( + String propertyName, + Schema schema, + BiFunction, Map.Entry, Map.Entry> formatter, + BiConsumer consumer) { + var names = List.of(propertyName); + var index = new AtomicInteger(0); + if (schema.getType().equals("array")) { + schema = schema.getItems(); + // shows 3 examples + names = List.of(propertyName, propertyName, propertyName); + index.set(1); + } + var schemaType = context.schemaType(schema); + if ("binary".equals(schema.getFormat())) { + schemaType = "file"; + } + var value = schemaType + "%1$s"; + for (String name : names) { + var formattedPair = + formatter.apply( + schema, + Map.entry( + name, String.format(value, (index.get() == 0 ? "" : index.getAndIncrement())))); + consumer.accept(formattedPair.getKey(), formattedPair.getValue()); + } + } + + public boolean isDeprecated() { + return operation.getDeprecated() == Boolean.TRUE; + } + + public List getSecurity() { + return operation.getSecurity(); + } + + @Override + public Schema getBody() { + return getBody(List.of()); + } + + public ParameterList getAllParameters() { + var parameters = allParameters(); + var body = getForm(); + var bodyType = "form"; + if (body == null) { + body = getBody(); + bodyType = "body"; + } + var paramType = bodyType; + context.traverseSchema( + body, + (propertyName, schema) -> { + var p = new Parameter(); + p.setName(propertyName); + p.setSchema(schema); + p.setIn(paramType); + p.setDescription(schema.getDescription()); + parameters.add(p); + }); + return new ParameterList(parameters, ParameterList.PARAM); + } + + private ParameterList getParameterList(Predicate predicate, List includes) { + return new ParameterList(getParameters(predicate), includes); + } + + private List getParameters(Predicate predicate) { + return predicate == NOOP + ? allParameters() + : allParameters().stream().filter(predicate).toList(); + } + + private static Predicate inFilter(String in) { + return p -> "*".equals(in) || in.equals(p.getIn()); + } + + @NonNull @Override + public String toString() { + return getMethod() + " " + getPath(); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequestList.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequestList.java new file mode 100644 index 0000000000..565e82a634 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpRequestList.java @@ -0,0 +1,79 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.annotation.JsonIncludeProperties; +import edu.umd.cs.findbugs.annotations.NonNull; + +@JsonIncludeProperties({"operations"}) +public record HttpRequestList(AsciiDocContext context, List operations) + implements Iterable, ToAsciiDoc { + @NonNull @Override + public Iterator iterator() { + return operations.iterator(); + } + + @NonNull @Override + public String toString() { + return operations.toString(); + } + + @Override + public String list(Map options) { + var sb = new StringBuilder(); + operations.forEach( + op -> + sb.append("* `+") + .append(op) + .append("+`") + .append( + Optional.ofNullable(op.getSummary()).map(summary -> ": " + summary).orElse("")) + .append('\n')); + if (!sb.isEmpty()) { + sb.setLength(sb.length() - 1); + } + return sb.toString(); + } + + @Override + public String table(Map options) { + var sb = new StringBuilder(); + if (options.isEmpty()) { + options.put("options", "header"); + } + options.putIfAbsent("cols", "1,1,3a"); + sb.append( + options.entrySet().stream() + .map(it -> it.getKey() + "=\"" + it.getValue() + "\"") + .collect(Collectors.joining(", ", "[", "]"))) + .append('\n'); + sb.append("|===").append('\n'); + sb.append("|").append("Method|Path|Summary").append("\n\n"); + operations.forEach( + op -> + sb.append("|`+") + .append(op.getMethod()) + .append("+`\n") + .append("|`+") + .append(op.getPath()) + .append("+`\n") + .append("|") + .append(Optional.ofNullable(op.operation().getSummary()).orElse("")) + .append("\n\n")); + if (!sb.isEmpty()) { + sb.append("\n"); + sb.setLength(sb.length() - 1); + } + sb.append("|==="); + return sb.toString(); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpResponse.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpResponse.java new file mode 100644 index 0000000000..097cd2fb9b --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/HttpResponse.java @@ -0,0 +1,118 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import com.fasterxml.jackson.annotation.JsonIncludeProperties; +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.StatusCode; +import io.jooby.internal.openapi.OperationExt; +import io.jooby.internal.openapi.ParameterExt; +import io.jooby.internal.openapi.ResponseExt; +import io.pebbletemplates.pebble.template.EvaluationContext; +import io.swagger.v3.oas.models.media.Schema; + +@JsonIncludeProperties({"method", "path"}) +public record HttpResponse( + EvaluationContext evaluationContext, + OperationExt operation, + Integer statusCode, + Map options) + implements HttpMessage { + @Override + public ParameterList getHeaders() { + return new ParameterList( + operation.getProduces().stream() + .map(value -> ParameterExt.header("Content-Type", value)) + .toList(), + ParameterList.NAME_DESC); + } + + @Override + public ParameterList getCookies() { + return new ParameterList(List.of(), ParameterList.NAME_DESC); + } + + @Override + public AsciiDocContext context() { + return AsciiDocContext.from(evaluationContext); + } + + public String getMethod() { + return operation.getMethod(); + } + + public String getPath() { + return operation.getPath(); + } + + @Override + public Schema getBody() { + return selectBody(getBody(response()), options.getOrDefault("body", "full").toString()); + } + + public boolean isSuccess() { + return statusCode != null && statusCode >= 200 && statusCode < 300; + } + + public Object getSucessOrError() { + var response = response(); + if (response == operation.getDefaultResponse()) { + return getBody(); + } + // massage error apply global error format + var rsp = operation.getResponses().get(Integer.toString(statusCode)); + + if (rsp == null) { + // default output + return context().error(evaluationContext, Map.of("code", statusCode)); + } + var errorContext = new LinkedHashMap(); + errorContext.put("code", statusCode); + errorContext.put("message", rsp.getDescription()); + return context().error(evaluationContext, errorContext); + } + + private ResponseExt response() { + if (statusCode == null) { + return operation.getDefaultResponse(); + } else { + var rsp = operation.getResponses().get(Integer.toString(statusCode)); + if (rsp == null) { + if (statusCode >= 200 && statusCode <= 299) { + // override default response + return operation.getDefaultResponse(); + } + } + return (ResponseExt) rsp; + } + } + + public StatusCode getStatusCode() { + if (statusCode == null) { + return Optional.ofNullable(response()) + .map(it -> StatusCode.valueOf(Integer.parseInt(it.getCode()))) + .orElse(StatusCode.OK); + } + return StatusCode.valueOf(statusCode); + } + + private Schema getBody(ResponseExt response) { + return Optional.ofNullable(response) + .map(it -> toSchema(it.getContent(), List.of())) + .map(context()::resolveSchema) + .orElse(null); + } + + @NonNull @Override + public String toString() { + return operation.getMethod() + " " + operation.getPath(); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Lookup.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Lookup.java new file mode 100644 index 0000000000..46feb364f6 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Lookup.java @@ -0,0 +1,267 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.*; +import java.util.stream.Stream; + +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.StatusCode; +import io.pebbletemplates.pebble.extension.Function; +import io.pebbletemplates.pebble.template.EvaluationContext; +import io.pebbletemplates.pebble.template.PebbleTemplate; + +/** + * GET("path") | table GET("path") | parameters | table + * + *

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

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

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

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

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

GET("path") | request | body | http + */ +public enum Lookup implements Function { + operation { + @Override + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + var method = args.get("method").toString(); + var path = args.get("path").toString(); + var asciidoc = AsciiDocContext.from(context); + return asciidoc.getOpenApi().findOperation(method, path); + } + + @Override + public List getArgumentNames() { + return List.of("method", "path"); + } + }, + GET { + @Override + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + return operation.execute(appendMethod(args), self, context, lineNumber); + } + + @Override + public List getArgumentNames() { + return List.of("path"); + } + }, + POST { + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + return operation.execute(appendMethod(args), self, context, lineNumber); + } + + @Override + public List getArgumentNames() { + return List.of("path"); + } + }, + PUT { + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + return operation.execute(appendMethod(args), self, context, lineNumber); + } + + @Override + public List getArgumentNames() { + return List.of("path"); + } + }, + PATCH { + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + return operation.execute(appendMethod(args), self, context, lineNumber); + } + + @Override + public List getArgumentNames() { + return List.of("path"); + } + }, + DELETE { + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + return operation.execute(appendMethod(args), self, context, lineNumber); + } + + @Override + public List getArgumentNames() { + return List.of("path"); + } + }, + schema { + @Override + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + var path = args.get("path").toString(); + var asciidoc = AsciiDocContext.from(context); + return asciidoc.resolveSchema(path); + } + + @Override + public List getArgumentNames() { + return List.of("path"); + } + + @Override + public List alias() { + return List.of("schema", "model"); + } + }, + tag { + @Override + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + var asciidoc = AsciiDocContext.from(context); + var name = args.get("name").toString(); + return asciidoc.getOpenApi().getTags().stream() + .filter(tag -> tag.getName().equalsIgnoreCase(name)) + .findFirst() + .map( + it -> + new TagExt( + it, + asciidoc.getOpenApi().findOperationByTag(it.getName()).stream() + .map(op -> new HttpRequest(asciidoc, op, Map.of())) + .toList())) + .orElseThrow(() -> new NoSuchElementException("Tag not found: " + name)); + } + + @Override + public List getArgumentNames() { + return List.of("name"); + } + }, + error { + @Override + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + var asciidoc = AsciiDocContext.from(context); + return asciidoc.error(context, args); + } + + @Override + public List getArgumentNames() { + return List.of("code"); + } + }, + routes { + @Override + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + var asciidoc = AsciiDocContext.from(context); + var operations = asciidoc.getOpenApi().getOperations(); + var list = + operations.stream() + .filter( + it -> { + var includes = (String) args.get("includes"); + return includes == null || it.getPath().matches(includes); + }) + .map(it -> new HttpRequest(asciidoc, it, args)) + .toList(); + return new HttpRequestList(asciidoc, list); + } + + @Override + public List getArgumentNames() { + return List.of("includes"); + } + + @Override + public List alias() { + return List.of("routes", "operations"); + } + }, + statusCode { + @Override + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + var code = args.get("code"); + if (code instanceof List codes) { + return new StatusCodeList(codes.stream().flatMap(this::toMap).toList()); + } + return new StatusCodeList(toMap(code).toList()); + } + + @NonNull private Stream> toMap(Object candidate) { + if (candidate instanceof Number code) { + Map map = new LinkedHashMap<>(); + map.put("code", code.intValue()); + map.put("reason", StatusCode.valueOf(code.intValue()).reason()); + return Stream.of(map); + } else if (candidate instanceof Map codeMap) { + var codes = new ArrayList>(); + for (var entry : new TreeMap<>(codeMap).entrySet()) { + var value = entry.getKey(); + if (value instanceof Number code) { + Map map = new LinkedHashMap<>(); + map.put("code", code.intValue()); + map.put("reason", entry.getValue()); + codes.add(map); + } else { + throw new ClassCastException("Must be Map: " + candidate); + } + } + return codes.stream(); + } + throw new ClassCastException("Not a number: " + candidate); + } + + @Override + public List getArgumentNames() { + return List.of("code"); + } + }, + server { + @Override + public Object execute( + Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) { + var asciidoc = AsciiDocContext.from(context); + var servers = asciidoc.getOpenApi().getServers(); + if (servers == null || servers.isEmpty()) { + throw new NoSuchElementException("No servers"); + } + var nameOrIndex = args.get("name"); + if (nameOrIndex instanceof Number index) { + if (index.intValue() >= 0 && index.intValue() < servers.size()) { + return servers.get(index.intValue()); + } else { + throw new NoSuchElementException("Server not found: [" + nameOrIndex + "]"); + } + } else { + return servers.stream() + .filter(it -> nameOrIndex.equals(it.getDescription())) + .findFirst() + .orElseThrow(() -> new NoSuchElementException("Server not found: " + nameOrIndex)); + } + } + + @Override + public List getArgumentNames() { + return List.of("name"); + } + }; + + public List alias() { + return List.of(name()); + } + + protected Map appendMethod(Map args) { + Map result = new LinkedHashMap<>(args); + result.put("method", name()); + return result; + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Mutator.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Mutator.java new file mode 100644 index 0000000000..3d85d4b458 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/Mutator.java @@ -0,0 +1,188 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import io.jooby.internal.openapi.OperationExt; +import io.pebbletemplates.pebble.error.PebbleException; +import io.pebbletemplates.pebble.extension.Filter; +import io.pebbletemplates.pebble.template.EvaluationContext; +import io.pebbletemplates.pebble.template.PebbleTemplate; +import io.swagger.v3.oas.models.media.Schema; + +public enum Mutator implements Filter { + example { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + if (input instanceof Schema schema) { + var asciidoc = AsciiDocContext.from(context); + return asciidoc.schemaExample(schema); + } + return input; + } + }, + truncate { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + if (input instanceof Schema schema) { + var asciidoc = AsciiDocContext.from(context); + return asciidoc.reduceSchema(schema); + } + return input; + } + }, + request { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + return new HttpRequest(AsciiDocContext.from(context), toOperation(input), args); + } + }, + response { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + return new HttpResponse( + context, + toOperation(input), + Optional.ofNullable(args.get("code")) + .map(Number.class::cast) + .map(Number::intValue) + .orElse(null), + args); + } + + @Override + public List getArgumentNames() { + return List.of("code"); + } + }, + parameters { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + var in = normalizeList(args.getOrDefault("in", "*")); + var includes = normalizeList(args.getOrDefault("includes", List.of())); + return toHttpRequest(context, input, args).getParameters(in, includes); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private List normalizeList(Object value) { + if (value instanceof List valueList) { + return valueList; + } + return value == null ? List.of() : List.of(value.toString()); + } + + @Override + public List getArgumentNames() { + return List.of("in", "includes"); + } + }, + body { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + var bodyType = args.getOrDefault("type", "full"); + // Handle response a bit different + if (input instanceof HttpResponse rsp) { + // success or error + return rsp.getSucessOrError(); + } + return toHttpMessage(context, input, Map.of("body", bodyType)).getBody(); + } + }, + form { + @Override + public Object apply( + Object input, + Map args, + PebbleTemplate self, + EvaluationContext context, + int lineNumber) + throws PebbleException { + return toHttpRequest(context, input, args).getForm(); + } + }; + + protected OperationExt toOperation(Object input) { + return switch (input) { + case OperationExt op -> op; + case HttpRequest req -> req.operation(); + case HttpResponse rsp -> rsp.operation(); + case null -> throw new NullPointerException(name() + ": requires a request/response input"); + default -> + throw new ClassCastException( + name() + ": requires a request/response input: " + input.getClass()); + }; + } + + protected HttpMessage toHttpMessage( + EvaluationContext context, Object input, Map options) { + return switch (input) { + // default to http request + case OperationExt op -> new HttpRequest(AsciiDocContext.from(context), op, options); + case HttpMessage msg -> msg; + case null -> throw new NullPointerException(name() + ": requires a request/response input"); + default -> + throw new ClassCastException( + name() + ": requires a request/response input: " + input.getClass()); + }; + } + + protected HttpRequest toHttpRequest( + EvaluationContext context, Object input, Map options) { + return switch (input) { + // default to http request + case OperationExt op -> new HttpRequest(AsciiDocContext.from(context), op, options); + case HttpRequest msg -> msg; + case null -> throw new NullPointerException(name() + ": requires a request/response input"); + default -> + throw new ClassCastException( + name() + ": requires a request/response input: " + input.getClass()); + }; + } + + @Override + public List getArgumentNames() { + return List.of(); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/ParameterList.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/ParameterList.java new file mode 100644 index 0000000000..9cf432b560 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/ParameterList.java @@ -0,0 +1,51 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.AbstractList; +import java.util.List; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import edu.umd.cs.findbugs.annotations.NonNull; +import io.swagger.v3.oas.models.parameters.Parameter; + +@JsonIgnoreProperties({"includes"}) +public class ParameterList extends AbstractList { + public static final List NAME_DESC = List.of("name", "description"); + public static final List NAME_TYPE_DESC = List.of("name", "type", "description"); + public static final List PARAM = List.of("name", "type", "in", "description"); + private final List parameters; + private final List includes; + + public ParameterList(List parameters, List includes) { + this.parameters = parameters; + this.includes = includes; + } + + public List parameters() { + return parameters; + } + + public List includes() { + return includes; + } + + @Override + public int size() { + return parameters.size(); + } + + @NonNull @Override + public String toString() { + return parameters.stream().map(Parameter::getName).collect(Collectors.joining(", ")); + } + + @Override + public Parameter get(int index) { + return parameters.get(index); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/StatusCodeList.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/StatusCodeList.java new file mode 100644 index 0000000000..6b4adebeb8 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/StatusCodeList.java @@ -0,0 +1,49 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonIncludeProperties; +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.internal.openapi.asciidoc.display.MapToAsciiDoc; + +@JsonIncludeProperties({"codes"}) +public record StatusCodeList(List> codes) + implements Iterable>, ToAsciiDoc { + @NonNull @Override + public String toString() { + return codes.toString(); + } + + @NonNull @Override + public Iterator> iterator() { + return codes.iterator(); + } + + @Override + public String list(Map options) { + var sb = new StringBuilder(); + codes.forEach( + (row) -> + sb.append("* `+") + .append(row.get("code")) + .append("+`: ") + .append(row.get("reason")) + .append('\n')); + if (!sb.isEmpty()) { + sb.setLength(sb.length() - 1); + } + return sb.toString(); + } + + @Override + public String table(Map options) { + return new MapToAsciiDoc(codes).table(options); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/TagExt.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/TagExt.java new file mode 100644 index 0000000000..7655775d00 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/TagExt.java @@ -0,0 +1,31 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.List; + +import io.swagger.v3.oas.models.tags.Tag; + +public class TagExt extends Tag { + + private final List operations; + + public TagExt(Tag tag, List operations) { + setDescription(tag.getDescription()); + setName(tag.getName()); + setExternalDocs(tag.getExternalDocs()); + setExtensions(tag.getExtensions()); + this.operations = operations; + } + + public List getOperations() { + return operations; + } + + public List getRoutes() { + return operations; + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/ToAsciiDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/ToAsciiDoc.java new file mode 100644 index 0000000000..435ead5f3e --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/ToAsciiDoc.java @@ -0,0 +1,14 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.Map; + +public interface ToAsciiDoc { + String list(Map options); + + String table(Map options); +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/ToSnippet.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/ToSnippet.java new file mode 100644 index 0000000000..d3e0a82fd0 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/ToSnippet.java @@ -0,0 +1,12 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import java.util.Map; + +public interface ToSnippet { + String render(Map options); +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/MapToAsciiDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/MapToAsciiDoc.java new file mode 100644 index 0000000000..2f1511fd84 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/MapToAsciiDoc.java @@ -0,0 +1,55 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc.display; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import io.jooby.internal.openapi.asciidoc.ToAsciiDoc; + +public record MapToAsciiDoc(List> rows) implements ToAsciiDoc { + + public String list(Map options) { + var sb = new StringBuilder(); + rows.forEach( + (row) -> { + row.forEach( + (name, value) -> { + sb.append("* ").append(name).append(": ").append(value).append('\n'); + }); + }); + if (!sb.isEmpty()) { + sb.setLength(sb.length() - 1); + } + return sb.toString(); + } + + public String table(Map options) { + var sb = new StringBuilder(); + if (!options.isEmpty()) { + sb.append( + options.entrySet().stream() + .map(it -> it.getKey() + "=\"" + it.getValue() + "\"") + .collect(Collectors.joining(", ", "[", "]"))) + .append('\n'); + } + sb.append("|===").append('\n'); + if (!rows.isEmpty()) { + sb.append(rows.getFirst().keySet().stream().collect(Collectors.joining("|", "|", ""))) + .append("\n\n"); + rows.forEach( + row -> { + row.values().forEach(value -> sb.append("|").append(value).append("\n")); + sb.append("\n"); + }); + sb.append("\n"); + } + sb.setLength(sb.length() - 1); + sb.append("|==="); + return sb.toString(); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/OpenApiToAsciiDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/OpenApiToAsciiDoc.java new file mode 100644 index 0000000000..22185430cc --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/OpenApiToAsciiDoc.java @@ -0,0 +1,211 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc.display; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.google.common.base.CaseFormat; +import io.jooby.internal.openapi.EnumSchema; +import io.jooby.internal.openapi.asciidoc.AsciiDocContext; +import io.jooby.internal.openapi.asciidoc.ParameterList; +import io.jooby.internal.openapi.asciidoc.ToAsciiDoc; +import io.swagger.v3.oas.models.media.Schema; + +public record OpenApiToAsciiDoc( + AsciiDocContext context, + Map> properties, + List columns, + Map additionalProperties) + implements ToAsciiDoc { + private static final String ROOT = "___root__"; + + public static OpenApiToAsciiDoc schema(AsciiDocContext context, Schema schema) { + var columns = + schema instanceof EnumSchema + ? List.of("name", "description") + : List.of("name", "type", "description"); + var properties = new LinkedHashMap>(); + properties.put(OpenApiToAsciiDoc.ROOT, schema); + context.traverseSchema(schema, properties::put); + return new OpenApiToAsciiDoc(context, properties, columns, Map.of()); + } + + public static OpenApiToAsciiDoc parameters(AsciiDocContext context, ParameterList parameters) { + var properties = new LinkedHashMap>(); + parameters.forEach(p -> properties.put(p.getName(), p.getSchema())); + Map additionalProperties = new LinkedHashMap<>(); + parameters.forEach( + p -> { + additionalProperties.put(p.getName() + ".in", p.getIn()); + additionalProperties.put(p.getName() + ".description", p.getDescription()); + }); + return new OpenApiToAsciiDoc(context, properties, parameters.includes(), additionalProperties); + } + + public String list(Map options) { + var isEnum = properties.get(ROOT) instanceof EnumSchema; + var sb = new StringBuilder(); + if (isEnum) { + var enumSchema = (EnumSchema) properties.remove(ROOT); + for (var enumName : enumSchema.getEnum()) { + sb.append(boldCell(enumName)).append("::").append('\n'); + var enumDesc = enumSchema.getDescription(enumName); + if (enumDesc != null) { + sb.append("* ").append(enumDesc); + } + sb.append('\n'); + } + } else { + properties.remove(ROOT); + properties.forEach( + (name, value) -> { + sb.append(name).append("::").append('\n'); + sb.append("* ") + .append("type") + .append(": ") + .append(monospaceCell(context.schemaType(value))) + .append('\n'); + var in = additionalProperties.get(name + ".in"); + if (in != null) { + sb.append("* ") + .append("in") + .append(": ") + .append(monospaceCell((String) in)) + .append('\n'); + } + var isEnumProperty = value instanceof EnumSchema; + var description = + isEnumProperty ? ((EnumSchema) value).getSummary() : value.getDescription(); + if (isEnumProperty) { + sb.append("* ").append("description").append(":"); + if (description != null) { + sb.append(" ").append(description); + } + sb.append('\n'); + var enumSchema = (EnumSchema) value; + for (var enumName : enumSchema.getEnum()) { + sb.append("** ").append(boldCell(enumName)); + var enumDesc = enumSchema.getDescription(enumName); + if (enumDesc != null) { + sb.append(": ").append(enumDesc); + } + sb.append('\n'); + } + } else { + if (description != null) { + sb.append("* ").append("description").append(": ").append(description).append('\n'); + } + } + }); + } + if (!sb.isEmpty()) { + sb.setLength(sb.length() - 1); + } + return sb.toString(); + } + + @SuppressWarnings({"unchecked"}) + public String table(Map options) { + var isEnum = properties.get(ROOT) instanceof EnumSchema; + var columns = (List) options.getOrDefault("columns", this.columns); + options.remove("columns"); + var colList = colList(columns); + var sb = new StringBuilder(); + sb.append("|===").append('\n'); + sb.append(header(columns)).append('\n'); + if (isEnum) { + var enumSchema = (EnumSchema) properties.remove(ROOT); + for (var enumName : enumSchema.getEnum()) { + sb.append("| ").append(boldCell(enumName)).append('\n'); + var enumDesc = enumSchema.getDescription(enumName); + if (enumDesc != null) { + sb.append("| ").append(enumDesc); + } + sb.append('\n'); + } + } else { + properties.remove(ROOT); + properties.forEach( + (name, value) -> { + var isPropertyEnum = value instanceof EnumSchema; + for (int i = 0; i < columns.size(); i++) { + var column = columns.get(i); + sb.append("|").append(row(column, name, value)).append("\n"); + if (isPropertyEnum && column.equals("description")) { + colList.set(i, colList.get(i) + "a"); + var enumSchema = (EnumSchema) value; + for (var enumValue : enumSchema.getEnum()) { + sb.append("\n* ").append(boldCell(enumValue)); + var enumDesc = enumSchema.getDescription(enumValue); + if (enumDesc != null) { + sb.append(": ").append(enumDesc); + } + } + sb.append('\n'); + } + } + sb.append('\n'); + }); + } + sb.append("|==="); + options.putIfAbsent("cols", colsToString(colList)); + if (options.size() == 1) { + options.put("options", "header"); + } + return options.entrySet().stream() + .map(e -> e.getKey() + "=\"" + e.getValue() + "\"") + .collect(Collectors.joining(", ", "[", "]")) + + "\n" + + sb; + } + + private String colsToString(List cols) { + return String.join(",", cols); + } + + private List colList(List names) { + return names.stream() + .map(it -> it.equals("description") ? "3" : "1") + .collect(Collectors.toList()); + } + + private String header(List names) { + return names.stream() + .map(it -> CaseFormat.LOWER_CAMEL.to(CaseFormat.UPPER_CAMEL, it)) + .collect(Collectors.joining("|", "|", "")); + } + + private static String monospaceCell(String value) { + return value == null || value.trim().isEmpty() ? "" : "`+" + value + "+`"; + } + + private String boldCell(String value) { + return value == null || value.trim().isEmpty() ? "" : "*" + value + "*"; + } + + private String nullSafe(String value) { + return value == null || value.trim().isEmpty() ? "" : value; + } + + private String row(String col, String property, Schema schema) { + return nullSafe( + switch (col) { + case "name" -> monospaceCell(property); + case "type" -> monospaceCell(context.schemaType(schema)); + case "in" -> monospaceCell((String) additionalProperties.get(property + "." + col)); + case "description" -> + (schema instanceof EnumSchema enumSchema + ? enumSchema.getSummary() + : (String) + additionalProperties.getOrDefault( + property + "." + col, schema.getDescription())); + default -> throw new IllegalArgumentException("Unknown property: " + col); + }); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/RequestToCurl.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/RequestToCurl.java new file mode 100644 index 0000000000..9da8674661 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/RequestToCurl.java @@ -0,0 +1,180 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc.display; + +import java.util.*; + +import com.google.common.base.Splitter; +import com.google.common.collect.LinkedHashMultimap; +import com.google.common.collect.Multimap; +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.Router; +import io.jooby.internal.openapi.asciidoc.*; + +public class RequestToCurl implements ToSnippet { + private static final CharSequence Accept = new HeaderName("Accept"); + private static final CharSequence ContentType = new HeaderName("Content-Type"); + + private final AsciiDocContext context; + private final HttpRequest request; + + public RequestToCurl(AsciiDocContext context, HttpRequest request) { + this.context = context; + this.request = request; + } + + @Override + public String render(Map args) { + var language = (String) args.remove("language"); + var options = args(args); + var method = removeOption(options, "-X", request.getMethod()).toUpperCase(); + /* Accept/Content-Type: */ + var addAccept = true; + var addContentType = true; + if (options.containsKey("-H")) { + var headers = parseHeaders(options.get("-H")); + addAccept = !headers.containsKey(Accept); + addContentType = !headers.containsKey(ContentType); + } + if (addAccept) { + request.getProduces().forEach(value -> options.put("-H", "'Accept: " + value + "'")); + } + if (addContentType + && Set.of(Router.PATCH, Router.PUT, Router.POST, Router.DELETE).contains(method)) { + request.getConsumes().forEach(value -> options.put("-H", "'Content-Type: " + value + "'")); + } + /* Body */ + var formUrlEncoded = + request.formUrlEncoded( + (schema, field) -> { + var option = "--data-urlencode"; + var value = field.getValue(); + if ("binary".equals(schema.getFormat())) { + option = "-F"; + value = "@/file%1$s.extension"; + } + return Map.entry(option, "\"" + field.getKey() + "=" + value + "\""); + }); + if (formUrlEncoded.isEmpty()) { + var body = request.getBody(); + if (body != null) { + options.put("-d", "'" + context.toJson(context.schemaProperties(body), false) + "'"); + } + } else { + formUrlEncoded.forEach(options::put); + } + + /* Method */ + var url = request.getPath() + request.getQueryString(); + options.put("-X", method + " '" + url + "'"); + return toString(options, language); + } + + private String toString(Multimap options, String language) { + var curl = "curl"; + var sb = new StringBuilder(); + sb.append("[source"); + if (language != null) { + sb.append(", ").append(language); + } + sb.append("]\n----\n").append(curl); + var separator = "\\\n"; + var tabSize = 1; + for (var entry : options.entries()) { + var k = entry.getKey(); + var v = entry.getValue(); + sb.append(" ".repeat(tabSize)); + sb.append(k); + if (v != null && !v.isEmpty()) { + sb.append(" ").append(v); + } + sb.append(separator); + tabSize = curl.length() + 1; + } + sb.setLength(sb.length() - separator.length()); + sb.append("\n----"); + return sb.toString(); + } + + private Multimap parseHeaders(Collection headers) { + Multimap result = LinkedHashMultimap.create(); + for (var line : headers) { + if (line.startsWith("'") && line.endsWith("'")) { + line = line.substring(1, line.length() - 1); + } + var header = Splitter.on(':').trimResults().omitEmptyStrings().splitToList(line); + if (header.size() != 2) { + throw new IllegalArgumentException("Invalid header: " + line); + } + result.put(new HeaderName(header.get(0)), header.get(1)); + } + return result; + } + + @NonNull private static String removeOption( + Multimap options, String name, String defaultValue) { + return Optional.of(options.removeAll(name)) + .map(Collection::iterator) + .filter(Iterator::hasNext) + .map(Iterator::next) + .orElse(defaultValue); + } + + private Multimap args(Map args) { + Multimap result = LinkedHashMultimap.create(); + var optionList = new ArrayList<>(args.values()); + for (int i = 0; i < optionList.size(); ) { + var key = optionList.get(i).toString(); + String value = null; + if (i + 1 < optionList.size()) { + var next = optionList.get(i + 1); + if (next.toString().startsWith("-")) { + i += 1; + } else { + value = next.toString(); + i += 2; + } + } else { + i += 1; + } + result.put(key, value == null ? "" : value); + } + return result; + } + + private record HeaderName(String value) implements CharSequence { + + @Override + public int length() { + return value.length(); + } + + @Override + public boolean equals(Object obj) { + return value.equalsIgnoreCase(obj.toString()); + } + + @Override + public int hashCode() { + return value.toLowerCase().hashCode(); + } + + @Override + public char charAt(int index) { + return value.charAt(index); + } + + @NonNull @Override + public CharSequence subSequence(int start, int end) { + return value.subSequence(start, end); + } + + @Override + @NonNull public String toString() { + return value; + } + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/RequestToHttp.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/RequestToHttp.java new file mode 100644 index 0000000000..387e11de7c --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/RequestToHttp.java @@ -0,0 +1,50 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc.display; + +import java.util.Map; + +import io.jooby.SneakyThrows; +import io.jooby.internal.openapi.ParameterExt; +import io.jooby.internal.openapi.asciidoc.AsciiDocContext; +import io.jooby.internal.openapi.asciidoc.HttpRequest; +import io.jooby.internal.openapi.asciidoc.ToSnippet; + +/** + * [source,http,options="nowrap"] ---- ${method} ${path} HTTP/1.1 {% for h in headers -%} ${h.name}: + * ${h.value} {% endfor -%} ${requestBody -} ---- + * + * @param context + * @param request + */ +public record RequestToHttp(AsciiDocContext context, HttpRequest request) implements ToSnippet { + @Override + public String render(Map options) { + try { + var sb = new StringBuilder(); + sb.append("[source,http,options=\"nowrap\"]").append('\n'); + sb.append("----").append('\n'); + sb.append(request.getMethod()) + .append(" ") + .append(request.getPath()) + .append(" HTTP/1.1") + .append('\n'); + for (var header : request.getHeaders()) { + sb.append(header.getName()) + .append(": ") + .append(((ParameterExt) header).getDefaultValue()) + .append('\n'); + } + var schema = request.getBody(); + if (schema != null) { + sb.append(context.toJson(context.schemaProperties(schema), false)).append('\n'); + } + return sb.append("----").toString(); + } catch (Exception x) { + throw SneakyThrows.propagate(x); + } + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/ResponseToHttp.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/ResponseToHttp.java new file mode 100644 index 0000000000..545572c473 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/asciidoc/display/ResponseToHttp.java @@ -0,0 +1,39 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc.display; + +import java.util.Map; + +import io.jooby.SneakyThrows; +import io.jooby.internal.openapi.ParameterExt; +import io.jooby.internal.openapi.asciidoc.*; + +public record ResponseToHttp(AsciiDocContext context, HttpResponse response) implements ToSnippet { + @Override + public String render(Map options) { + try { + var sb = new StringBuilder(); + sb.append("[source,http,options=\"nowrap\"]").append('\n'); + sb.append("----").append('\n'); + sb.append("HTTP/1.1 ") + .append(response.getStatusCode().value()) + .append(" ") + .append(response.getStatusCode().reason()) + .append('\n'); + for (var header : response.getHeaders()) { + var value = ((ParameterExt) header).getDefaultValue(); + sb.append(header.getName()).append(": ").append(value).append('\n'); + } + var schema = response.getBody(); + if (schema != null) { + sb.append(context.toJson(context.schemaProperties(schema), false)).append('\n'); + } + return sb.append("----").toString(); + } catch (Exception x) { + throw SneakyThrows.propagate(x); + } + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java index 35d7c04bda..7fb1c6136f 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java @@ -88,6 +88,16 @@ public String getEnumDescription(String text) { return text; } + public String getEnumItemDescription(String name) { + if (isEnum()) { + var field = fields.get(name); + if (field != null) { + return field.getText(); + } + } + return null; + } + private void defaultRecordMembers() { JavaDocTag.javaDocTag( javadoc, diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ContentSplitter.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ContentSplitter.java new file mode 100644 index 0000000000..12e7e80b60 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ContentSplitter.java @@ -0,0 +1,158 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.javadoc; + +public class ContentSplitter { + + public record ContentResult(String summary, String description) {} + + public static ContentResult split(String text) { + if (text == null || text.isEmpty()) { + return new ContentResult("", ""); + } + + int len = text.length(); + int splitIndex = -1; + + // State trackers + int parenDepth = 0; // ( ) + int bracketDepth = 0; // [ ] + int braceDepth = 0; // { } + boolean inHtmlDef = false; // < ... > + boolean inCodeBlock = false; //

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

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

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

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

and tags (and their attributes) from the text. Keeps content inside the tags. + */ + private static String stripParagraphTags(String text) { + if (text.isEmpty()) return text; + + StringBuilder sb = new StringBuilder(text.length()); + int len = text.length(); + + for (int i = 0; i < len; i++) { + char c = text.charAt(i); + + if (c == '<') { + // Detect or + if (isTag(text, i, "p") || isCloseTag(text, i, "p")) { + // Fast-forward until we find the closing '>' + while (i < len && text.charAt(i) != '>') { + i++; + } + // We are now at '>', loop increment will move past it + continue; + } + } + sb.append(c); + } + return sb.toString().trim(); + } + + // --- Helper Methods --- + + private static boolean isTag(String text, int i, String tagName) { + int len = tagName.length(); + if (i + 1 + len > text.length()) return false; + + // Match tagName (case insensitive) + for (int k = 0; k < len; k++) { + char c = text.charAt(i + 1 + k); + if (Character.toLowerCase(c) != tagName.charAt(k)) return false; + } + + // Check delimiter (must be '>' or whitespace or end of string) + if (i + 1 + len == text.length()) return true; + char delimiter = text.charAt(i + 1 + len); + return delimiter == '>' || Character.isWhitespace(delimiter); + } + + private static boolean isCloseTag(String text, int i, String tagName) { + int len = tagName.length(); + if (i + 2 + len > text.length()) return false; + if (text.charAt(i + 1) != '/') return false; + + // Match tagName (case insensitive) + for (int k = 0; k < len; k++) { + char c = text.charAt(i + 2 + k); + if (Character.toLowerCase(c) != tagName.charAt(k)) return false; + } + + // Check delimiter + char delimiter = text.charAt(i + 2 + len); + return delimiter == '>' || Character.isWhitespace(delimiter); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/FieldDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/FieldDoc.java index 13e1ba2117..6ce38c22ce 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/FieldDoc.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/FieldDoc.java @@ -17,6 +17,12 @@ public FieldDoc(JavaDocParser ctx, DetailAST node, DetailAST javadoc) { super(ctx, node, javadoc); } + @Override + public String getText() { + var text = super.getText(); + return text == null ? null : text.replace("

", "").replace("

", "").trim(); + } + public String getName() { return JavaDocSupport.getSimpleName(node); } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java index 6d30ff8fde..fd3459ca79 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java @@ -5,7 +5,6 @@ */ package io.jooby.internal.openapi.javadoc; -import static io.jooby.internal.openapi.javadoc.JavaDocStream.*; import static io.jooby.internal.openapi.javadoc.JavaDocStream.javadocToken; import java.util.*; @@ -55,29 +54,8 @@ public Map getExtensions() { } public String getSummary() { - var builder = new StringBuilder(); - for (var node : forward(javadoc, JAVADOC_TAG).toList()) { - if (node.getType() == JavadocCommentsTokenTypes.TEXT) { - var text = node.getText(); - var trimmed = text.trim(); - if (trimmed.isEmpty()) { - if (!builder.isEmpty()) { - builder.append(text); - } - } else { - builder.append(text); - } - } else if (node.getType() == JavadocCommentsTokenTypes.NEWLINE && !builder.isEmpty()) { - break; - } - var index = builder.indexOf("."); - if (index > 0) { - builder.setLength(index + 1); - break; - } - } - var string = builder.toString().trim(); - return string.isEmpty() ? null : string; + var summary = ContentSplitter.split(getText()).summary(); + return summary.isEmpty() ? null : summary; } public List getTags() { @@ -85,12 +63,8 @@ public List getTags() { } public String getDescription() { - var text = getText(); - var summary = getSummary(); - if (summary == null) { - return text; - } - return summary.equals(text) ? null : text.replaceAll(summary, "").trim(); + var description = ContentSplitter.split(getText()).description(); + return description.isEmpty() ? null : description; } public String getText() { @@ -143,7 +117,12 @@ protected static String getText(List nodes, boolean stripLeading) { if (next != null && next.getType() != JavadocCommentsTokenTypes.LEADING_ASTERISK) { builder.append(next.getText()); visited.add(next); - // visited.add(next.getNextSibling()); + } + } else if (node.getType() == JavadocCommentsTokenTypes.TAG_NAME) { + //

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

"); } } } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java index a9b1ab7116..b73fe20638 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java @@ -21,6 +21,7 @@ import io.jooby.Router; import io.jooby.SneakyThrows; import io.jooby.internal.openapi.*; +import io.jooby.internal.openapi.asciidoc.AsciiDocContext; import io.jooby.internal.openapi.javadoc.JavaDocParser; import io.swagger.v3.core.util.*; import io.swagger.v3.oas.models.OpenAPI; @@ -47,7 +48,10 @@ public enum Format { /** JSON. */ JSON { @Override - public String toString(OpenAPIGenerator tool, OpenAPI result) { + @NonNull protected String toString( + @NonNull OpenAPIGenerator tool, + @NonNull OpenAPI result, + @NonNull Map options) { return tool.toJson(result); } }, @@ -55,9 +59,49 @@ public String toString(OpenAPIGenerator tool, OpenAPI result) { /** YAML. */ YAML { @Override - public String toString(OpenAPIGenerator tool, OpenAPI result) { + @NonNull protected String toString( + @NonNull OpenAPIGenerator tool, + @NonNull OpenAPI result, + @NonNull Map options) { return tool.toYaml(result); } + }, + + ADOC { + @Override + @NonNull protected String toString( + @NonNull OpenAPIGenerator tool, + @NonNull OpenAPI result, + @NonNull Map options) { + return tool.toAdoc(result, options); + } + + @SuppressWarnings("unchecked") + @NonNull @Override + public List write( + @NonNull OpenAPIGenerator tool, + @NonNull OpenAPI result, + @NonNull Map options) + throws IOException { + var files = (List) options.get("adoc"); + if (files == null || files.isEmpty()) { + // adoc generation is optional + return List.of(); + } + var outputDir = (Path) options.get("outputDir"); + var outputList = new ArrayList(); + var context = tool.createAsciidoc(files.getFirst().getParent(), (OpenAPIExt) result); + for (var file : files) { + var opts = new HashMap<>(options); + opts.put("adoc", file); + var content = toString(tool, result, opts); + var output = outputDir.resolve(file.getFileName()); + Files.write(output, List.of(content)); + context.export(output, outputDir); + outputList.add(output); + } + return outputList; + } }; /** @@ -76,8 +120,28 @@ public String toString(OpenAPIGenerator tool, OpenAPI result) { * @param result Model. * @return String (json or yaml content). */ - public abstract @NonNull String toString( - @NonNull OpenAPIGenerator tool, @NonNull OpenAPI result); + protected abstract @NonNull String toString( + @NonNull OpenAPIGenerator tool, + @NonNull OpenAPI result, + @NonNull Map options); + + /** + * Convert an {@link OpenAPI} model to the current format. + * + * @param tool Generator. + * @param result Model. + * @return String (json or yaml content). + */ + public @NonNull List write( + @NonNull OpenAPIGenerator tool, + @NonNull OpenAPI result, + @NonNull Map options) + throws IOException { + var output = (Path) options.get("output"); + var content = toString(tool, result, options); + Files.write(output, List.of(content)); + return List.of(output); + } } private Logger log = LoggerFactory.getLogger(getClass()); @@ -100,6 +164,8 @@ public String toString(OpenAPIGenerator tool, OpenAPI result) { private SpecVersion specVersion = SpecVersion.V30; + private AsciiDocContext asciidoc; + /** Default constructor. */ public OpenAPIGenerator() {} @@ -111,29 +177,31 @@ public OpenAPIGenerator() {} * @return Output file. * @throws IOException If fails to process input. */ - public @NonNull Path export(@NonNull OpenAPI openAPI, @NonNull Format format) throws IOException { + public @NonNull List export( + @NonNull OpenAPI openAPI, @NonNull Format format, @NonNull Map options) + throws IOException { Path output; if (openAPI instanceof OpenAPIExt) { - String source = ((OpenAPIExt) openAPI).getSource(); - String[] names = source.split("\\."); + var source = ((OpenAPIExt) openAPI).getSource(); + var names = source.split("\\."); output = Stream.of(names).limit(names.length - 1).reduce(outputDir, Path::resolve, Path::resolve); - String appname = names[names.length - 1]; + var appname = names[names.length - 1]; if (appname.endsWith("Kt")) { appname = appname.substring(0, appname.length() - 2); } output = output.resolve(appname + "." + format.extension()); } else { - output = outputDir.resolve("openapi." + format.extension()); + throw new ClassCastException(openAPI.getClass() + " is not a " + OpenAPIExt.class); } if (!Files.exists(output.getParent())) { Files.createDirectories(output.getParent()); } - - String content = format.toString(this, openAPI); - Files.write(output, Collections.singleton(content)); - return output; + var allOptions = new HashMap<>(options); + allOptions.put("output", output); + allOptions.put("outputDir", output.getParent()); + return format.write(this, openAPI, allOptions); } /** @@ -177,6 +245,7 @@ public OpenAPIGenerator() {} doc.getServers().forEach(openapi::addServersItem); doc.getContact().forEach(info::setContact); doc.getLicense().forEach(info::setLicense); + doc.getTags().forEach(openapi::addTagsItem); }); } @@ -201,7 +270,7 @@ public OpenAPIGenerator() {} Map globalTags = new LinkedHashMap<>(); Paths paths = new Paths(); for (OperationExt operation : operations) { - String pattern = operation.getPattern(); + String pattern = operation.getPath(); if (!includes(pattern) || excludes(pattern)) { log.debug("skipping {}", pattern); continue; @@ -310,6 +379,24 @@ private void defaults(String classname, String contextPath, OpenAPIExt openapi) } } + /** + * Generates an adoc version of the given model. + * + * @param openAPI Model. + * @return YAML content. + */ + public @NonNull String toAdoc(@NonNull OpenAPI openAPI, @NonNull Map options) { + try { + var file = (Path) options.get("adoc"); + if (file == null) { + throw new IllegalArgumentException("'adoc' file is required: " + options); + } + return createAsciidoc(file.getParent(), (OpenAPIExt) openAPI).generate(file); + } catch (IOException x) { + throw SneakyThrows.propagate(x); + } + } + /** * Generates a JSON version of the given model. * @@ -391,7 +478,7 @@ public Path getBasedir() { } /** - * Set output directory used by {@link #export(OpenAPI, Format)} operation. + * Set output directory used by {@link #export(OpenAPI, Format, Map)} operation. * *

Defaults to {@link #getBasedir()}. * @@ -438,7 +525,7 @@ public void setExcludes(@Nullable String excludes) { } /** - * Set output directory used by {@link #export(OpenAPI, Format)}. + * Set output directory used by {@link #export(OpenAPI, Format, Map)}. * * @param outputDir Output directory. */ @@ -451,7 +538,7 @@ public void setOutputDir(@NonNull Path outputDir) { * * @param specVersion One of 3.0 or 3.1. */ - public void setSpecVersion(SpecVersion specVersion) { + private void setSpecVersion(SpecVersion specVersion) { this.specVersion = specVersion; } @@ -461,19 +548,23 @@ public void setSpecVersion(SpecVersion specVersion) { * @param version One of 3.0 or 3.1. */ public void setSpecVersion(String version) { - if (specVersion != null) { - switch (version) { - case "v3.1", "v3.1.0", "3.1", "3.1.0": - setSpecVersion(SpecVersion.V31); - case "v3.0", "v3.0.0", "3.0", "3.0.0", "v3.0.1", "3.0.1": - setSpecVersion(SpecVersion.V30); - default: - throw new IllegalArgumentException( - "Invalid spec version: " + version + ". Supported version: [3.0.1, 3.1.0]"); - } + switch (version) { + case "v3.1", "v3.1.0", "3.1", "3.1.0", "V31": + setSpecVersion(SpecVersion.V31); + break; + case "v3.0", "v3.0.0", "3.0", "3.0.0", "v3.0.1", "3.0.1", "V30": + setSpecVersion(SpecVersion.V30); + break; + default: + throw new IllegalArgumentException( + "Invalid spec version: " + version + ". Supported version: [3.0.1, 3.1.0]"); } } + protected AsciiDocContext createAsciidoc(Path basedir, OpenAPIExt openapi) { + return new AsciiDocContext(basedir, jsonMapper(), yamlMapper(), openapi); + } + private String appname(String classname) { String name = classname; int i = name.lastIndexOf('.'); diff --git a/modules/jooby-openapi/src/main/java/module-info.java b/modules/jooby-openapi/src/main/java/module-info.java index deb869c94b..0606a6a0ed 100644 --- a/modules/jooby-openapi/src/main/java/module-info.java +++ b/modules/jooby-openapi/src/main/java/module-info.java @@ -17,4 +17,16 @@ requires org.objectweb.asm; requires org.objectweb.asm.tree; requires org.objectweb.asm.util; + 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; + requires org.jruby; + requires net.datafaker; + requires com.fasterxml.jackson.dataformat.yaml; + requires io.swagger.models; + requires com.fasterxml.jackson.annotation; } diff --git a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/AutoDataFakerMapperTest.java b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/AutoDataFakerMapperTest.java new file mode 100644 index 0000000000..234630db51 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/AutoDataFakerMapperTest.java @@ -0,0 +1,157 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import java.util.function.Supplier; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import com.fasterxml.jackson.core.JsonProcessingException; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AutoDataFakerMapperTest { + + private AutoDataFakerMapper mapper; + + @BeforeAll + void setup() { + // Initialize with a custom override for testing + mapper = new AutoDataFakerMapper(); + mapper.synonyms(Map.of("sku_id", "ean13")); + } + + @Test + void testExactMatchByClassAndField() { + // Book.title exists in Datafaker + Supplier generator = mapper.getGenerator("Book", "title", "string", "fail"); + String result = generator.get(); + + assertThat(result).isNotEqualTo("fail").isNotEmpty(); + } + + @Test + void testAuthorSSN() { + Supplier generator = mapper.getGenerator("Author", "ssn", "string", "fail"); + String result = generator.get(); + + assertThat(result).as("SSN format validation").matches("^\\d{3}-\\d{2}-\\d{4}$"); + } + + @Test + void testGenericMatchByField() { + // "firstName" is generic, should map to Name.firstName + Supplier generator = mapper.getGenerator("User", "firstName", "string", "fail"); + String result = generator.get(); + + assertThat(result).isNotEqualTo("fail").doesNotContain("fail"); + } + + @Test + void testSynonymHandling() { + // "dob" is a synonym for "birthday" + Supplier generator = mapper.getGenerator("User", "dob", "date", "fail"); + String result = generator.get(); + + // Datafaker birthday usually returns a date string + assertThat(result).isNotEqualTo("fail"); + } + + @Test + void testSynonymNormalization() { + // "e-mail" -> normalize -> "email" -> maps to internet().emailAddress() + Supplier generator = mapper.getGenerator("User", "e-mail", "string", "fail"); + String result = generator.get(); + + assertThat(result).contains("@"); + } + + @Test + void testFieldTypeFallback() { + // "unknown_field_xyz" does not exist in Faker. + // It should fallback to "date-time" type logic. + Supplier generator = + mapper.getGenerator("Log", "unknown_field_xyz", "date-time", "fail"); + String result = generator.get(); + + assertThat(result).isNotEqualTo("fail"); + } + + @Test + void testFieldTypeUUID() { + Supplier generator = mapper.getGenerator("Table", "pk_id", "uuid", "fail"); + String result = generator.get(); + + assertThat(result).hasSize(36); // UUID length + } + + @Test + void testCustomUserSynonym() { + // We registered "sku_id" -> "ean13" in setup() + Supplier generator = mapper.getGenerator("Product", "sku_id", "string", "fail"); + String result = generator.get(); + + // EAN13 is numbers + assertThat(result).matches("\\d+"); + } + + @Test + void testFuzzyMatching() { + // "customer_email_address" -> contains "email" -> maps to email provider + Supplier generator = + mapper.getGenerator("Customer", "customer_email_address", "string", "fail"); + String result = generator.get(); + + assertThat(result).contains("@"); + } + + @Test + void testCompleteFallback() { + // Nothing matches this + Supplier generator = + mapper.getGenerator("Alien", "warp_speed", "unknown_type", "DEFAULT_VALUE"); + String result = generator.get(); + + assertThat(result).isEqualTo("DEFAULT_VALUE"); + } + + @Test + @SuppressWarnings("unchecked") + void testCapabilityMapStructure() throws JsonProcessingException { + Map capabilities = mapper.getCapabilityMap(); + + // 1. Check Top Level Keys + assertThat(capabilities).containsKeys("domains", "generics", "types", "synonyms"); + + // 2. Check Domains: "book" -> { "title": "faker.book().title()" } + Map> domains = + (Map>) capabilities.get("domains"); + assertThat(domains).containsKey("book"); + + Map bookFields = domains.get("book"); + assertThat(bookFields).containsKey("title"); + assertThat(bookFields.get("title")).contains("faker.book().title"); + + // 3. Check Generics: "title" -> "faker.book().title()" + Map generics = (Map) capabilities.get("generics"); + assertThat(generics).containsKey("firstname"); + assertThat(generics.get("firstname")).contains("faker.name().firstName"); + + // 4. Check Types: "uuid" -> description + Map types = (Map) capabilities.get("types"); + assertThat(types).containsKey("uuid"); + assertThat(types.get("uuid")).contains("faker.internet().uuid"); + + // 5. Check Synonyms + Map synonyms = (Map) capabilities.get("synonyms"); + assertThat(synonyms).containsEntry("surname", "lastname"); + assertThat(synonyms).containsEntry("skuid", "ean13"); + } +} diff --git a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/PebbleTemplateSupport.java b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/PebbleTemplateSupport.java new file mode 100644 index 0000000000..a1decd839a --- /dev/null +++ b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/asciidoc/PebbleTemplateSupport.java @@ -0,0 +1,47 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.asciidoc; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.io.StringWriter; +import java.nio.file.Path; + +import org.assertj.core.api.AbstractStringAssert; + +import io.jooby.SneakyThrows; +import io.jooby.internal.openapi.OpenAPIExt; +import io.swagger.v3.core.util.Json31; +import io.swagger.v3.core.util.Yaml31; + +public class PebbleTemplateSupport { + + private final AsciiDocContext context; + + public PebbleTemplateSupport(Path basedir, OpenAPIExt openapi) { + this.context = new AsciiDocContext(basedir, Json31.mapper(), Yaml31.mapper(), openapi); + } + + public AbstractStringAssert evaluateThat(String input) throws IOException { + return assertThat(evaluate(input)); + } + + public void evaluate(String input, SneakyThrows.Consumer consumer) throws IOException { + consumer.accept(evaluate(input)); + } + + public AsciiDocContext getContext() { + return context; + } + + public String evaluate(String input) throws IOException { + var template = context.getEngine().getLiteralTemplate(input); + var writer = new StringWriter(); + template.evaluate(writer); + return writer.toString().trim(); + } +} diff --git a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/javadoc/ContentSplitterTest.java b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/javadoc/ContentSplitterTest.java new file mode 100644 index 0000000000..39f2ff3f8f --- /dev/null +++ b/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/javadoc/ContentSplitterTest.java @@ -0,0 +1,169 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi.javadoc; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class ContentSplitterTest { + + @Test + void shouldHandleNullAndEmpty() { + assertSplit(null, "", ""); + assertSplit("", "", ""); + assertSplit(" ", "", ""); + } + + @Test + void shouldSplitOnSimplePeriod() { + assertSplit("Hello world. This is description.", "Hello world.", "This is description."); + } + + @Test + void shouldSplitOnParagraphTag() { + //

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

Description

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

+ assertSplit("Hello world

Description

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

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

para

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

with description containing.

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

with attributes should still trigger split + assertSplit("Summary

Description

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

-> Empty summary + assertSplit("

Description only.

", "", "Description only."); + + // Ends with period -> Empty description + assertSplit("Only summary.", "Only summary.", ""); + } + + @Test + void shouldNotSplitInsidePreTags() { + // The period in 1.0 must be ignored because it is inside
...
+ assertSplit( + "Code
val x = 1.0
is cool. End.", + "Code
val x = 1.0
is cool.", + "End."); + } + + @Test + void shouldNotSplitInsideCodeTags() { + // The period in System.out must be ignored because it is inside ... + assertSplit( + "Use System.out.println for logging. Next.", + "Use System.out.println for logging.", + "Next."); + } + + @Test + void shouldHandleMixedNesting() { + // Parentheses + Code block + assertSplit( + "Check (e.g. var x = 1.0). Done.", + "Check (e.g. var x = 1.0).", + "Done."); + } + + @Test + void shouldIgnorePeriodInsideJavadocTags() { + // Test {@code ...} + assertSplit("Use {@code 1.0} version. Next.", "Use {@code 1.0} version.", "Next."); + + // Test {@link ...} + assertSplit("See {@link java.util.List}. End.", "See {@link java.util.List}.", "End."); + } + + @Test + void shouldIgnorePeriodInsideGeneralBraces() { + // Since we implemented brace tracking, this also supports standard JSON/Code blocks + assertSplit( + "Config { val x = 1.0; } allowed. Next.", "Config { val x = 1.0; } allowed.", "Next."); + } + + // Helper method to make tests readable + private void assertSplit(String input, String expectedSummary, String expectedDesc) { + var result = ContentSplitter.split(input); + assertEquals(expectedSummary, result.summary(), "Summary mismatch"); + assertEquals(expectedDesc, result.description(), "Description mismatch"); + } +} diff --git a/modules/jooby-openapi/src/test/java/io/jooby/openapi/CurrentDir.java b/modules/jooby-openapi/src/test/java/io/jooby/openapi/CurrentDir.java new file mode 100644 index 0000000000..ddc21c3cfe --- /dev/null +++ b/modules/jooby-openapi/src/test/java/io/jooby/openapi/CurrentDir.java @@ -0,0 +1,43 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.openapi; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.stream.Stream; + +public class CurrentDir { + public static Path basedir(String... others) { + return basedir(List.of(others)); + } + + public static Path basedir(List others) { + var baseDir = Paths.get(System.getProperty("user.dir")); + if (!baseDir.getFileName().toString().endsWith("jooby-openapi")) { + baseDir = baseDir.resolve("modules").resolve("jooby-openapi"); + } + for (var other : others) { + baseDir = baseDir.resolve(other); + } + return baseDir; + } + + public static Path testClass(Class clazz) { + var packageDir = clazz.getPackage().getName().split("\\."); + return basedir( + Stream.concat(Stream.of("src", "test", "resources"), Stream.of(packageDir)).toList()); + } + + public static Path testClass(Class clazz, String file) { + var packageDir = clazz.getPackage().getName().split("\\."); + return basedir( + Stream.concat( + Stream.concat(Stream.of("src", "test", "resources"), Stream.of(packageDir)), + Stream.of(file)) + .toList()); + } +} diff --git a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIExtension.java b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIExtension.java index b6ceddf36b..0668c56492 100644 --- a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIExtension.java +++ b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIExtension.java @@ -26,7 +26,9 @@ public boolean supportsParameter( ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { Parameter parameter = parameterContext.getParameter(); - return parameter.getType() == RouteIterator.class || parameter.getType() == OpenAPIResult.class; + return parameter.getType() == RouteIterator.class + || parameter.getType() == OpenAPIResult.class + || parameter.getType() == OpenAPIExt.class; } @Override @@ -43,7 +45,7 @@ public Object resolveParameter(ParameterContext parameterContext, ExtensionConte : EnumSet.copyOf(Arrays.asList(metadata.debug())); OpenAPIGenerator tool = newTool(debugOptions); - tool.setSpecVersion(metadata.version()); + tool.setSpecVersion(metadata.version().name()); String templateName = metadata.templateName(); if (templateName.isEmpty()) { templateName = classname.replace(".", "/").toLowerCase() + ".yaml"; @@ -66,6 +68,9 @@ public Object resolveParameter(ParameterContext parameterContext, ExtensionConte if (parameter.getType() == OpenAPIResult.class) { return result; } + if (parameter.getType() == OpenAPIExt.class) { + return result.getOpenAPI(); + } RouteIterator iterator = result.iterator(metadata.ignoreArguments()); getStore(context).put("iterator", iterator); return iterator; diff --git a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIResult.java b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIResult.java index 5913874ba9..c11fb966d4 100644 --- a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIResult.java +++ b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIResult.java @@ -5,12 +5,14 @@ */ package io.jooby.openapi; +import java.nio.file.Path; import java.util.List; import java.util.stream.Collectors; import com.fasterxml.jackson.databind.ObjectMapper; import io.jooby.SneakyThrows; import io.jooby.internal.openapi.OpenAPIExt; +import io.jooby.internal.openapi.asciidoc.AsciiDocContext; import io.swagger.v3.core.util.Json; import io.swagger.v3.core.util.Yaml; import io.swagger.v3.parser.OpenAPIV3Parser; @@ -35,6 +37,10 @@ public RouteIterator iterator(boolean ignoreArgs) { return new RouteIterator(openAPI == null ? List.of() : openAPI.getOperations(), ignoreArgs); } + public OpenAPIExt getOpenAPI() { + return openAPI; + } + public String toYaml() { return toYaml(true); } @@ -89,6 +95,34 @@ public String toJson(boolean validate) { } } + public String toAsciiDoc(Path index) { + return toAsciiDoc(index, false); + } + + public String toAsciiDoc(Path index, boolean validate) { + if (failure != null) { + throw failure; + } + try { + String json = this.json.writerWithDefaultPrettyPrinter().writeValueAsString(openAPI); + if (validate) { + SwaggerParseResult result = new OpenAPIV3Parser().readContents(json); + if (result.getMessages().isEmpty()) { + return json; + } + throw new IllegalStateException( + "Invalid OpenAPI specification:\n\t- " + + String.join("\n\t- ", result.getMessages()).trim() + + "\n\n" + + json); + } + var asciiDoc = new AsciiDocContext(index.getParent(), this.json, this.yaml, openAPI); + return asciiDoc.generate(index); + } catch (Exception x) { + throw SneakyThrows.propagate(x); + } + } + public static OpenAPIResult failure(RuntimeException failure) { var result = new OpenAPIResult(Json.mapper(), Yaml.mapper(), null); result.failure = failure; diff --git a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OperationBuilder.java b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OperationBuilder.java new file mode 100644 index 0000000000..a8813f219a --- /dev/null +++ b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OperationBuilder.java @@ -0,0 +1,166 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.openapi; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.mockito.ArgumentCaptor; +import org.mockito.stubbing.Answer; + +import io.jooby.Router; +import io.jooby.StatusCode; +import io.jooby.internal.openapi.*; +import io.swagger.v3.core.converter.ModelConverters; +import io.swagger.v3.core.util.Json; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.parameters.Parameter; +import io.swagger.v3.oas.models.responses.ApiResponses; + +public class OperationBuilder { + + private ApiResponses responses; + + private final OperationExt operation = mock(OperationExt.class); + + static { + ModelConverters.getInstance().addConverter(new ModelConverterExt(Json.mapper())); + } + + public static OperationBuilder operation(String method, String pattern) { + return new OperationBuilder().method(method).pattern(pattern); + } + + public OperationBuilder query(String... name) { + return parameter(Map.of("query", mapOf(name))); + } + + public OperationBuilder form(String... name) { + return parameter(Map.of("form", mapOf(name))); + } + + public OperationBuilder path(String... name) { + return parameter(Map.of("path", mapOf(name))); + } + + public OperationBuilder cookie(String... name) { + return parameter(Map.of("cookie", mapOf(name))); + } + + private static Map mapOf(String... values) { + Map map = new LinkedHashMap<>(); + for (var value : values) { + map.put(value, "string"); + } + return map; + } + + public OperationBuilder parameter(Map> parameterSpecs) { + List parameters = new ArrayList<>(); + for (var parameterSpec : parameterSpecs.entrySet()) { + var in = parameterSpec.getKey(); + for (var entry : parameterSpec.getValue().entrySet()) { + var schema = mock(Schema.class); + var type = entry.getValue(); + if (type.equals("binary")) { + when(schema.getFormat()).thenReturn(type); + type = "string"; + } + when(schema.getType()).thenReturn(type); + var parameter = mock(ParameterExt.class); + when(parameter.getName()).thenReturn(entry.getKey()); + when(parameter.getIn()).thenReturn(in); + when(parameter.getSchema()).thenReturn(schema); + parameters.add(parameter); + } + } + when(operation.getParameters()).thenReturn(parameters); + return this; + } + + public OperationBuilder produces(String... produces) { + return produces(List.of(produces)); + } + + public OperationBuilder produces(List produces) { + when(operation.getProduces()).thenReturn(produces); + return this; + } + + public OperationBuilder consumes(String... consumes) { + return consumes(List.of(consumes)); + } + + public OperationBuilder consumes(List consumes) { + when(operation.getConsumes()).thenReturn(consumes); + return this; + } + + public OperationBuilder method(String method) { + when(operation.getMethod()).thenReturn(method); + return this; + } + + @SuppressWarnings("unchecked") + public OperationBuilder pattern(String pattern) { + when(operation.getPath()).thenReturn(pattern); + ArgumentCaptor> args = ArgumentCaptor.forClass(Map.class); + when(operation.getPath(args.capture())) + .thenAnswer((Answer) invocationOnMock -> Router.reverse(pattern, args.getValue())); + return this; + } + + public OperationBuilder response(Object body, StatusCode code, String contentType) { + var schemas = ModelConvertersExt.getInstance().read(body.getClass()); + var mediaType = new io.swagger.v3.oas.models.media.MediaType(); + mediaType.schema(schemas.get(body.getClass().getSimpleName())); + + var content = new Content(); + content.addMediaType(contentType, mediaType); + + ResponseExt response = mock(ResponseExt.class); + when(response.getContent()).thenReturn(content); + when(response.getCode()).thenReturn(Integer.toString(code.value())); + + if (responses == null) { + responses = mock(ApiResponses.class); + when(operation.getResponses()).thenReturn(responses); + when(operation.getDefaultResponse()).thenReturn(response); + } + when(responses.get(Integer.toString(code.value()))).thenReturn(response); + return this; + } + + public OperationBuilder defaultResponse() { + return response(Map.of(), StatusCode.OK, "application/json"); + } + + public OperationBuilder body(Object body, String contentType) { + consumes(contentType); + var schemas = ModelConverters.getInstance().read(body.getClass()); + var mediaType = new io.swagger.v3.oas.models.media.MediaType(); + mediaType.schema(schemas.get(body.getClass().getSimpleName())); + + var content = new Content(); + content.addMediaType(contentType, mediaType); + + var requestBodyExt = mock(RequestBodyExt.class); + when(requestBodyExt.getContent()).thenReturn(content); + when(requestBodyExt.getJavaType()).thenReturn(body.getClass().getName()); + when(operation.getRequestBody()).thenReturn(requestBodyExt); + return this; + } + + public OperationExt build() { + return operation; + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/Issue1582.java b/modules/jooby-openapi/src/test/java/issues/Issue1582.java index b6a4f04eb8..9b4cbf04ab 100644 --- a/modules/jooby-openapi/src/test/java/issues/Issue1582.java +++ b/modules/jooby-openapi/src/test/java/issues/Issue1582.java @@ -12,6 +12,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; @@ -24,35 +26,36 @@ public class Issue1582 { @Test public void shouldGenerateOnOneLvelPackageLocation() throws IOException { - Path output = export("com.App"); - assertTrue(Files.exists(output)); - assertEquals(outDir.resolve("com").resolve("App.yaml"), output); + var output = export("com.App"); + output.forEach(it -> assertTrue(Files.exists(it))); + assertEquals(List.of(outDir.resolve("com").resolve("App.yaml")), output); } @Test public void shouldGenerateOnPackageLocation() throws IOException { - Path output = export("com.myapp.App"); - assertTrue(Files.exists(output)); - assertEquals(outDir.resolve("com").resolve("myapp").resolve("App.yaml"), output); + var output = export("com.myapp.App"); + output.forEach(it -> assertTrue(Files.exists(it))); + assertEquals(List.of(outDir.resolve("com").resolve("myapp").resolve("App.yaml")), output); } @Test public void shouldGenerateOnDeepPackageLocation() throws IOException { - Path output = export("com.foo.bar.app.App"); - assertTrue(Files.exists(output)); + var output = export("com.foo.bar.app.App"); + output.forEach(it -> assertTrue(Files.exists(it))); assertEquals( - outDir.resolve("com").resolve("foo").resolve("bar").resolve("app").resolve("App.yaml"), + List.of( + outDir.resolve("com").resolve("foo").resolve("bar").resolve("app").resolve("App.yaml")), output); } @Test public void shouldGenerateOnRootLocation() throws IOException { - Path output = export("App"); - assertTrue(Files.exists(output)); - assertEquals(outDir.resolve("App.yaml"), output); + var output = export("App"); + output.forEach(it -> assertTrue(Files.exists(it))); + assertEquals(List.of(outDir.resolve("App.yaml")), output); } - private Path export(String source) throws IOException { + private List export(String source) throws IOException { Info info = new Info(); info.setTitle("API"); info.setVersion("1.0"); @@ -64,6 +67,6 @@ private Path export(String source) throws IOException { OpenAPIGenerator generator = new OpenAPIGenerator(); generator.setOutputDir(outDir); - return generator.export(openAPI, OpenAPIGenerator.Format.YAML); + return generator.export(openAPI, OpenAPIGenerator.Format.YAML, Map.of()); } } diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java index 8260168217..d8bc609cc7 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java @@ -5,8 +5,10 @@ */ package issues.i3729.api; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; +import io.jooby.openapi.CurrentDir; import io.jooby.openapi.OpenAPIResult; import io.jooby.openapi.OpenAPITest; @@ -17,6 +19,385 @@ public void shouldGenerateMvcDoc(OpenAPIResult result) { checkResult(result); } + @OpenAPITest(value = AppDemoLibrary.class) + public void shouldGenerateGoodDoc(OpenAPIResult result) { + assertThat(result.toYaml()) + .isEqualToIgnoringNewLines( + """ + openapi: 3.0.1 + info: + title: DemoLibrary API + description: DemoLibrary API description + version: "1.0" + paths: + /library/books/{isbn}: + summary: The Public Front Desk of the library. + get: + tags: + - Library + summary: Get Specific Book Details + description: View the full information for a single specific book using its + unique ISBN. + operationId: getBook + parameters: + - name: isbn + in: path + description: "The unique ID from the URL (e.g., /books/978-3-16-148410-0)" + required: true + schema: + type: string + responses: + "200": + description: The book data + content: + application/json: + schema: + $ref: "#/components/schemas/Book" + "404": + description: "Not Found: error if it doesn't exist." + /library/search: + summary: The Public Front Desk of the library. + get: + tags: + - Library + summary: Quick Search + description: "Find books by a partial title (e.g., searching \\"Harry\\" finds\\ + \\ \\"Harry Potter\\")." + operationId: searchBooks + parameters: + - name: q + in: query + description: The word or phrase to search for. + schema: + type: string + responses: + "200": + description: A list of books matching that term. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Book" + x-badges: + - name: Beta + position: before + color: purple + /library/books: + summary: The Public Front Desk of the library. + get: + tags: + - Library + summary: Browse Books (Paginated) + description: "Look up a specific book title where there might be many editions\\ + \\ or copies, splitting the results into manageable pages." + operationId: getBooksByTitle + parameters: + - name: title + in: query + description: The exact book title to filter by. + schema: + type: string + - name: page + in: query + description: Which page number to load (defaults to 1). + required: true + schema: + type: integer + format: int32 + - name: size + in: query + description: How many books to show per page (defaults to 20). + required: true + schema: + type: integer + format: int32 + responses: + "200": + description: "A \\"Page\\" object containing the books and info like \\"Total\\ + \\ Pages: 5\\"." + content: + application/json: + schema: + type: object + properties: + content: + type: array + items: + $ref: "#/components/schemas/Book" + numberOfElements: + type: integer + format: int32 + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int64 + pageRequest: + type: object + properties: + page: + type: integer + format: int64 + size: + type: integer + format: int32 + nextPageRequest: + type: object + properties: + page: + type: integer + format: int64 + size: + type: integer + format: int32 + previousPageRequest: + type: object + properties: + page: + type: integer + format: int64 + size: + type: integer + format: int32 + post: + tags: + - Inventory + summary: Add New Book + description: Register a new book in the system. + operationId: addBook + requestBody: + description: New book to add. + content: + application/json: + schema: + $ref: "#/components/schemas/Book" + required: true + responses: + "200": + description: A text message confirming success. + content: + application/json: + schema: + $ref: "#/components/schemas/Book" + components: + schemas: + Address: + type: object + properties: + street: + type: string + description: Street name. + city: + type: string + description: City name. + state: + type: string + description: State. + country: + type: string + description: Two digit country code. + description: Author address. + Book: + type: object + properties: + isbn: + type: string + description: Book ISBN. Method. + title: + type: string + description: Book's title. + publicationDate: + type: string + description: Publication date. Format mm-dd-yyyy. + format: date + text: + type: string + description: Book's content. + type: + type: string + description: |- + Book type. + - Fiction: Fiction books are based on imaginary characters and events, while non-fiction books are based o n real people and events. + - NonFiction: Non-fiction genres include biography, autobiography, history, self-help, and true crime. + enum: + - Fiction + - NonFiction + authors: + uniqueItems: true + type: array + items: + $ref: "#/components/schemas/Author" + image: + type: string + format: binary + description: Book model. + Author: + type: object + properties: + ssn: + type: string + description: Social security number. + name: + type: string + description: Author's name. + address: + $ref: "#/components/schemas/Address" + books: + uniqueItems: true + type: array + description: Published books. + items: + $ref: "#/components/schemas/Book"\ + """); + } + + @OpenAPITest(value = AppLibrary.class) + public void shouldGenerateAdoc(OpenAPIResult result) { + assertThat( + result.toAsciiDoc( + CurrentDir.basedir("src", "test", "resources", "adoc", "library.adoc"))) + .isEqualToIgnoringNewLines( + """ + = Library API. + Jooby Doc; + :doctype: book + :icons: font + :source-highlighter: highlightjs + :toc: left + :toclevels: 4 + :sectlinks: + + == Introduction + + Available data: Books and authors. + + == Support + + Write your questions at support@jooby.io + + [[overview_operations]] + == Operations + + === List Books + + Query books. By using advanced filters. + + Example: `/api/library?title=...` + + ==== Request Fields + + [cols="1,1,3", options="header"] + |=== + |Name|Type|Description + |`+title+` + |`+string+` + |Book's title. + + |`+author+` + |`+string+` + |Book's author. Optional. + + |`+isbn+` + |`+array+` + |Book's isbn. Optional. + + |=== + + === Find a book by ISBN + + [source] + ---- + curl -i\\ + -H 'Accept: application/json'\\ + -X GET '/api/library/{isbn}' + ---- + + .GET /api/library/{isbn} + [source, json] + ---- + { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "authors" : [ { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "state" : "string", + "country" : "string" + }, + "books" : [ { } ] + } ], + "image" : "binary" + } + ---- + + .GET /api/library/{isbn} + [source, json] + ---- + { + "message" : "Bad Request: For bad ISBN code.", + "reason" : "Bad Request", + "statusCode" : 400 + } + ---- + + .GET /api/library/{isbn} + [source, json] + ---- + { + "message" : "Not Found: If a book doesn't exist.", + "reason" : "Not Found", + "statusCode" : 404 + } + ---- + + ==== Response Fields + + [cols="1,1,3a", options="header"] + |=== + |Name|Type|Description + |`+isbn+` + |`+string+` + |Book ISBN. Method. + + |`+title+` + |`+string+` + |Book's title. + + |`+publicationDate+` + |`+date+` + |Publication date. Format mm-dd-yyyy. + + |`+text+` + |`+string+` + |Book's content. + + |`+type+` + |`+string+` + |Books can be broadly categorized into fiction and non-fiction. + + * *Fiction*: Fiction books are based on imaginary characters and events, while non-fiction books are based o n real people and events. + * *NonFiction*: Non-fiction genres include biography, autobiography, history, self-help, and true crime. + + |`+authors+` + |`+array+` + | + + |`+image+` + |`+binary+` + | + + |===\ + """); + } + @OpenAPITest(value = ScriptLibrary.class) public void shouldGenerateScriptDoc(OpenAPIResult result) { checkResult(result); @@ -24,227 +405,233 @@ public void shouldGenerateScriptDoc(OpenAPIResult result) { private void checkResult(OpenAPIResult result) { assertEquals( - "openapi: 3.0.1\n" - + "info:\n" - + " title: Library API.\n" - + " description: \"Available data: Books and authors.\"\n" - + " contact:\n" - + " name: Jooby\n" - + " url: https://jooby.io\n" - + " email: support@jooby.io\n" - + " license:\n" - + " name: Apache\n" - + " url: https://jooby.io/LICENSE\n" - + " version: 4.0.0\n" - + " x-logo:\n" - + " url: https://redocly.github.io/redoc/museum-logo.png\n" - + " altText: Museum logo\n" - + "servers:\n" - + "- url: https://api.fake-museum-example.com/v1\n" - + "tags:\n" - + "- name: Library\n" - + " description: Access to all books.\n" - + "- name: Author\n" - + " description: Oxxx\n" - + "paths:\n" - + " /api/library/{isbn}:\n" - + " summary: Library API.\n" - + " description: \"Contains all operations for creating, updating and fetching" - + " books.\"\n" - + " get:\n" - + " tags:\n" - + " - Library\n" - + " - Book\n" - + " - Author\n" - + " summary: Find a book by isbn.\n" - + " operationId: bookByIsbn\n" - + " parameters:\n" - + " - name: isbn\n" - + " in: path\n" - + " description: Book isbn. Like IK-1900.\n" - + " required: true\n" - + " schema:\n" - + " type: string\n" - + " responses:\n" - + " \"200\":\n" - + " description: A matching book.\n" - + " content:\n" - + " application/json:\n" - + " schema:\n" - + " $ref: \"#/components/schemas/Book\"\n" - + " \"404\":\n" - + " description: \"Not Found: If a book doesn't exist.\"\n" - + " \"400\":\n" - + " description: \"Bad Request: For bad ISBN code.\"\n" - + " /api/library/{id}:\n" - + " summary: Library API.\n" - + " description: \"Contains all operations for creating, updating and fetching" - + " books.\"\n" - + " get:\n" - + " tags:\n" - + " - Library\n" - + " - Author\n" - + " summary: Author by Id.\n" - + " operationId: author\n" - + " parameters:\n" - + " - name: id\n" - + " in: path\n" - + " description: ID.\n" - + " required: true\n" - + " schema:\n" - + " type: string\n" - + " responses:\n" - + " \"200\":\n" - + " description: An author\n" - + " content:\n" - + " application/json:\n" - + " schema:\n" - + " $ref: \"#/components/schemas/Author\"\n" - + " /api/library:\n" - + " summary: Library API.\n" - + " description: \"Contains all operations for creating, updating and fetching" - + " books.\"\n" - + " get:\n" - + " tags:\n" - + " - Library\n" - + " summary: Query books.\n" - + " operationId: query\n" - + " parameters:\n" - + " - name: title\n" - + " in: query\n" - + " description: Book's title.\n" - + " schema:\n" - + " type: string\n" - + " - name: author\n" - + " in: query\n" - + " description: Book's author. Optional.\n" - + " schema:\n" - + " type: string\n" - + " - name: isbn\n" - + " in: query\n" - + " description: Book's isbn. Optional.\n" - + " schema:\n" - + " type: string\n" - + " responses:\n" - + " \"200\":\n" - + " description: Matching books.\n" - + " content:\n" - + " application/json:\n" - + " schema:\n" - + " type: array\n" - + " items:\n" - + " $ref: \"#/components/schemas/Book\"\n" - + " x-badges:\n" - + " - name: Beta\n" - + " position: before\n" - + " color: purple\n" - + " post:\n" - + " tags:\n" - + " - Library\n" - + " - Author\n" - + " summary: Creates a new book.\n" - + " description: Book can be created or updated.\n" - + " operationId: createBook\n" - + " requestBody:\n" - + " description: Book to create.\n" - + " content:\n" - + " application/json:\n" - + " schema:\n" - + " $ref: \"#/components/schemas/Book\"\n" - + " example:\n" - + " isbn: X01981\n" - + " title: HarryPotter\n" - + " required: true\n" - + " responses:\n" - + " \"200\":\n" - + " description: Saved book.\n" - + " content:\n" - + " application/json:\n" - + " schema:\n" - + " $ref: \"#/components/schemas/Book\"\n" - + " example:\n" - + " id: generatedId\n" - + " isbn: '...'\n" - + "components:\n" - + " schemas:\n" - + " Author:\n" - + " type: object\n" - + " properties:\n" - + " ssn:\n" - + " type: string\n" - + " description: Social security number.\n" - + " name:\n" - + " type: string\n" - + " description: Author's name.\n" - + " address:\n" - + " $ref: \"#/components/schemas/Address\"\n" - + " books:\n" - + " uniqueItems: true\n" - + " type: array\n" - + " description: Published books.\n" - + " items:\n" - + " $ref: \"#/components/schemas/Book\"\n" - + " BookQuery:\n" - + " type: object\n" - + " properties:\n" - + " title:\n" - + " type: string\n" - + " description: Book's title.\n" - + " author:\n" - + " type: string\n" - + " description: Book's author. Optional.\n" - + " isbn:\n" - + " type: string\n" - + " description: Book's isbn. Optional.\n" - + " description: Query books by complex filters.\n" - + " Address:\n" - + " type: object\n" - + " properties:\n" - + " street:\n" - + " type: string\n" - + " description: Street name.\n" - + " city:\n" - + " type: string\n" - + " description: City name.\n" - + " state:\n" - + " type: string\n" - + " description: State.\n" - + " country:\n" - + " type: string\n" - + " description: Two digit country code.\n" - + " description: Author address.\n" - + " Book:\n" - + " type: object\n" - + " properties:\n" - + " isbn:\n" - + " type: string\n" - + " description: Book ISBN. Method.\n" - + " title:\n" - + " type: string\n" - + " description: Book's title.\n" - + " publicationDate:\n" - + " type: string\n" - + " description: Publication date. Format mm-dd-yyyy.\n" - + " format: date\n" - + " text:\n" - + " type: string\n" - + " type:\n" - + " type: string\n" - + " description: |-\n" - + " Book type.\n" - + " - Fiction: Fiction books are based on imaginary characters and events," - + " while non-fiction books are based o n real people and events.\n" - + " - NonFiction: Non-fiction genres include biography, autobiography," - + " history, self-help, and true crime.\n" - + " enum:\n" - + " - Fiction\n" - + " - NonFiction\n" - + " authors:\n" - + " uniqueItems: true\n" - + " type: array\n" - + " items:\n" - + " $ref: \"#/components/schemas/Author\"\n" - + " description: Book model.\n", + """ + openapi: 3.0.1 + info: + title: Library API. + description: "Available data: Books and authors." + contact: + name: Jooby + url: https://jooby.io + email: support@jooby.io + license: + name: Apache + url: https://jooby.io/LICENSE + version: 4.0.0 + x-logo: + url: https://redocly.github.io/redoc/museum-logo.png + altText: Museum logo + servers: + - url: https://api.fake-museum-example.com/v1 + tags: + - name: Library + description: Access to all books. + - name: Author + description: Oxxx + paths: + /api/library/{isbn}: + summary: Library API. + description: "Contains all operations for creating, updating and fetching books." + get: + tags: + - Library + - Book + - Author + summary: Find a book by isbn. + operationId: bookByIsbn + parameters: + - name: isbn + in: path + description: Book isbn. Like IK-1900. + required: true + schema: + type: string + responses: + "200": + description: A matching book. + content: + application/json: + schema: + $ref: "#/components/schemas/Book" + "404": + description: "Not Found: If a book doesn't exist." + "400": + description: "Bad Request: For bad ISBN code." + /api/library/author/{id}: + summary: Library API. + description: "Contains all operations for creating, updating and fetching books." + get: + tags: + - Library + - Author + summary: Author by Id. + operationId: author + parameters: + - name: id + in: path + description: Author ID. + required: true + schema: + type: string + responses: + "200": + description: An author + content: + application/json: + schema: + $ref: "#/components/schemas/Author" + /api/library: + summary: Library API. + description: "Contains all operations for creating, updating and fetching books." + get: + tags: + - Library + summary: Query books. + description: By using advanced filters. + operationId: query + parameters: + - name: title + in: query + description: Book's title. + schema: + type: string + - name: author + in: query + description: Book's author. Optional. + schema: + type: string + - name: isbn + in: query + description: Book's isbn. Optional. + schema: + type: array + items: + type: string + responses: + "200": + description: Matching books. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Book" + x-badges: + - name: Beta + position: before + color: purple + post: + tags: + - Library + - Author + summary: Creates a new book. + description: Book can be created or updated. + operationId: createBook + requestBody: + description: Book to create. + content: + application/json: + schema: + $ref: "#/components/schemas/Book" + example: + isbn: X01981 + title: HarryPotter + required: true + responses: + "200": + description: Saved book. + content: + application/json: + schema: + $ref: "#/components/schemas/Book" + example: + id: generatedId + isbn: '...' + components: + schemas: + Author: + type: object + properties: + ssn: + type: string + description: Social security number. + name: + type: string + description: Author's name. + address: + $ref: "#/components/schemas/Address" + books: + uniqueItems: true + type: array + description: Published books. + items: + $ref: "#/components/schemas/Book" + BookQuery: + type: object + properties: + title: + type: string + description: Book's title. + author: + type: string + description: Book's author. Optional. + isbn: + type: array + description: Book's isbn. Optional. + items: + type: string + description: Query books by complex filters. + Address: + type: object + properties: + street: + type: string + description: Street name. + city: + type: string + description: City name. + state: + type: string + description: State. + country: + type: string + description: Two digit country code. + description: Author address. + Book: + type: object + properties: + isbn: + type: string + description: Book ISBN. Method. + title: + type: string + description: Book's title. + publicationDate: + type: string + description: Publication date. Format mm-dd-yyyy. + format: date + text: + type: string + description: Book's content. + type: + type: string + description: |- + Book type. + - Fiction: Fiction books are based on imaginary characters and events, while non-fiction books are based o n real people and events. + - NonFiction: Non-fiction genres include biography, autobiography, history, self-help, and true crime. + enum: + - Fiction + - NonFiction + authors: + uniqueItems: true + type: array + items: + $ref: "#/components/schemas/Author" + image: + type: string + format: binary + description: Book model. + """, result.toYaml()); } } diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/AppDemoLibrary.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/AppDemoLibrary.java new file mode 100644 index 0000000000..16e2158d6f --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/AppDemoLibrary.java @@ -0,0 +1,17 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3729.api; + +import static io.jooby.openapi.MvcExtensionGenerator.toMvcExtension; + +import io.jooby.Jooby; + +public class AppDemoLibrary extends Jooby { + + { + mvc(toMvcExtension(LibraryDemoApi.class)); + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/Book.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/Book.java index bfc4c2bc3e..7104e464b2 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/Book.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/Book.java @@ -8,6 +8,8 @@ import java.time.LocalDate; import java.util.Set; +import io.jooby.FileUpload; + /** Book model. */ public class Book { /** Book ISBN. */ @@ -19,6 +21,7 @@ public class Book { /** Publication date. Format mm-dd-yyyy. */ LocalDate publicationDate; + /** Book's content. */ String text; /** Book type. */ @@ -26,6 +29,8 @@ public class Book { Set authors; + FileUpload image; + /** * Book ISBN. Method. * @@ -78,4 +83,12 @@ public Set getAuthors() { public void setAuthors(Set authors) { this.authors = authors; } + + public FileUpload getImage() { + return image; + } + + public void setImage(FileUpload image) { + this.image = image; + } } diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/BookError.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/BookError.java new file mode 100644 index 0000000000..0181bf5d54 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/BookError.java @@ -0,0 +1,41 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3729.api; + +/** Book model. */ +public class BookError { + /** Book resource path. */ + private String path; + + /** Book's error message. */ + private String message; + + private int code; + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public int getCode() { + return code; + } + + public void setCode(int code) { + this.code = code; + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/BookQuery.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/BookQuery.java index 51cc1f1493..e03f0c814c 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/BookQuery.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/BookQuery.java @@ -5,6 +5,8 @@ */ package issues.i3729.api; +import java.util.List; + /** * Query books by complex filters. * @@ -12,4 +14,4 @@ * @param author Book's author. Optional. * @param isbn Book's isbn. Optional. */ -public record BookQuery(String title, String author, String isbn) {} +public record BookQuery(String title, String author, List isbn) {} diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/BookType.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/BookType.java new file mode 100644 index 0000000000..5808ec8d6e --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/BookType.java @@ -0,0 +1,49 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3729.api; + +/** Defines the format and release schedule of the item. */ +public enum BookType { + /** + * A fictional narrative story. + * + *

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

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

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

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

Examples: The New England Journal of Medicine, Harvard Law Review. These focus on academic + * research or trade news and are written by experts for other experts. + */ + JOURNAL +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java index f9e6deb147..93340cfa08 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java @@ -20,6 +20,7 @@ * @tag.description Access to all books. */ @Path("/api/library") +@Produces("application/json") public class LibraryApi { /** @@ -40,17 +41,17 @@ public Book bookByIsbn(@PathParam String isbn) throws NotFoundException, BadRequ /** * Author by Id. * - * @param id ID. + * @param id Author ID. * @return An author * @tag Author. Oxxx */ - @GET("/{id}") + @GET("/author/{id}") public Author author(@PathParam String id) { return new Author(); } /** - * Query books. + * Query books. By using advanced filters. * * @param query Book's param query. * @return Matching books. diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryDemoApi.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryDemoApi.java new file mode 100644 index 0000000000..e4824f10e1 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryDemoApi.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.i3729.api; + +import java.util.List; + +import io.jooby.annotation.*; +import io.jooby.exception.NotFoundException; +import jakarta.data.page.Page; +import jakarta.data.page.PageRequest; +import jakarta.inject.Inject; + +/** The Public Front Desk of the library. */ +@Path("/library") +public class LibraryDemoApi { + + private final LibraryRepo library; + + @Inject + public LibraryDemoApi(LibraryRepo library) { + this.library = library; + } + + /** + * Get Specific Book Details + * + *

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

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

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

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

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

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

Example: If you search for "%Harry%", it finds "Harry Potter" and "Dirty Harry". It also + * sorts the results alphabetically by title. + */ + @Query("where title like :pattern order by title") + List searchBooks(String pattern); + + /** + * A custom report that just lists the titles of new books. Useful for creating quick lists + * without loading all the book details. + * + * @param minYear The oldest year we care about (e.g., 2023). + * @return Just the names of the books. + */ + @Query("select title from Book where extract(year from publicationDate) >= :minYear") + List findRecentBookTitles(int minYear); + + // --- Saving & Deleting --- + + /** Registers a new book in the system. */ + @Insert + Book add(Book book); + + /** Saves changes made to an author's details. */ + @Update + void update(Author author); + + /** Permanently removes a book from the library. */ + @Delete + void remove(Book book); +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3729/api/ScriptLibrary.java b/modules/jooby-openapi/src/test/java/issues/i3729/api/ScriptLibrary.java index c123db911f..f4d762decf 100644 --- a/modules/jooby-openapi/src/test/java/issues/i3729/api/ScriptLibrary.java +++ b/modules/jooby-openapi/src/test/java/issues/i3729/api/ScriptLibrary.java @@ -45,20 +45,20 @@ public class ScriptLibrary extends Jooby { /* * Author by Id. * - * @param id ID. + * @param id Author ID. * @return An author * @tag Author. Oxxx * @operationId author */ get( - "/{id}", + "/author/{id}", ctx -> { var id = ctx.path("id").value(); return new Author(); }); /* - * Query books. + * Query books. By using advanced filters. * * @param query Book's param query. * @return Matching books. diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/App3820a.java b/modules/jooby-openapi/src/test/java/issues/i3820/App3820a.java new file mode 100644 index 0000000000..29925c495e --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3820/App3820a.java @@ -0,0 +1,19 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3820; + +import io.jooby.Jooby; +import issues.i3820.model.Book; + +public class App3820a extends Jooby { + { + post( + "/library/books", + ctx -> { + return ctx.body(Book.class); + }); + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/App3820b.java b/modules/jooby-openapi/src/test/java/issues/i3820/App3820b.java new file mode 100644 index 0000000000..6d13fc14fe --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3820/App3820b.java @@ -0,0 +1,29 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3820; + +import java.util.ArrayList; +import java.util.List; + +import io.jooby.Jooby; + +public class App3820b extends Jooby { + { + get( + "/strings", + ctx -> { + List strings = new ArrayList<>(); + return strings; + }); + + get( + "/string", + ctx -> { + String value = ""; + return value; + }); + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/Issue3820.java b/modules/jooby-openapi/src/test/java/issues/i3820/Issue3820.java new file mode 100644 index 0000000000..7c7a21d0fc --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3820/Issue3820.java @@ -0,0 +1,45 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3820; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.jooby.openapi.CurrentDir; +import io.jooby.openapi.OpenAPIResult; +import io.jooby.openapi.OpenAPITest; + +public class Issue3820 { + @OpenAPITest(value = App3820a.class) + public void shouldGenerateRequestBodySchema(OpenAPIResult result) { + assertThat(result.toAsciiDoc(CurrentDir.testClass(getClass(), "schema.adoc"))) + .isEqualToIgnoringNewLines( + """ + [source, json] + ---- + { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "publisher" : { + "id" : "int64", + "name" : "string" + }, + "authors" : [ { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "zip" : "string" + } + } ] + } + ----\ + """); + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java b/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java new file mode 100644 index 0000000000..a02643228e --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3820/PebbleSupportTest.java @@ -0,0 +1,1559 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3820; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; + +import io.jooby.internal.openapi.OpenAPIExt; +import io.jooby.internal.openapi.asciidoc.PebbleTemplateSupport; +import io.jooby.openapi.CurrentDir; +import io.jooby.openapi.OpenAPITest; +import io.swagger.v3.core.util.Json31; +import io.swagger.v3.core.util.Yaml31; +import io.swagger.v3.oas.models.SpecVersion; +import issues.i3729.api.AppLibrary; +import issues.i3820.app.AppLib; + +public class PebbleSupportTest { + + @OpenAPITest(value = AppLib.class) + public void routes(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + + templates + .evaluateThat("{{ routes(\"/library/books/?.*\") | table(grid=\"rows\") }}") + .isEqualToIgnoringNewLines( + """ + [cols="1,1,3a", grid="rows"] + |=== + |Method|Path|Summary + + |`+GET+` + |`+/library/books/{isbn}+` + |Get Specific Book Details + + |`+GET+` + |`+/library/books+` + |Browse Books (Paginated) + + |`+POST+` + |`+/library/books+` + |Add New Book + + |===\ + """); + + // default error map + templates + .evaluateThat("{{ routes }}") + .isEqualTo( + "[GET /library/books/{isbn}, GET /library/search, GET /library/books, POST" + + " /library/books, POST /library/authors]"); + templates + .evaluateThat("{{ routes(\"/library/books/?.*\") }}") + .isEqualTo("[GET /library/books/{isbn}, GET /library/books, POST /library/books]"); + templates.evaluate("{{ routes | json(false) }}", output -> Json31.mapper().readTree(output)); + + templates + .evaluateThat("{{ routes(\"/library/books/?.*\") | table }}") + .isEqualToIgnoringNewLines( + """ + [cols="1,1,3a", options="header"] + |=== + |Method|Path|Summary + + |`+GET+` + |`+/library/books/{isbn}+` + |Get Specific Book Details + + |`+GET+` + |`+/library/books+` + |Browse Books (Paginated) + + |`+POST+` + |`+/library/books+` + |Add New Book + + |===\ + """); + + templates + .evaluateThat("{{ routes(\"/library/books/?.*\") | list }}") + .isEqualToIgnoringNewLines( + """ + * `+GET /library/books/{isbn}+`: Get Specific Book Details + * `+GET /library/books+`: Browse Books (Paginated) + * `+POST /library/books+`: Add New Book\ + """); + } + + @OpenAPITest(value = AppLib.class) + public void statusCode(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + // default error map + templates.evaluateThat("{{ statusCode(200) }}").isEqualTo("[{code=200, reason=Success}]"); + + templates + .evaluateThat("{{ statusCode(200) | json }}") + .isEqualToIgnoringNewLines( + """ + [source, json] + ---- + { + "code" : 200, + "reason" : "Success" + } + ----\ + """); + + templates + .evaluateThat("{{ statusCode(200) | list }}") + .isEqualToIgnoringNewLines( + """ + * `+200+`: Success\ + """); + + templates + .evaluateThat("{{ statusCode(200) | table }}") + .isEqualToIgnoringNewLines( + """ + |=== + |code|reason + + |200 + |Success + + |===\ + """); + + templates + .evaluateThat("{{ statusCode([200, 201]) | table }}") + .isEqualToIgnoringNewLines( + """ + |=== + |code|reason + + |200 + |Success + + |201 + |Created + + |===\ + """); + + templates + .evaluateThat("{{ statusCode([200, 201]) | list }}") + .isEqualToIgnoringNewLines( + """ + * `+200+`: Success + * `+201+`: Created\ + """); + + templates + .evaluateThat("{{ statusCode({200: \"OK\", 500: \"Internal Server Error\"}) | list }}") + .isEqualToIgnoringNewLines( + """ + * `+200+`: OK + * `+500+`: Internal Server Error\ + """); + } + + @OpenAPITest(value = AppLibrary.class) + public void bodyBug(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + + templates.evaluateThat("{{ GET(\"/api/library/{isbn}\") | response | body | json(false) }}"); + templates + .evaluateThat("{{ GET(\"/api/library/{isbn}\") | response | body | json(false) }}") + .isEqualToIgnoringNewLines( + """ + { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "authors" : [ { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "state" : "string", + "country" : "string" + }, + "books" : [ { } ] + } ], + "image" : "binary" + }\ + """); + } + + @OpenAPITest(value = AppLib.class, version = SpecVersion.V31) + public void shouldSupportJsonSchemaInV31(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + templates + .evaluateThat("{{ POST(\"/library/books\") | request | body | json(false) }}") + .isEqualToIgnoringNewLines( + """ + { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "publisher" : { + "id" : "int64", + "name" : "string" + }, + "authors" : [ { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "zip" : "string" + } + } ] + }\ + """); + } + + @OpenAPITest(value = AppLib.class) + public void errorMap(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + templates + .evaluateThat( + """ + {{ error(400) | json }} + """) + .isEqualToIgnoringNewLines( + """ + [source, json] + ---- + { + "message" : "...", + "reason" : "Bad Request", + "statusCode" : 400 + } + ----\ + """); + // default error map + templates + .evaluateThat( + """ + {{ error(code=400) | json }} + """) + .isEqualToIgnoringNewLines( + """ + [source, json] + ---- + { + "message" : "...", + "reason" : "Bad Request", + "statusCode" : 400 + } + ----\ + """); + + templates + .evaluateThat( + """ + {{ error(code=400) | list }} + """) + .isEqualToIgnoringNewLines( + """ + * message: ... + * reason: Bad Request + * statusCode: 400\ + """); + + templates + .evaluateThat( + """ + {{ error(code=400) | table }} + """) + .isEqualToIgnoringNewLines( + """ + |=== + |message|reason|statusCode + + |... + |Bad Request + |400 + + |===\ + """); + + templates + .evaluateThat( + """ + {%- set error = {"code": 500, "message": "{{code.reason}}", "time": now } -%} + {{ error(code=402) | json }} + """) + .isEqualToIgnoringNewLines( + String.format( + """ + [source, json] + ---- + { + "code" : 402, + "message" : "Payment Required", + "time" : "%s" + } + ----\ + """, + templates.getContext().getNow())); + } + + @OpenAPITest(value = AppLib.class) + public void openApi(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + templates.evaluate( + "{{openapi | json(wrap=false) }}", + output -> { + Json31.mapper().readTree(output); + }); + + templates.evaluate( + "{{GET(\"/library/search\") | json(false)}}", + output -> { + Json31.mapper().readTree(output); + }); + + templates.evaluate( + "{{openapi | yaml}}", + output -> { + Yaml31.mapper().readTree(output); + }); + + templates.evaluate( + "{{GET(\"/library/search\") | yaml}}", + output -> { + Yaml31.mapper().readTree(output); + }); + } + + @OpenAPITest(value = AppLib.class) + public void tags(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + templates + .evaluateThat("{{tag(\"Library\").description }}") + .isEqualToIgnoringNewLines( + "Outlines the available actions in the Library System API. The system is designed to" + + " allow users to search for books, view details, and manage the library" + + " inventory."); + + templates + .evaluateThat( + """ + {% for tag in tags %} + == {{ tag.name }} + + {{ tag.description }} + + // 2. Loop through all routes associated with this tag + {% for route in tag.routes %} + === {{ route.summary }} + + {{ route.description }} + + *URL:* `{{ route.path }}` ({{ route.method }}) + + {% if route.parameters is not empty %} + *Parameters:* + {{ route | parameters | table }} + {% endif %} + + // Only show Request Body if it exists (e.g. for POST/PUT) + {% if route.body is not null %} + *Data Payload:* + {{ route | request | body | json }} + {% endif %} + + // Example response for success + .Response + {{ route | response(200) | json }} + + {% if route.security is not empty %} + .Required Permissions + [cols="1,3"] + |=== + |Type | Scopes + + {# Iterate through security schemes #} + {% for scheme in route.security %} + {% for req in scheme %} + | *{{ req.key }}* | {{ req.value | join(", ") }} + {% endfor %} + {% endfor %} + |=== + + {% endif %} + {% endfor %} + {% endfor %} + """) + .isEqualToIgnoringNewLines( + """ + == Library + Outlines the available actions in the Library System API. The system is designed to allow users to search for books, view details, and manage the library inventory. + // 2. Loop through all routes associated with this tag + === Get Specific Book Details + View the full information for a single specific book using its unique ISBN. + *URL:* `/library/books/{isbn}` (GET) + + *Parameters:* + [cols="1,1,1,3", options="header"] + |=== + |Name|Type|In|Description + |`+isbn+` + |`+string+` + |`+path+` + |The unique ID from the URL (e.g., /books/978-3-16-148410-0) + + |=== + // Only show Request Body if it exists (e.g. for POST/PUT) + + // Example response for success + .Response + [source, json] + ---- + { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "publisher" : { + "id" : "int64", + "name" : "string" + }, + "authors" : [ { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "zip" : "string" + } + } ] + } + ---- + .Required Permissions + [cols="1,3"] + |=== + |Type | Scopes + + | *librarySecurity* | read:books|=== + + === Quick Search + Find books by a partial title (e.g., searching "Harry" finds "Harry Potter"). + *URL:* `/library/search` (GET) + + *Parameters:* + [cols="1,1,1,3", options="header"] + |=== + |Name|Type|In|Description + |`+q+` + |`+string+` + |`+query+` + |The word or phrase to search for. + + |=== + // Only show Request Body if it exists (e.g. for POST/PUT) + + // Example response for success + .Response + [source, json] + ---- + [ { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "publisher" : { + "id" : "int64", + "name" : "string" + }, + "authors" : [ { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "zip" : "string" + } + } ] + } ] + ---- + .Required Permissions + [cols="1,3"] + |=== + |Type | Scopes + + | *librarySecurity* | read:books|=== + + === Browse Books (Paginated) + Look up a specific book title where there might be many editions or copies, splitting the results into manageable pages. + *URL:* `/library/books` (GET) + + *Parameters:* + [cols="1,1,1,3", options="header"] + |=== + |Name|Type|In|Description + |`+Accept+` + |`+string+` + |`+header+` + | + + |`+title+` + |`+string+` + |`+query+` + |The exact book title to filter by. + + |`+page+` + |`+int32+` + |`+query+` + |Which page number to load (defaults to 1). + + |`+size+` + |`+int32+` + |`+query+` + |How many books to show per page (defaults to 20). + + |=== + // Only show Request Body if it exists (e.g. for POST/PUT) + + // Example response for success + .Response + [source, json] + ---- + { + "content" : [ { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "publisher" : { + "id" : "int64", + "name" : "string" + }, + "authors" : [ { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "zip" : "string" + } + } ] + } ], + "numberOfElements" : "int32", + "totalElements" : "int64", + "totalPages" : "int64", + "pageRequest" : { + "page" : "int64", + "size" : "int32" + }, + "nextPageRequest" : { }, + "previousPageRequest" : { } + } + ---- + .Required Permissions + [cols="1,3"] + |=== + |Type | Scopes + + | *librarySecurity* | read:books|=== + + == Inventory + Managing Inventory + // 2. Loop through all routes associated with this tag + === Add New Book + Register a new book in the system. + *URL:* `/library/books` (POST) + + *Parameters:* + [cols="1,1,1,3", options="header"] + |=== + |Name|Type|In|Description + |`+Accept+` + |`+string+` + |`+header+` + | + + |`+Content-Type+` + |`+string+` + |`+header+` + | + + |=== + // Only show Request Body if it exists (e.g. for POST/PUT) + *Data Payload:* + [source, json] + ---- + { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "publisher" : { + "id" : "int64", + "name" : "string" + }, + "authors" : [ { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "zip" : "string" + } + } ] + } + ---- + // Example response for success + .Response + [source, json] + ---- + { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "publisher" : { + "id" : "int64", + "name" : "string" + }, + "authors" : [ { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "zip" : "string" + } + } ] + } + ---- + .Required Permissions + [cols="1,3"] + |=== + |Type | Scopes + + | *librarySecurity* | write:books|=== + + === Add New Author + + *URL:* `/library/authors` (POST) + + + // Only show Request Body if it exists (e.g. for POST/PUT) + *Data Payload:* + [source, json] + ---- + { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "zip" : "string" + } + } + ---- + // Example response for success + .Response + [source, json] + ---- + { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "zip" : "string" + } + } + ---- + .Required Permissions + [cols="1,3"] + |=== + |Type | Scopes + + | *librarySecurity* | write:author + |===\ + """); + } + + @OpenAPITest(value = AppLib.class) + public void server(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + templates.evaluateThat("{{ server(0).url }}").isEqualTo("https://library.jooby.io"); + + templates + .evaluateThat("{{ server(\"Production\").url }}") + .isEqualTo("https://library.jooby.io"); + } + + @OpenAPITest(value = AppLib.class) + public void schema(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + + templates + .evaluateThat("{{schema(\"Book\") | truncate | json}}") + .isEqualToIgnoringNewLines( + """ + [source, json] + ---- + { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "publisher" : { }, + "authors" : [ { } ] + } + ----\ + """); + + templates + .evaluateThat("{{schema(\"Book\") | truncate | yaml(false) }}") + .isEqualToIgnoringNewLines( + """ + isbn: string + title: string + publicationDate: date + text: string + type: string + publisher: {} + authors: + - {}\ + """); + + // example on same schema must generate same output + var output = templates.evaluate("{{schema(\"Book\") | example | json}}"); + assertEquals(output, templates.evaluate("{{schema(\"Book\") | example | json}}")); + + var yamlOutput = templates.evaluate("{{model(\"Book\") | example | yaml}}"); + assertEquals(yamlOutput, templates.evaluate("{{model(\"Book\") | example | yaml}}")); + + templates + .evaluateThat("{{schema(\"Book\") | json(false) }}") + .isEqualToIgnoringNewLines( + """ + { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "publisher" : { + "id" : "int64", + "name" : "string" + }, + "authors" : [ { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "zip" : "string" + } + } ] + } + """); + + templates + .evaluateThat("{{schema(\"Address\") | table }}") + .isEqualToIgnoringNewLines( + """ + [cols="1,1,3", options="header"] + |=== + |Name|Type|Description + |`+street+` + |`+string+` + |The specific street address. Includes the house number, street name, and apartment number if applicable. Example: "123 Maple Avenue, Apt 4B". + + |`+city+` + |`+string+` + |The town, city, or municipality. Used for grouping authors by location or calculating shipping regions. + + |`+zip+` + |`+string+` + |The postal or zip code. Stored as text (String) rather than a number to support codes that start with zero (e.g., "02138") or contain letters (e.g., "K1A 0B1"). + + |===\ + """); + + templates + .evaluateThat("{{schema(\"Address\") | list }}") + .isEqualToIgnoringNewLines( + """ + street:: + * type: `+string+` + * description: The specific street address. Includes the house number, street name, and apartment number if applicable. Example: "123 Maple Avenue, Apt 4B". + city:: + * type: `+string+` + * description: The town, city, or municipality. Used for grouping authors by location or calculating shipping regions. + zip:: + * type: `+string+` + * description: The postal or zip code. Stored as text (String) rather than a number to support codes that start with zero (e.g., "02138") or contain letters (e.g., "K1A 0B1").\ + """); + + templates + .evaluateThat("{{schema(\"Book.type\") | list }}") + .isEqualToIgnoringNewLines( + """ + *NOVEL*:: + * A fictional narrative story. Examples: "Pride and Prejudice", "Harry Potter", "Dune". These are creative works meant for entertainment or artistic expression. + *BIOGRAPHY*:: + * A written account of a real person's life. Examples: "Steve Jobs" by Walter Isaacson, "The Diary of a Young Girl". These are non-fiction historical records of an individual. + *TEXTBOOK*:: + * An educational book used for study. Examples: "Calculus: Early Transcendentals", "Introduction to Java Programming". These are designed for students and are often used as reference material in academic courses. + *MAGAZINE*:: + * A periodical publication intended for general readers. Examples: Time, National Geographic, Vogue. These contain various articles, are published frequently (weekly/monthly), and often have a glossy format. + *JOURNAL*:: + * A scholarly or professional publication. Examples: The New England Journal of Medicine, Harvard Law Review. These focus on academic research or trade news and are written by experts for other experts.\ + """); + templates + .evaluateThat("{{schema(\"Book.type\") | table }}") + .isEqualToIgnoringNewLines( + """ + [cols="1,3", options="header"] + |=== + |Name|Description + | *NOVEL* + | A fictional narrative story. Examples: "Pride and Prejudice", "Harry Potter", "Dune". These are creative works meant for entertainment or artistic expression. + | *BIOGRAPHY* + | A written account of a real person's life. Examples: "Steve Jobs" by Walter Isaacson, "The Diary of a Young Girl". These are non-fiction historical records of an individual. + | *TEXTBOOK* + | An educational book used for study. Examples: "Calculus: Early Transcendentals", "Introduction to Java Programming". These are designed for students and are often used as reference material in academic courses. + | *MAGAZINE* + | A periodical publication intended for general readers. Examples: Time, National Geographic, Vogue. These contain various articles, are published frequently (weekly/monthly), and often have a glossy format. + | *JOURNAL* + | A scholarly or professional publication. Examples: The New England Journal of Medicine, Harvard Law Review. These focus on academic research or trade news and are written by experts for other experts. + |===\ + """); + } + + @OpenAPITest(value = AppLib.class) + public void curl(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + + templates + .evaluateThat("{{POST(\"/library/authors\") | curl(\"-i\", language=\"bash\") }}") + .isEqualToIgnoringNewLines( + """ + [source, bash] + ---- + curl -i\\ + --data-urlencode "ssn=string"\\ + --data-urlencode "name=string"\\ + --data-urlencode "address.street=string"\\ + --data-urlencode "address.city=string"\\ + --data-urlencode "address.zip=string"\\ + -X POST '/library/authors' + ----\ + """); + + templates + .evaluateThat("{{POST(\"/library/authors\") | curl }}") + .isEqualToIgnoringNewLines( + """ + [source] + ---- + curl --data-urlencode "ssn=string"\\ + --data-urlencode "name=string"\\ + --data-urlencode "address.street=string"\\ + --data-urlencode "address.city=string"\\ + --data-urlencode "address.zip=string"\\ + -X POST '/library/authors' + ----\ + """); + + templates + .evaluateThat("{{POST(\"/library/books\") | request | curl }}") + .isEqualToIgnoringNewLines( + """ + [source] + ---- + curl -H 'Accept: application/json'\\ + -H 'Content-Type: application/json'\\ + -d '{"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","publisher":{"id":"int64","name":"string"},"authors":[{"ssn":"string","name":"string","address":{"street":"string","city":"string","zip":"string"}}]}'\\ + -X POST '/library/books' + ----\ + """); + + templates + .evaluateThat("{{GET(\"/library/books\") | request | curl }}") + .isEqualToIgnoringNewLines( + """ + [source] + ---- + curl -H 'Accept: application/json'\\ + -X GET '/library/books?title=string&page=int32&size=int32' + ----\ + """); + + templates + .evaluateThat("{{GET(\"/library/books/{isbn}\") | request | curl }}") + .isEqualToIgnoringNewLines( + """ + [source] + ---- + curl -X GET '/library/books/{isbn}' + ----\ + """); + + templates + .evaluateThat("{{GET(\"/library/books/{isbn}\") | request | curl(\"-i\") }}") + .isEqualToIgnoringNewLines( + """ + [source] + ---- + curl -i\\ + -X GET '/library/books/{isbn}' + ----\ + """); + + templates + .evaluateThat( + "{{GET(\"/library/books/{isbn}\") | request | curl(\"-i\", \"-X\", \"POST\") }}") + .isEqualToIgnoringNewLines( + """ + [source] + ---- + curl -i\\ + -X POST '/library/books/{isbn}' + ----\ + """); + + templates + .evaluateThat( + "{{GET(\"/library/books/{isbn}\") | request | curl(\"-i\", \"-X\", \"POST\") }}") + .isEqualToIgnoringNewLines( + """ + [source] + ---- + curl -i\\ + -X POST '/library/books/{isbn}' + ----\ + """); + + templates + .evaluateThat( + "{{GET(\"/library/books\") | request | curl(\"-H\", \"'Accept: application/xml'\") }}") + .isEqualToIgnoringNewLines( + """ + [source] + ---- + curl -H 'Accept: application/xml'\\ + -X GET '/library/books?title=string&page=int32&size=int32' + ----\ + """); + } + + @OpenAPITest(value = AppLib.class) + public void link(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + + templates + .evaluateThat("{{GET(\"/library/books\") | response | link }}") + .isEqualTo("Page[<>]"); + + templates + .evaluateThat("{{GET(\"/library/search\") | response | link }}") + .isEqualTo("<>[]"); + } + + @OpenAPITest(value = App3820b.class) + public void checkPrimitives(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + + templates.evaluateThat("{{GET(\"/strings\") | response | link }}").isEqualTo("string[]"); + + templates + .evaluateThat("{{GET(\"/strings\") | response | json }}") + .isEqualToIgnoringNewLines( + """ + [source, json] + ---- + [ "string" ] + ----\ + """); + + templates + .evaluateThat("{{GET(\"/string\") | response | json }}") + .isEqualToIgnoringNewLines( + """ + [source, json] + ---- + "string" + ----\ + """); + } + + @OpenAPITest(value = AppLib.class) + public void response(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + + templates + .evaluateThat("{{GET(\"/library/books\") | response | json }}") + .isEqualToIgnoringNewLines( + """ + [source, json] + ---- + { + "content" : [ { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "publisher" : { + "id" : "int64", + "name" : "string" + }, + "authors" : [ { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "zip" : "string" + } + } ] + } ], + "numberOfElements" : "int32", + "totalElements" : "int64", + "totalPages" : "int64", + "pageRequest" : { + "page" : "int64", + "size" : "int32" + }, + "nextPageRequest" : { }, + "previousPageRequest" : { } + } + ----\ + """); + + templates + .evaluateThat("{{GET(\"/library/search\") | response | json }}") + .isEqualToIgnoringNewLines( + """ + [source, json] + ---- + [ { + "isbn" : "string", + "title" : "string", + "publicationDate" : "date", + "text" : "string", + "type" : "string", + "publisher" : { + "id" : "int64", + "name" : "string" + }, + "authors" : [ { + "ssn" : "string", + "name" : "string", + "address" : { + "street" : "string", + "city" : "string", + "zip" : "string" + } + } ] + } ] + ----\ + """); + + /* Error response code: */ + templates + .evaluateThat("{{GET(\"/library/books/{isbn}\") | response(code=404) | json }}") + .isEqualToIgnoringNewLines( + """ + [source, json] + ---- + { + "message" : "Not Found: error if it doesn't exist.", + "reason" : "Not Found", + "statusCode" : 404 + } + ----\ + """); + + templates + .evaluateThat("{{GET(\"/library/books/{isbn}\") | response(code=404) | http }}") + .isEqualToIgnoringNewLines( + """ + [source,http,options="nowrap"] + ---- + HTTP/1.1 404 Not Found + ----\ + """); + + /* Override default response code: */ + templates + .evaluateThat("{{POST(\"/library/books\") | response(code=201) | http }}") + .isEqualToIgnoringNewLines( + """ + [source,http,options="nowrap"] + ---- + HTTP/1.1 201 Created + Content-Type: application/json + {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","publisher":{"id":"int64","name":"string"},"authors":[{"ssn":"string","name":"string","address":{"street":"string","city":"string","zip":"string"}}]} + ----\ + """); + + /* Default response */ + templates + .evaluateThat("{{POST(\"/library/books\") | response | http }}") + .isEqualToIgnoringNewLines( + """ + [source,http,options="nowrap"] + ---- + HTTP/1.1 200 Success + Content-Type: application/json + {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","publisher":{"id":"int64","name":"string"},"authors":[{"ssn":"string","name":"string","address":{"street":"string","city":"string","zip":"string"}}]} + ----\ + """); + + templates + .evaluateThat("{{POST(\"/library/books\") | response | list }}") + .isEqualToIgnoringNewLines( + """ + isbn:: + * type: `+string+` + * description: The unique "barcode" for this book (ISBN). We use this to identify exactly which book edition we are talking about. + title:: + * type: `+string+` + * description: The name printed on the cover. + publicationDate:: + * type: `+date+` + * description: When this book was released to the public. + text:: + * type: `+string+` + * description: The full story or content of the book. Since this can be very long, we store it in a special way (Large Object) to keep the database fast. + type:: + * type: `+string+` + * description: Defines the format and release schedule of the item. + ** *NOVEL*: A fictional narrative story. Examples: "Pride and Prejudice", "Harry Potter", "Dune". These are creative works meant for entertainment or artistic expression. + ** *BIOGRAPHY*: A written account of a real person's life. Examples: "Steve Jobs" by Walter Isaacson, "The Diary of a Young Girl". These are non-fiction historical records of an individual. + ** *TEXTBOOK*: An educational book used for study. Examples: "Calculus: Early Transcendentals", "Introduction to Java Programming". These are designed for students and are often used as reference material in academic courses. + ** *MAGAZINE*: A periodical publication intended for general readers. Examples: Time, National Geographic, Vogue. These contain various articles, are published frequently (weekly/monthly), and often have a glossy format. + ** *JOURNAL*: A scholarly or professional publication. Examples: The New England Journal of Medicine, Harvard Law Review. These focus on academic research or trade news and are written by experts for other experts. + publisher:: + * type: `+object+` + * description: A company that produces and sells books. + authors:: + * type: `+array+` + * description: The list of people who wrote this book.\ + """); + + templates + .evaluateThat("{{POST(\"/library/books\") | response | table }}") + .isEqualToIgnoringNewLines( + """ + [cols="1,1,3a", options="header"] + |=== + |Name|Type|Description + |`+isbn+` + |`+string+` + |The unique "barcode" for this book (ISBN). We use this to identify exactly which book edition we are talking about. + + |`+title+` + |`+string+` + |The name printed on the cover. + + |`+publicationDate+` + |`+date+` + |When this book was released to the public. + + |`+text+` + |`+string+` + |The full story or content of the book. Since this can be very long, we store it in a special way (Large Object) to keep the database fast. + + |`+type+` + |`+string+` + |Defines the format and release schedule of the item. + + * *NOVEL*: A fictional narrative story. Examples: "Pride and Prejudice", "Harry Potter", "Dune". These are creative works meant for entertainment or artistic expression. + * *BIOGRAPHY*: A written account of a real person's life. Examples: "Steve Jobs" by Walter Isaacson, "The Diary of a Young Girl". These are non-fiction historical records of an individual. + * *TEXTBOOK*: An educational book used for study. Examples: "Calculus: Early Transcendentals", "Introduction to Java Programming". These are designed for students and are often used as reference material in academic courses. + * *MAGAZINE*: A periodical publication intended for general readers. Examples: Time, National Geographic, Vogue. These contain various articles, are published frequently (weekly/monthly), and often have a glossy format. + * *JOURNAL*: A scholarly or professional publication. Examples: The New England Journal of Medicine, Harvard Law Review. These focus on academic research or trade news and are written by experts for other experts. + + |`+publisher+` + |`+object+` + |A company that produces and sells books. + + |`+authors+` + |`+array+` + |The list of people who wrote this book. + + |===\ + """); + } + + @OpenAPITest(value = AppLib.class) + public void request(OpenAPIExt openapi) throws IOException { + var templates = new PebbleTemplateSupport(CurrentDir.testClass(getClass(), "adoc"), openapi); + + templates + .evaluateThat("{{GET(\"/library/books\") | request | path }}") + .isEqualTo("/library/books?title=string&page=int32&size=int32"); + + templates + .evaluateThat("{{GET(\"/library/books\") | request | path(title=\"...\") }}") + .isEqualTo("/library/books?title=..."); + + templates + .evaluateThat("{{GET(\"/library/books\") | request | path(title=\"...\", page=1) }}") + .isEqualTo("/library/books?title=...&page=1"); + + templates + .evaluateThat("{{GET(\"/library/books\") | request | path(title=\"word space\", page=1) }}") + .isEqualTo("/library/books?title=word%20space&page=1"); + + templates + .evaluateThat("{{GET(\"/library/books/{isbn}\") | request | path }}") + .isEqualTo("/library/books/{isbn}"); + + templates + .evaluateThat("{{GET(\"/library/books/{isbn}\") | request | path(isbn=123) }}") + .isEqualTo("/library/books/123"); + + templates + .evaluateThat("{{POST(\"/library/books\") | request | list }}") + .isEqualToIgnoringNewLines( + """ + Accept:: + * type: `+string+` + * in: `+header+` + Content-Type:: + * type: `+string+` + * in: `+header+` + isbn:: + * type: `+string+` + * in: `+body+` + * description: The unique "barcode" for this book (ISBN). We use this to identify exactly which book edition we are talking about. + title:: + * type: `+string+` + * in: `+body+` + * description: The name printed on the cover. + publicationDate:: + * type: `+date+` + * in: `+body+` + * description: When this book was released to the public. + text:: + * type: `+string+` + * in: `+body+` + * description: The full story or content of the book. Since this can be very long, we store it in a special way (Large Object) to keep the database fast. + type:: + * type: `+string+` + * in: `+body+` + * description: Defines the format and release schedule of the item. + ** *NOVEL*: A fictional narrative story. Examples: "Pride and Prejudice", "Harry Potter", "Dune". These are creative works meant for entertainment or artistic expression. + ** *BIOGRAPHY*: A written account of a real person's life. Examples: "Steve Jobs" by Walter Isaacson, "The Diary of a Young Girl". These are non-fiction historical records of an individual. + ** *TEXTBOOK*: An educational book used for study. Examples: "Calculus: Early Transcendentals", "Introduction to Java Programming". These are designed for students and are often used as reference material in academic courses. + ** *MAGAZINE*: A periodical publication intended for general readers. Examples: Time, National Geographic, Vogue. These contain various articles, are published frequently (weekly/monthly), and often have a glossy format. + ** *JOURNAL*: A scholarly or professional publication. Examples: The New England Journal of Medicine, Harvard Law Review. These focus on academic research or trade news and are written by experts for other experts. + publisher:: + * type: `+object+` + * in: `+body+` + * description: A company that produces and sells books. + authors:: + * type: `+array+` + * in: `+body+` + * description: The list of people who wrote this book.\ + """); + + templates + .evaluateThat("{{POST(\"/library/books\") | request | table }}") + .isEqualToIgnoringNewLines( + """ + [cols="1,1,1,3a", options="header"] + |=== + |Name|Type|In|Description + |`+Accept+` + |`+string+` + |`+header+` + | + + |`+Content-Type+` + |`+string+` + |`+header+` + | + + |`+isbn+` + |`+string+` + |`+body+` + |The unique "barcode" for this book (ISBN). We use this to identify exactly which book edition we are talking about. + + |`+title+` + |`+string+` + |`+body+` + |The name printed on the cover. + + |`+publicationDate+` + |`+date+` + |`+body+` + |When this book was released to the public. + + |`+text+` + |`+string+` + |`+body+` + |The full story or content of the book. Since this can be very long, we store it in a special way (Large Object) to keep the database fast. + + |`+type+` + |`+string+` + |`+body+` + |Defines the format and release schedule of the item. + + * *NOVEL*: A fictional narrative story. Examples: "Pride and Prejudice", "Harry Potter", "Dune". These are creative works meant for entertainment or artistic expression. + * *BIOGRAPHY*: A written account of a real person's life. Examples: "Steve Jobs" by Walter Isaacson, "The Diary of a Young Girl". These are non-fiction historical records of an individual. + * *TEXTBOOK*: An educational book used for study. Examples: "Calculus: Early Transcendentals", "Introduction to Java Programming". These are designed for students and are often used as reference material in academic courses. + * *MAGAZINE*: A periodical publication intended for general readers. Examples: Time, National Geographic, Vogue. These contain various articles, are published frequently (weekly/monthly), and often have a glossy format. + * *JOURNAL*: A scholarly or professional publication. Examples: The New England Journal of Medicine, Harvard Law Review. These focus on academic research or trade news and are written by experts for other experts. + + |`+publisher+` + |`+object+` + |`+body+` + |A company that produces and sells books. + + |`+authors+` + |`+array+` + |`+body+` + |The list of people who wrote this book. + + |===\ + """); + + templates + .evaluateThat("{{GET(\"/library/books\") | request | table }}") + .isEqualToIgnoringNewLines( + """ + [cols="1,1,1,3", options="header"] + |=== + |Name|Type|In|Description + |`+Accept+` + |`+string+` + |`+header+` + | + + |`+title+` + |`+string+` + |`+query+` + |The exact book title to filter by. + + |`+page+` + |`+int32+` + |`+query+` + |Which page number to load (defaults to 1). + + |`+size+` + |`+int32+` + |`+query+` + |How many books to show per page (defaults to 20). + + |===\ + """); + + templates + .evaluateThat("{{GET(\"/library/books\") | request | parameters(query) | table }}") + .isEqualToIgnoringNewLines( + """ + [cols="1,1,3", options="header"] + |=== + |Name|Type|Description + |`+title+` + |`+string+` + |The exact book title to filter by. + + |`+page+` + |`+int32+` + |Which page number to load (defaults to 1). + + |`+size+` + |`+int32+` + |How many books to show per page (defaults to 20). + + |===\ + """); + + templates + .evaluateThat( + "{{GET(\"/library/books\") | request | parameters(query, ['title']) | table }}") + .isEqualToIgnoringNewLines( + """ + [cols="1,1,3", options="header"] + |=== + |Name|Type|Description + |`+title+` + |`+string+` + |The exact book title to filter by. + + |===\ + """); + + templates + .evaluateThat("{{GET(\"/library/books\") | request | parameters('path') | table }}") + .isEqualToIgnoringNewLines( + """ + [cols="1,1,3", options="header"] + |=== + |Name|Type|Description + |===\ + """); + + templates + .evaluateThat("{{GET(\"/library/books\") | parameters('path') | table }}") + .isEqualToIgnoringNewLines( + """ + [cols="1,1,3", options="header"] + |=== + |Name|Type|Description + |===\ + """); + + templates + .evaluateThat("{{GET(\"/library/books\") | parameters(cookie) | table }}") + .isEqualToIgnoringNewLines( + """ + [cols="1,3", options="header"] + |=== + |Name|Description + |===\ + """); + + templates + .evaluateThat("{{POST(\"/library/books\") | request(body=\"none\") | http }}") + .isEqualToIgnoringNewLines( + """ + [source,http,options="nowrap"] + ---- + POST /library/books HTTP/1.1 + Accept: application/json + Content-Type: application/json + {} + ----\ + """); + + // example on same schema must generate same output + templates + .evaluateThat("{{GET(\"/library/books\") | request }}") + .isEqualTo("GET /library/books"); + + // example on same schema must generate same output + templates + .evaluateThat("{{GET(\"/library/books\") | request | http }}") + .isEqualToIgnoringNewLines( + """ + [source,http,options="nowrap"] + ---- + GET /library/books HTTP/1.1 + Accept: application/json + ----\ + """); + + templates + .evaluateThat("{{POST(\"/library/books\") | request | http }}") + .isEqualToIgnoringNewLines( + """ + [source,http,options="nowrap"] + ---- + POST /library/books HTTP/1.1 + Accept: application/json + Content-Type: application/json + {"isbn":"string","title":"string","publicationDate":"date","text":"string","type":"string","publisher":{"id":"int64","name":"string"},"authors":[{"ssn":"string","name":"string","address":{"street":"string","city":"string","zip":"string"}}]} + ----\ + """); + + templates + .evaluateThat("{{POST(\"/library/books\") | request | parameters(header) | table }}") + .isEqualToIgnoringNewLines( + """ + [cols="1,3", options="header"] + |=== + |Name|Description + |`+Accept+` + | + + |`+Content-Type+` + | + + |===\ + """); + + templates + .evaluateThat( + "{{POST(\"/library/books\") | request | parameters(header) | table(columns=['name'])" + + " }}") + .isEqualToIgnoringNewLines( + """ + [cols="1", options="header"] + |=== + |Name + |`+Accept+` + + |`+Content-Type+` + + |===\ + """); + + templates + .evaluateThat("{{POST(\"/library/books\") | request | body | table }}") + .isEqualToIgnoringNewLines( + """ + [cols="1,1,3a", options="header"] + |=== + |Name|Type|Description + |`+isbn+` + |`+string+` + |The unique "barcode" for this book (ISBN). We use this to identify exactly which book edition we are talking about. + + |`+title+` + |`+string+` + |The name printed on the cover. + + |`+publicationDate+` + |`+date+` + |When this book was released to the public. + + |`+text+` + |`+string+` + |The full story or content of the book. Since this can be very long, we store it in a special way (Large Object) to keep the database fast. + + |`+type+` + |`+string+` + |Defines the format and release schedule of the item. + + * *NOVEL*: A fictional narrative story. Examples: "Pride and Prejudice", "Harry Potter", "Dune". These are creative works meant for entertainment or artistic expression. + * *BIOGRAPHY*: A written account of a real person's life. Examples: "Steve Jobs" by Walter Isaacson, "The Diary of a Young Girl". These are non-fiction historical records of an individual. + * *TEXTBOOK*: An educational book used for study. Examples: "Calculus: Early Transcendentals", "Introduction to Java Programming". These are designed for students and are often used as reference material in academic courses. + * *MAGAZINE*: A periodical publication intended for general readers. Examples: Time, National Geographic, Vogue. These contain various articles, are published frequently (weekly/monthly), and often have a glossy format. + * *JOURNAL*: A scholarly or professional publication. Examples: The New England Journal of Medicine, Harvard Law Review. These focus on academic research or trade news and are written by experts for other experts. + + |`+publisher+` + |`+object+` + |A company that produces and sells books. + + |`+authors+` + |`+array+` + |The list of people who wrote this book. + + |===\ + """); + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/app/AppLib.java b/modules/jooby-openapi/src/test/java/issues/i3820/app/AppLib.java new file mode 100644 index 0000000000..309b32a6ce --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3820/app/AppLib.java @@ -0,0 +1,42 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3820.app; + +import static io.jooby.openapi.MvcExtensionGenerator.toMvcExtension; + +import io.jooby.Jooby; + +/** + * Library API. + * + *

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

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

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

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

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

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

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

Example: If you search for "%Harry%", it finds "Harry Potter" and "Dirty Harry". It also + * sorts the results alphabetically by title. + */ + @Query("where title like :pattern order by title") + List searchBooks(String pattern); + + /** + * A custom report that just lists the titles of new books. Useful for creating quick lists + * without loading all the book details. + * + * @param minYear The oldest year we care about (e.g., 2023). + * @return Just the names of the books. + */ + @Query("select title from Book where extract(year from publicationDate) >= :minYear") + List findRecentBookTitles(int minYear); + + // --- Saving & Deleting --- + + /** Registers a new book in the system. */ + @Insert + Book add(Book book); + + /** Saves changes made to an author's details. */ + @Update + void update(Author author); + + /** Permanently removes a book from the library. */ + @Delete + void remove(Book book); +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/model/Address.java b/modules/jooby-openapi/src/test/java/issues/i3820/model/Address.java new file mode 100644 index 0000000000..c0a010fca8 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3820/model/Address.java @@ -0,0 +1,35 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3820.model; + +/** + * A reusable way to store address details (Street, City, Zip). We can reuse this on Authors, + * Publishers, or Users. + */ +public class Address { + /** + * The specific street address. + * + *

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

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

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

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

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

Performance Note: We only load this information if you specifically ask for it ("Lazy"), + * which saves memory. + */ + private Publisher publisher; + + /** The list of people who wrote this book. */ + private Set authors = new HashSet<>(); + + public Book() {} + + public Book(String isbn, String title, BookType type) { + this.isbn = isbn; + this.title = title; + this.type = type; + this.text = "Content placeholder"; + } + + public String getIsbn() { + return isbn; + } + + public void setIsbn(String isbn) { + this.isbn = isbn; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public LocalDate getPublicationDate() { + return publicationDate; + } + + public void setPublicationDate(LocalDate publicationDate) { + this.publicationDate = publicationDate; + } + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } + + public BookType getType() { + return type; + } + + public void setType(BookType type) { + this.type = type; + } + + public Publisher getPublisher() { + return publisher; + } + + public void setPublisher(Publisher publisher) { + this.publisher = publisher; + } + + public Set getAuthors() { + return authors; + } + + public void setAuthors(Set authors) { + this.authors = authors; + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/model/BookType.java b/modules/jooby-openapi/src/test/java/issues/i3820/model/BookType.java new file mode 100644 index 0000000000..c0dd6d6382 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3820/model/BookType.java @@ -0,0 +1,49 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3820.model; + +/** Defines the format and release schedule of the item. */ +public enum BookType { + /** + * A fictional narrative story. + * + *

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

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

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

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

Examples: The New England Journal of Medicine, Harvard Law Review. These focus on academic + * research or trade news and are written by experts for other experts. + */ + JOURNAL +} diff --git a/modules/jooby-openapi/src/test/java/issues/i3820/model/Publisher.java b/modules/jooby-openapi/src/test/java/issues/i3820/model/Publisher.java new file mode 100644 index 0000000000..322e09341d --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/i3820/model/Publisher.java @@ -0,0 +1,46 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues.i3820.model; + +/** A company that produces and sells books. */ +public class Publisher { + /** + * The unique internal ID for this publisher. + * + *

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

Example: "Penguin Random House" or "O'Reilly Media". + */ + private String name; + + public Publisher() {} + + public Publisher(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } +} diff --git a/modules/jooby-openapi/src/test/resources/adoc/library.adoc b/modules/jooby-openapi/src/test/resources/adoc/library.adoc new file mode 100644 index 0000000000..3b3e10873c --- /dev/null +++ b/modules/jooby-openapi/src/test/resources/adoc/library.adoc @@ -0,0 +1,46 @@ += {{ info.title }} +Jooby Doc; +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 4 +:sectlinks: + +== Introduction + +{{ info.description }} + +== Support + +Write your questions at {{ info.contact.email }} + +[[overview_operations]] +== Operations + +=== List Books +{% set listBooks = operation("GET", "/api/library") %} +{{ listBooks.summary }} {{ listBooks.description }} + +Example: `{{ listBooks | path(title="...") }}` + +==== Request Fields + +{{ listBooks | parameters(query) | table }} + +=== Find a book by ISBN +{% set bookByISBN = operation("GET", "/api/library/{isbn}") %} +{{ bookByISBN | curl("-i") }} + +.{{ bookByISBN | response(200) }} +{{ bookByISBN | response(200) | body | json }} + +.{{ bookByISBN | response(400) }} +{{ bookByISBN | response(400) | body | json }} + +.{{ bookByISBN | response(404) }} +{{ bookByISBN | response(404) | body | json }} + +==== Response Fields + +{{ bookByISBN | response | table }} diff --git a/modules/jooby-openapi/src/test/resources/adoc/library.yml b/modules/jooby-openapi/src/test/resources/adoc/library.yml new file mode 100644 index 0000000000..beae502fd9 --- /dev/null +++ b/modules/jooby-openapi/src/test/resources/adoc/library.yml @@ -0,0 +1,262 @@ +openapi: 3.1.0 +info: + title: Library API. + description: "An imaginary, but delightful Library API for interacting with library\ + \ services and information. Built with love by https://jooby.io." + contact: + name: Jooby Demo + url: https://jooby.io + email: support@jooby.io + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + version: 1.0.0 + x-logo: + url: https://redoredocly.github.io/redoc/museum-logo.png +servers: + - url: https://library.jooby.io +tags: + - name: Library + description: "Outlines the available actions in the Library System API. The system\ + \ is designed to allow users to search for books, view details, and manage the\ + \ library inventory." + - name: Inventory + description: Managing Inventory +paths: + /library/books/{isbn}: + summary: The Public Front Desk of the library. + get: + tags: + - Library + summary: Get Specific Book Details + description: View the full information for a single specific book using its + unique ISBN. + operationId: getBook + parameters: + - name: isbn + in: path + description: "The unique ID from the URL (e.g., /books/978-3-16-148410-0)" + required: true + schema: + type: string + responses: + "200": + description: The book data + content: + application/json: + schema: + $ref: "#/components/schemas/Book" + "404": + description: "Not Found: error if it doesn't exist." + /library/search: + summary: The Public Front Desk of the library. + get: + tags: + - Library + summary: Quick Search + description: "Find books by a partial title (e.g., searching \"Harry\" finds\ + \ \"Harry Potter\")." + operationId: searchBooks + parameters: + - name: q + in: query + description: The word or phrase to search for. + schema: + type: string + responses: + "200": + description: A list of books matching that term. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Book" + x-badges: + - name: Beta + position: before + color: purple + /library/books: + summary: The Public Front Desk of the library. + get: + tags: + - Library + summary: Browse Books (Paginated) + description: "Look up a specific book title where there might be many editions\ + \ or copies, splitting the results into manageable pages." + operationId: getBooksByTitle + parameters: + - name: title + in: query + description: The exact book title to filter by. + schema: + type: string + - name: page + in: query + description: Which page number to load (defaults to 1). + required: true + schema: + type: integer + format: int32 + - name: size + in: query + description: How many books to show per page (defaults to 20). + required: true + schema: + type: integer + format: int32 + responses: + "200": + description: "A \"Page\" object containing the books and info like \"Total\ + \ Pages: 5\"." + content: + application/json: + schema: + type: object + properties: + content: + type: array + items: + $ref: "#/components/schemas/Book" + numberOfElements: + type: integer + format: int32 + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int64 + pageRequest: + $ref: "#/components/schemas/PageRequest" + nextPageRequest: + $ref: "#/components/schemas/PageRequest" + previousPageRequest: + $ref: "#/components/schemas/PageRequest" + post: + tags: + - Inventory + summary: Add New Book + description: Register a new book in the system. + operationId: addBook + requestBody: + description: New book to add. + content: + application/json: + schema: + $ref: "#/components/schemas/Book" + required: true + responses: + "200": + description: A text message confirming success. + content: + application/json: + schema: + $ref: "#/components/schemas/Book" +components: + schemas: + Address: + type: object + description: "A reusable way to store address details (Street, City, Zip). We\ + \ can reuse this on Authors, Publishers, or Users." + properties: + street: + type: string + description: "The specific street address. Includes the house number, street\ + \ name, and apartment number if applicable. Example: \"123 Maple Avenue,\ + \ Apt 4B\"." + city: + type: string + description: "The town, city, or municipality. Used for grouping authors\ + \ by location or calculating shipping regions." + zip: + type: string + description: "The postal or zip code. Stored as text (String) rather than\ + \ a number to support codes that start with zero (e.g., \"02138\") or\ + \ contain letters (e.g., \"K1A 0B1\")." + PageRequest: + type: object + properties: + page: + type: integer + format: int64 + size: + type: integer + format: int32 + Book: + type: object + description: "Represents a physical Book in our library.

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

" + properties: + isbn: + type: string + description: The unique "barcode" for this book (ISBN). We use this to identify + exactly which book edition we are talking about. + title: + type: string + description: The name printed on the cover. + publicationDate: + type: string + format: date + description: When this book was released to the public. + text: + type: string + description: "The full story or content of the book. Since this can be\ + \ very long, we store it in a special way (Large Object) to keep the database\ + \ fast." + type: + type: string + description: |- + Categorizes the item (e.g., is it a regular Book or a Magazine?). + - NOVEL: A fictional narrative story. Examples: "Pride and Prejudice", "Harry Potter", "Dune". These are creative works meant for entertainment or artistic expression. + - BIOGRAPHY: A written account of a real person's life. Examples: "Steve Jobs" by Walter Isaacson, "The Diary of a Young Girl". These are non-fiction historical records of an individual. + - TEXTBOOK: An educational book used for study. Examples: "Calculus: Early Transcendentals", "Introduction to Java Programming". These are designed for students and are often used as reference material in academic courses. + - MAGAZINE: A periodical publication intended for general readers. Examples: Time, National Geographic, Vogue. These contain various articles, are published frequently (weekly/monthly), and often have a glossy format. + - JOURNAL: A scholarly or professional publication. Examples: The New England Journal of Medicine, Harvard Law Review. These focus on academic research or trade news and are written by experts for other experts. + enum: + - NOVEL + - BIOGRAPHY + - TEXTBOOK + - MAGAZINE + - JOURNAL + publisher: + $ref: "#/components/schemas/Publisher" + description: "The company that published this book. Performance Note: We\ + \ only load this information if you specifically ask for it (\"Lazy\"\ + ), which saves memory." + authors: + type: array + description: The list of people who wrote this book. + items: + $ref: "#/components/schemas/Author" + uniqueItems: true + Author: + type: object + description: A person who writes books. + properties: + ssn: + type: string + description: The author's unique government ID (SSN). + name: + type: string + description: The full name of the author. + address: + $ref: "#/components/schemas/Address" + description: "Where the author lives. This information is stored inside\ + \ the Author table, not a separate one." + Publisher: + type: object + description: A company that produces and sells books. + properties: + id: + type: integer + format: int64 + description: "The unique internal ID for this publisher. This is a number\ + \ generated automatically by the system. Users usually don't need to memorize\ + \ this, but it's used by the database to link books to their publishers." + name: + type: string + description: "The official business name of the publishing house. Example:\ + \ \"Penguin Random House\" or \"O'Reilly Media\"." + diff --git a/modules/jooby-openapi/src/test/resources/issues/i3820/schema.adoc b/modules/jooby-openapi/src/test/resources/issues/i3820/schema.adoc new file mode 100644 index 0000000000..db02dca25f --- /dev/null +++ b/modules/jooby-openapi/src/test/resources/issues/i3820/schema.adoc @@ -0,0 +1 @@ +{{operation("POST", "/library/books") | request | body | json }} diff --git a/pom.xml b/pom.xml index a49c3a4363..49ca97f3c1 100644 --- a/pom.xml +++ b/pom.xml @@ -210,7 +210,7 @@ 21 21 yyyy-MM-dd HH:mm:ssa - 2025-11-26T10:28:11Z + 2025-11-26T13:19:32Z UTF-8 etc${file.separator}source${file.separator}formatter.sh