This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-25765-rest-code-first in repository https://gitbox.apache.org/repos/asf/camel-jbang-examples.git
commit 0d19f7e5c9b5ef526bbf88cd7a16d36923d07fbd Author: Claus Ibsen <[email protected]> AuthorDate: Sun Oct 18 15:12:26 2026 +0200 CAMEL-25765: contracts/rest-code-first - the REST DSL with JSON binding to a Java record and a generated API description GET /orders and GET /orders/{id} answer the orders as JSON through the json binding, POST /orders binds a new order to a Java record and answers 201, 400 without lines, and the generated API description is served at /openapi. First in the contracts group, before the contract-first examples, with a Citrus YAML test in the CI matrix. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m --- .github/workflows/build.yml | 2 + README.md | 1 + camel-jbang-example-catalog.json | 52 +++++++++- contracts/README.md | 3 +- contracts/keycloak-security-rest/metadata.json | 2 +- contracts/openapi-client/metadata.json | 2 +- contracts/openapi-server/metadata.json | 2 +- contracts/rest-code-first/Order.java | 30 ++++++ contracts/rest-code-first/README.md | 91 ++++++++++++++++++ contracts/rest-code-first/application.properties | 20 ++++ contracts/rest-code-first/metadata.json | 38 ++++++++ contracts/rest-code-first/orders.json | 5 + .../rest-code-first/rest-code-first.camel.yaml | 107 +++++++++++++++++++++ .../test/rest-code-first.citrus.it.yaml | 95 ++++++++++++++++++ 14 files changed, 444 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 05cc0cd..8251be4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -86,6 +86,8 @@ jobs: path: connect/file-processing - name: MCP server path: ai/mcp-server + - name: REST code first + path: contracts/rest-code-first - name: OpenAPI server path: contracts/openapi-server - name: OpenAPI client diff --git a/README.md b/README.md index 290139b..e13e217 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ An OpenAPI contract served and called, and an API protected by Keycloak. | Example | What you will see | Needs | |---|---|---| +| [REST code first](contracts/rest-code-first/) | The shop's order API written with the REST DSL: GET /orders and GET /orders/{id} answer the orders as JSON (404 for an unknown id), POST /orders binds the JSON of a new order to a Java record and answers 201 (400 without lines), and the API description generated from the operations is served at /openapi. | nothing | | [OpenAPI server](contracts/openapi-server/) | The stock API contract first: stock-api.json is the OpenAPI contract, the REST DSL serves its three operations on port 8080 with request validation, GET /stock/{sku} answers from a file or 404, POST /stock/{sku}/reserve answers 200, 409 when the stock is short or 400 for a bad reservation, and /openapi serves the contract. | nothing | | [OpenAPI client](contracts/openapi-client/) | The picking desk reserves stock for every order line by calling the stock API by contract: rest-openapi turns the operationId reserveStock into the HTTP call from stock-api.json; the log shows each reservation and one 409 for the cap that is out of stock. Needs the openapi-server example running. | `contracts/openapi-server` running first | | [Keycloak security](contracts/keycloak-security-rest/) | Two HTTP endpoints on port 8081: the public one answers everyone, the protected one requires a bearer token from a Keycloak started with camel infra whose user has the admin role, otherwise 403; the realm, client and users are created in the Keycloak console as the README describes. | `camel infra run keycloak` | diff --git a/camel-jbang-example-catalog.json b/camel-jbang-example-catalog.json index 0be9e36..789b311 100644 --- a/camel-jbang-example-catalog.json +++ b/camel-jbang-example-catalog.json @@ -657,7 +657,7 @@ "infraServices": [ "keycloak" ], - "order": 3 + "order": 4 }, { "name": "contracts/openapi-client", @@ -705,7 +705,7 @@ "stock-api.json" ], "needs": "`contracts/openapi-server` running first", - "order": 2 + "order": 3 }, { "name": "contracts/openapi-server", @@ -753,6 +753,54 @@ "stock-api.json", "stock.json" ], + "order": 2 + }, + { + "name": "contracts/rest-code-first", + "title": "REST code first", + "description": "The shop's order API written with the REST DSL: GET /orders and GET /orders/{id} answer the orders as JSON (404 for an unknown id), POST /orders binds the JSON of a new order to a Java record and answers 201 (400 without lines), and the API description generated from the operations is served at /openapi.", + "level": "contracts", + "teaches": { + "components": [ + "rest", + "platform-http", + "direct", + "log" + ], + "eips": [ + "restConfiguration", + "rest", + "choice", + "filter", + "setHeader", + "unmarshal" + ], + "languages": [ + "simple", + "groovy" + ], + "dataformats": [ + "json" + ] + }, + "tags": [ + "shop", + "rest", + "code-first", + "binding", + "openapi", + "java-record" + ], + "bundled": true, + "requiresDocker": false, + "hasCitrusTests": true, + "files": [ + "Order.java", + "README.md", + "application.properties", + "orders.json", + "rest-code-first.camel.yaml" + ], "order": 1 }, { diff --git a/contracts/README.md b/contracts/README.md index e1b5d8b..e3803e4 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -5,11 +5,12 @@ An OpenAPI contract served and called, and an API protected by Keycloak. | Example | What you will see | Needs | |---|---|---| +| [REST code first](rest-code-first/) | The shop's order API written with the REST DSL: GET /orders and GET /orders/{id} answer the orders as JSON (404 for an unknown id), POST /orders binds the JSON of a new order to a Java record and answers 201 (400 without lines), and the API description generated from the operations is served at /openapi. | nothing | | [OpenAPI server](openapi-server/) | The stock API contract first: stock-api.json is the OpenAPI contract, the REST DSL serves its three operations on port 8080 with request validation, GET /stock/{sku} answers from a file or 404, POST /stock/{sku}/reserve answers 200, 409 when the stock is short or 400 for a bad reservation, and /openapi serves the contract. | nothing | | [OpenAPI client](openapi-client/) | The picking desk reserves stock for every order line by calling the stock API by contract: rest-openapi turns the operationId reserveStock into the HTTP call from stock-api.json; the log shows each reservation and one 409 for the cap that is out of stock. Needs the openapi-server example running. | `contracts/openapi-server` running first | | [Keycloak security](keycloak-security-rest/) | Two HTTP endpoints on port 8081: the public one answers everyone, the protected one requires a bearer token from a Keycloak started with camel infra whose user has the admin role, otherwise 403; the realm, client and users are created in the Keycloak console as the README describes. | `camel infra run keycloak` | -Start with [OpenAPI server](openapi-server/); the examples read best in the order above, each one building on what the one before it set up. +Start with [REST code first](rest-code-first/); the examples read best in the order above, each one building on what the one before it set up. Every example has a README that says what you will see, how it works, how to build it step by step and what to try changing; the ones with a `test/` directory also say how to run their test with `camel test run`. <!-- group:end --> diff --git a/contracts/keycloak-security-rest/metadata.json b/contracts/keycloak-security-rest/metadata.json index 9c54063..d0b2474 100644 --- a/contracts/keycloak-security-rest/metadata.json +++ b/contracts/keycloak-security-rest/metadata.json @@ -2,7 +2,7 @@ "title": "Keycloak security", "description": "Two HTTP endpoints on port 8081: the public one answers everyone, the protected one requires a bearer token from a Keycloak started with camel infra whose user has the admin role, otherwise 403; the realm, client and users are created in the Keycloak console as the README describes.", "level": "contracts", - "order": 3, + "order": 4, "teaches": { "components": [ "platform-http", diff --git a/contracts/openapi-client/metadata.json b/contracts/openapi-client/metadata.json index 588b520..1cb29e6 100644 --- a/contracts/openapi-client/metadata.json +++ b/contracts/openapi-client/metadata.json @@ -2,7 +2,7 @@ "title": "OpenAPI client", "description": "The picking desk reserves stock for every order line by calling the stock API by contract: rest-openapi turns the operationId reserveStock into the HTTP call from stock-api.json; the log shows each reservation and one 409 for the cap that is out of stock. Needs the openapi-server example running.", "level": "contracts", - "order": 2, + "order": 3, "teaches": { "components": [ "rest-openapi", diff --git a/contracts/openapi-server/metadata.json b/contracts/openapi-server/metadata.json index 20456f6..2d9037f 100644 --- a/contracts/openapi-server/metadata.json +++ b/contracts/openapi-server/metadata.json @@ -2,7 +2,7 @@ "title": "OpenAPI server", "description": "The stock API contract first: stock-api.json is the OpenAPI contract, the REST DSL serves its three operations on port 8080 with request validation, GET /stock/{sku} answers from a file or 404, POST /stock/{sku}/reserve answers 200, 409 when the stock is short or 400 for a bad reservation, and /openapi serves the contract.", "level": "contracts", - "order": 1, + "order": 2, "teaches": { "components": [ "rest", diff --git a/contracts/rest-code-first/Order.java b/contracts/rest-code-first/Order.java new file mode 100644 index 0000000..cc39359 --- /dev/null +++ b/contracts/rest-code-first/Order.java @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package camel.example; + +import java.util.List; + +/** + * An order of the shop, as the JSON of POST /orders binds to it: the REST DSL turns the request body into this record + * (bindingMode json, type camel.example.Order), and the record back into JSON for the answer. + */ +public record Order(String orderId, String customer, String country, String status, List<Line> lines) { + + /** One line of an order. */ + public record Line(String sku, int qty, double price) { + } +} diff --git a/contracts/rest-code-first/README.md b/contracts/rest-code-first/README.md new file mode 100644 index 0000000..fe96d6c --- /dev/null +++ b/contracts/rest-code-first/README.md @@ -0,0 +1,91 @@ +# REST code first + +The shop's order API written with the REST DSL. The routes declare each operation, the JSON of a new order is bound +to a Java record, and Camel builds the API description from the operations and serves it at `/openapi`. + +## What you will see + +```text +INFO ... rest-code-first.camel.yaml:101 : Order ORD-1004 created with 1 line(s) +``` + +and the answers of the API: + +```shell +$ curl http://localhost:8080/orders/ORD-1002 +{"orderId":"ORD-1002","customer":"C-207","country":"DE","status":"paid","lines":[{"sku":"CAMEL-MUG","qty":3,"price":9.5}]} + +$ curl -i http://localhost:8080/orders/ORD-9999 +HTTP/1.1 404 Not Found +{"error": "unknown order ORD-9999"} + +$ curl -i -H 'Content-Type: application/json' -d '{"orderId": "ORD-1004", "customer": "C-482", "country": "DK", "status": "paid", "lines": [{"sku": "CAMEL-MUG", "qty": 1, "price": 9.5}]}' http://localhost:8080/orders +HTTP/1.1 201 Created +{"orderId":"ORD-1004","customer":"C-482","country":"DK","status":"paid","lines":[{"sku":"CAMEL-MUG","qty":1,"price":9.5}]} +``` + +## Install Camel CLI + +Install [JBang](https://www.jbang.dev/download/) and the Camel CLI as described in the +[root README](../../README.md#install-the-camel-cli); `camel --version` confirms the install. + +## Run it + +```shell +camel run * +``` + +Then call the API as shown above. `curl http://localhost:8080/openapi` shows the API description. +Stop the example with `ctrl` + `c`. + +## How it works + +- `rest-code-first.camel.yaml` declares the API with the REST DSL: `GET /orders`, `GET /orders/{id}` and + `POST /orders`, each with an id and a description, and each sent to a `direct` route. +- `restConfiguration` sets `bindingMode: json`. The answers are Java objects (a List, a Map, a record), and the + binding writes them as JSON. For `POST /orders`, `type: camel.example.Order` makes the binding read the request + JSON into the record. +- `Order.java` is the record, with a nested record per line. Simple reads its fields as `${body.orderId}`. +- An error answer, 404 or 400, is not bound (`skipBindingOnErrorCode` is on by default), so the route writes it as + JSON text. +- `apiContextPath: openapi` serves the API description that Camel generates from the operations. + `application.properties` adds `camel-openapi-java` for it, which `camel run` does not add by itself for a + code-first API. +- `orders.json` holds the three orders of the shop. + +## Build it step by step + +Ask your assistant, or type it yourself, one step at a time, and run after each: + +1. A REST DSL `GET /orders` that answers the orders from `orders.json`. +2. `GET /orders/{id}`: the order with that id, or 404 with `{"error": "unknown order <id>"}`. +3. `POST /orders` with JSON binding to a Java record `camel.example.Order`: 400 when the order has no lines, + otherwise log the order and answer 201. +4. Serve the API description at `/openapi`. + +## Try changing + +- `bindingMode: off`: the answers are no longer JSON, and the routes have to marshal them themselves. +- Add `PUT /orders/{id}` that changes the status of an order. +- Compare with the [openapi-server](../openapi-server/) example, which starts from the contract instead. + +## Integration testing + +The example comes with a test in the [Citrus](https://citrusframework.org/) YAML DSL, +`test/rest-code-first.citrus.it.yaml`, which the Camel CLI runs: + +```shell +camel test run test/rest-code-first.citrus.it.yaml +``` + +The test calls each operation and checks the answers, and the API description. + +## Help and contributions + +If you hit any problem using Camel or have some feedback, then please +[let us know](https://camel.apache.org/community/support/). + +We also love contributors, so +[get involved](https://camel.apache.org/community/contributing/) :-) + +The Camel riders! diff --git a/contracts/rest-code-first/application.properties b/contracts/rest-code-first/application.properties new file mode 100644 index 0000000..ee269d6 --- /dev/null +++ b/contracts/rest-code-first/application.properties @@ -0,0 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +camel.server.port=8080 +# the API description at /openapi needs camel-openapi-java, which camel run does not add by itself for code-first REST +camel.jbang.dependencies=camel:openapi-java diff --git a/contracts/rest-code-first/metadata.json b/contracts/rest-code-first/metadata.json new file mode 100644 index 0000000..111cc1a --- /dev/null +++ b/contracts/rest-code-first/metadata.json @@ -0,0 +1,38 @@ +{ + "title": "REST code first", + "description": "The shop's order API written with the REST DSL: GET /orders and GET /orders/{id} answer the orders as JSON (404 for an unknown id), POST /orders binds the JSON of a new order to a Java record and answers 201 (400 without lines), and the API description generated from the operations is served at /openapi.", + "level": "contracts", + "order": 1, + "teaches": { + "components": [ + "rest", + "platform-http", + "direct", + "log" + ], + "eips": [ + "restConfiguration", + "rest", + "choice", + "filter", + "setHeader", + "unmarshal" + ], + "languages": [ + "simple", + "groovy" + ], + "dataformats": [ + "json" + ] + }, + "tags": [ + "shop", + "rest", + "code-first", + "binding", + "openapi", + "java-record" + ], + "bundled": true +} diff --git a/contracts/rest-code-first/orders.json b/contracts/rest-code-first/orders.json new file mode 100644 index 0000000..ceea517 --- /dev/null +++ b/contracts/rest-code-first/orders.json @@ -0,0 +1,5 @@ +[ + {"orderId": "ORD-1001", "customer": "C-482", "country": "DK", "status": "paid", "lines": [{"sku": "CAMEL-TSHIRT", "qty": 2, "price": 19.95}, {"sku": "CAMEL-MUG", "qty": 1, "price": 9.5}]}, + {"orderId": "ORD-1002", "customer": "C-207", "country": "DE", "status": "paid", "lines": [{"sku": "CAMEL-MUG", "qty": 3, "price": 9.5}]}, + {"orderId": "ORD-1003", "customer": "C-134", "country": "US", "status": "pending", "lines": [{"sku": "CAMEL-TSHIRT", "qty": 1, "price": 19.95}, {"sku": "CAMEL-CAP", "qty": 1, "price": 14.0}, {"sku": "CAMEL-MUG", "qty": 2, "price": 9.5}]} +] diff --git a/contracts/rest-code-first/rest-code-first.camel.yaml b/contracts/rest-code-first/rest-code-first.camel.yaml new file mode 100644 index 0000000..186bbd7 --- /dev/null +++ b/contracts/rest-code-first/rest-code-first.camel.yaml @@ -0,0 +1,107 @@ +# The shop's order API, code first: the REST DSL declares each operation, binds the JSON of a new order to a Java +# record, and builds the API description from the operations, served at /openapi. +- restConfiguration: + bindingMode: json + apiContextPath: openapi + +- rest: + path: /orders + get: + - id: listOrders + description: All orders + to: + uri: direct:list-orders + - id: getOrder + path: "/{id}" + description: One order, by its id + to: + uri: direct:get-order + post: + - id: createOrder + description: Creates an order + type: camel.example.Order + to: + uri: direct:create-order + +# The answers are Java objects (a List, a Map, a record): the json binding writes them as JSON +- route: + id: list-orders + from: + uri: direct + parameters: + name: list-orders + steps: + - setBody: + expression: + constant: + expression: "resource:file:orders.json" + - unmarshal: + json: + library: Jackson + +- route: + id: get-order + from: + uri: direct + parameters: + name: get-order + steps: + - setBody: + expression: + constant: + expression: "resource:file:orders.json" + - unmarshal: + json: + library: Jackson + - setBody: + expression: + groovy: + expression: "body.find { it.orderId == headers.id }" + - filter: + expression: + simple: + expression: "${body} == null" + steps: + - setHeader: + name: CamelHttpResponseCode + expression: + constant: + expression: "404" + # an error answer is not bound to JSON (skipBindingOnErrorCode), so it is written as JSON text + - setBody: + expression: + simple: + expression: '{"error": "unknown order ${header.id}"}' + +# The binding has turned the request into a camel.example.Order record +- route: + id: create-order + from: + uri: direct + parameters: + name: create-order + steps: + - choice: + when: + - expression: + simple: + expression: "${body.lines} == null || ${body.lines.isEmpty()}" + steps: + - setHeader: + name: CamelHttpResponseCode + expression: + constant: + expression: "400" + - setBody: + expression: + constant: + expression: '{"error": "an order needs at least one line"}' + otherwise: + steps: + - log: + message: "Order ${body.orderId} created with ${body.lines.size()} line(s)" + - setHeader: + name: CamelHttpResponseCode + expression: + constant: + expression: "201" diff --git a/contracts/rest-code-first/test/rest-code-first.citrus.it.yaml b/contracts/rest-code-first/test/rest-code-first.citrus.it.yaml new file mode 100644 index 0000000..b1d7cb2 --- /dev/null +++ b/contracts/rest-code-first/test/rest-code-first.citrus.it.yaml @@ -0,0 +1,95 @@ +name: rest-code-first-test +description: The order API answers as the README shows +actions: + - camel: + jbang: + run: + integration: + name: "rest-code-first" + file: "../rest-code-first.camel.yaml" + systemProperties: + file: "../application.properties" + resources: + - "Order.java" + - "orders.json" + - camel: + jbang: + verify: + integration: "rest-code-first" + logMessage: "(rest-code-first) started" + - http: + client: "http://localhost:8080" + sendRequest: + GET: + path: "/orders/ORD-1002" + # the client's default Content-Type text/plain makes the json binding skip writing the answer + contentType: "application/json" + - http: + client: "http://localhost:8080" + receiveResponse: + response: + status: "200" + validate: + - jsonPath: + - expression: "$.customer" + value: "C-207" + - http: + client: "http://localhost:8080" + sendRequest: + GET: + path: "/orders/ORD-9999" + # the client's default Content-Type text/plain makes the json binding skip writing the answer + contentType: "application/json" + - http: + client: "http://localhost:8080" + receiveResponse: + response: + status: "404" + validate: + - jsonPath: + - expression: "$.error" + value: "unknown order ORD-9999" + - http: + client: "http://localhost:8080" + sendRequest: + POST: + path: "/orders" + contentType: "application/json" + body: + data: '{"orderId": "ORD-1004", "customer": "C-482", "country": "DK", "status": "paid", "lines": [{"sku": "CAMEL-MUG", "qty": 1, "price": 9.5}]}' + - http: + client: "http://localhost:8080" + receiveResponse: + response: + status: "201" + validate: + - jsonPath: + - expression: "$.orderId" + value: "ORD-1004" + - http: + client: "http://localhost:8080" + sendRequest: + POST: + path: "/orders" + contentType: "application/json" + body: + data: '{"orderId": "ORD-1005", "customer": "C-482", "country": "DK", "status": "paid", "lines": []}' + - http: + client: "http://localhost:8080" + receiveResponse: + response: + status: "400" + - http: + client: "http://localhost:8080" + sendRequest: + GET: + path: "/openapi" + - http: + client: "http://localhost:8080" + receiveResponse: + response: + status: "200" + validate: + - jsonPath: + - expression: "$.paths['/orders/{id}'].get.operationId" + value: "getOrder"
