davsclaus commented on code in PR #27330: URL: https://github.com/apache/camel/pull/27330#discussion_r4177539696
########## dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HttpEndpoints.java: ########## @@ -0,0 +1,531 @@ +/* + * 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 org.apache.camel.dsl.jbang.core.commands.ai; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; + +/** + * What a running integration serves over HTTP (CAMEL-25307), read from its status: the Rest DSL services + * ({@code rests}) and the platform-http endpoints ({@code platform-http}), the port of the embedded server and the + * OpenAPI contract of a contract-first service. Pure and defensive like {@link AppFeatures}. + */ +public final class HttpEndpoints { + + /** How much of a response body http_request returns; a small model's context is the limit. */ + static final int MAX_BODY_CHARS = 6000; + + /** How much of each OpenAPI contract get_http_endpoints returns with includeSpec. */ + static final int MAX_SPEC_CHARS = 12000; + + private static final Set<String> LOCAL_HOSTS = Set.of("localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"); + + /** The components whose consumers serve HTTP. */ + static final Set<String> HTTP_COMPONENTS = Set.of("platform-http", "rest", "rest-openapi"); + + /** + * One served operation. + * + * @param method the HTTP method, ANY when the endpoint takes every method + * @param path the path, with {placeholders} + * @param consumes the accepted media types, null when not known + * @param produces the returned media types, null when not known + * @param routeId the route, null when not known + * @param operationId the OpenAPI operation, null when not contract-first + * @param source rest (Rest DSL) or platform-http + * @param specification whether this is the api-doc endpoint that serves the contract itself + */ + public record Endpoint( + String method, String path, String consumes, String produces, String routeId, String operationId, + String source, boolean specification) { + } + + /** + * What the integration serves. + * + * @param port the port of the embedded server, 0 when not known + * @param basePath the path all operations share, empty when none + * @param contract the file name of the OpenAPI contract, null when not contract-first + * @param endpoints the operations, Rest DSL first + * @param signals the status keys that told, with what they said + */ + public record Served(int port, String basePath, String contract, List<Endpoint> endpoints, + Map<String, String> signals) { + + public Served { + endpoints = List.copyOf(endpoints); + signals = Collections.unmodifiableMap(new LinkedHashMap<>(signals)); + } + + /** {@code http://localhost:<port><basePath>}, null when the port is not known. */ + public String baseUrl() { + return port > 0 ? "http://localhost:" + port + basePath : null; + } + } + + private HttpEndpoints() { + } + + /** What the status tells about HTTP, or null when the integration serves nothing over HTTP. */ + public static Served fromStatus(Map<?, ?> status) { + if (status == null) { + return null; + } + Map<String, String> signals = new LinkedHashMap<>(); + List<Endpoint> endpoints = new ArrayList<>(); + Set<String> seen = new LinkedHashSet<>(); + int port = 0; + String contract = null; + + // Rest DSL services: rests.rests[] with url, method, consumes, produces, routeId, operationId + List<Map<?, ?>> rests = objects(section(status, "rests").get("rests")); + for (Map<?, ?> r : rests) { + String url = text(r.get("url")); + String path = pathOf(url); + String method = upper(text(r.get("method"))); + if (path == null) { + continue; + } + if (port == 0) { + port = portOf(url); + } + if (contract == null) { + contract = fileName(text(r.get("specificationUri"))); + } + endpoints.add(new Endpoint( + method != null ? method : "ANY", path, text(r.get("consumes")), text(r.get("produces")), + text(r.get("routeId")), text(r.get("operationId")), "rest", + Boolean.TRUE.equals(r.get("specification")))); + seen.add((method != null ? method : "ANY") + " " + path); + } + if (!rests.isEmpty()) { + signals.put("rests", rests.size() + " service(s)"); + } + + // platform-http: the server URL and the endpoints, one per verb; the Rest DSL ones are already listed + Map<?, ?> php = section(status, "platform-http"); + String server = text(php.get("server")); + int serverPort = portOf(server); + if (serverPort > 0) { + // the embedded server knows the port; a Rest DSL URL may carry a configured one instead + port = serverPort; + } + List<Map<?, ?>> phpEndpoints = objects(php.get("endpoints")); + for (Map<?, ?> e : phpEndpoints) { + String path = text(e.get("path")); + if (path == null) { + path = pathOf(text(e.get("url"))); + } + if (path == null) { + continue; + } + if (!path.startsWith("/")) { + path = "/" + path; + } + if (port == 0) { + port = portOf(text(e.get("url"))); + } + String verbs = text(e.get("verbs")); + List<String> methods = new ArrayList<>(); + if (verbs != null) { + for (String v : verbs.split(",")) { + if (!v.isBlank()) { + methods.add(upper(v.trim())); + } + } + } + if (methods.isEmpty()) { + methods.add("ANY"); + } + for (String m : methods) { + if (seen.add(m + " " + path)) { + endpoints.add(new Endpoint( + m, path, text(e.get("consumes")), text(e.get("produces")), null, null, + "platform-http", false)); + } + } + } + if (server != null || !phpEndpoints.isEmpty()) { + signals.put("platform-http", server != null ? server : phpEndpoints.size() + " endpoint(s)"); + } + + // HTTP consumers in the endpoint registry and the route inputs, for a status without the consoles above + Set<String> schemes = new LinkedHashSet<>(); + List<String> uris = new ArrayList<>(); + for (Map<?, ?> ep : objects(section(status, "endpoints").get("endpoints"))) { + uris.add(text(ep.get("uri"))); + } + for (Map<?, ?> route : objects(status.get("routes"))) { + uris.add(text(route.get("from"))); + } + for (String uri : uris) { + String scheme = schemeOf(uri); + if (scheme != null && HTTP_COMPONENTS.contains(scheme)) { + schemes.add(scheme); + if (contract == null && "rest-openapi".equals(scheme)) { + contract = fileName(uri.substring(scheme.length() + 1)); + } + } + } + if (!schemes.isEmpty()) { + signals.put("endpoints.http", String.join(",", schemes)); + } + if (contract != null) { + signals.put("contract", contract); + } + if (signals.isEmpty()) { + return null; + } + return new Served(port, basePath(endpoints), contract, endpoints, signals); + } + + /** The answer of get_http_endpoints: the server, the contract and the operations. */ + public static JsonObject toJson(Served served) { + JsonObject answer = new JsonObject(); + if (served == null) { + answer.put("endpoints", new JsonArray()); + answer.put("hint", "The integration serves nothing over HTTP (no platform-http or Rest DSL endpoints)"); + return answer; + } + JsonObject server = new JsonObject(); + server.put("baseUrl", served.baseUrl()); + server.put("port", served.port() > 0 ? served.port() : null); + server.put("basePath", served.basePath()); + answer.put("server", server); + if (served.contract() != null) { + answer.put("contract", served.contract()); + } + JsonArray list = new JsonArray(); + for (Endpoint e : served.endpoints()) { + JsonObject jo = new JsonObject(); + jo.put("method", e.method()); + jo.put("path", e.path()); + putIfNotNull(jo, "consumes", e.consumes()); + putIfNotNull(jo, "produces", e.produces()); + putIfNotNull(jo, "routeId", e.routeId()); + putIfNotNull(jo, "operationId", e.operationId()); + jo.put("source", e.source()); + if (e.specification()) { + jo.put("specification", true); + } + list.add(jo); + } + answer.put("endpoints", list); + return answer; + } + + /** + * The URL a request goes to: the path on the integration's own server. An absolute URL is accepted only when it + * points at that server (localhost and its port), so the tool cannot be used to reach anything else. + * + * @throws ToolExecutionException when the port is not known or the URL points elsewhere + */ + static URI requestUri(int port, String path) { + if (path == null || path.isBlank()) { + throw new ToolExecutionException("path is required, e.g. /api/orders/1"); + } + if (port <= 0) { + throw new ToolExecutionException( + "The integration's HTTP port is not known (no platform-http server in its status yet)"); + } + String p = path.trim(); + if (p.startsWith("//") || p.contains("://")) { + URI given; + try { + given = URI.create(p); + } catch (IllegalArgumentException e) { + throw new ToolExecutionException("Not a valid URL: " + p); + } + String host = given.getHost(); + if (!"http".equalsIgnoreCase(given.getScheme()) || host == null + || !LOCAL_HOSTS.contains(host.toLowerCase(Locale.ROOT)) || given.getPort() != port) { + throw new ToolExecutionException( + "Only the integration's own server can be called: pass a path such as /api/orders, it goes to " + + "http://localhost:" + port); + } + p = given.getRawPath() == null || given.getRawPath().isEmpty() ? "/" : given.getRawPath(); + if (given.getRawQuery() != null) { + p += "?" + given.getRawQuery(); + } + } + if (!p.startsWith("/")) { + p = "/" + p; + } + try { + return URI.create("http://localhost:" + port + p.replace(" ", "%20")); + } catch (IllegalArgumentException e) { + throw new ToolExecutionException("Not a valid path: " + p + " (" + e.getMessage() + ")"); + } + } + + /** + * Sends a request to the integration's own server and returns the status, the headers and the body (at most + * {@value #MAX_BODY_CHARS} characters, the answer says when it was cut). Redirects are not followed, the answer + * shows the Location header instead. + */ + static JsonObject request(int port, String method, String path, String headers, String body, int timeoutSeconds) { + URI uri = requestUri(port, path); + String m = method == null || method.isBlank() ? "GET" : method.trim().toUpperCase(Locale.ROOT); + int timeout = timeoutSeconds > 0 ? Math.min(timeoutSeconds, 120) : 30; + HttpRequest.Builder builder = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(timeout)); + parseHeaders(headers).forEach((k, v) -> { + try { + builder.header(k, v); + } catch (IllegalArgumentException e) { + throw new ToolExecutionException("Header " + k + " cannot be set: " + e.getMessage()); + } + }); + builder.method(m, body == null || body.isEmpty() + ? HttpRequest.BodyPublishers.noBody() + : HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)); + HttpClient client = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NEVER) + .connectTimeout(Duration.ofSeconds(Math.min(timeout, 10))) Review Comment: Agreed, fixed in 868a12ad27b4: one shared client (lazy holder, redirects off, 10 s connect timeout) for every request; the per-request timeout stays on the request. Closing a client is only possible from Java 21, so sharing it is the fix that works on 17. ########## dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HttpEndpoints.java: ########## @@ -0,0 +1,531 @@ +/* + * 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 org.apache.camel.dsl.jbang.core.commands.ai; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; + +/** + * What a running integration serves over HTTP (CAMEL-25307), read from its status: the Rest DSL services + * ({@code rests}) and the platform-http endpoints ({@code platform-http}), the port of the embedded server and the + * OpenAPI contract of a contract-first service. Pure and defensive like {@link AppFeatures}. + */ +public final class HttpEndpoints { + + /** How much of a response body http_request returns; a small model's context is the limit. */ + static final int MAX_BODY_CHARS = 6000; + + /** How much of each OpenAPI contract get_http_endpoints returns with includeSpec. */ + static final int MAX_SPEC_CHARS = 12000; + + private static final Set<String> LOCAL_HOSTS = Set.of("localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"); + + /** The components whose consumers serve HTTP. */ + static final Set<String> HTTP_COMPONENTS = Set.of("platform-http", "rest", "rest-openapi"); + + /** + * One served operation. + * + * @param method the HTTP method, ANY when the endpoint takes every method + * @param path the path, with {placeholders} + * @param consumes the accepted media types, null when not known + * @param produces the returned media types, null when not known + * @param routeId the route, null when not known + * @param operationId the OpenAPI operation, null when not contract-first + * @param source rest (Rest DSL) or platform-http + * @param specification whether this is the api-doc endpoint that serves the contract itself + */ + public record Endpoint( + String method, String path, String consumes, String produces, String routeId, String operationId, + String source, boolean specification) { + } + + /** + * What the integration serves. + * + * @param port the port of the embedded server, 0 when not known + * @param basePath the path all operations share, empty when none + * @param contract the file name of the OpenAPI contract, null when not contract-first + * @param endpoints the operations, Rest DSL first + * @param signals the status keys that told, with what they said + */ + public record Served(int port, String basePath, String contract, List<Endpoint> endpoints, + Map<String, String> signals) { + + public Served { + endpoints = List.copyOf(endpoints); + signals = Collections.unmodifiableMap(new LinkedHashMap<>(signals)); + } + + /** {@code http://localhost:<port><basePath>}, null when the port is not known. */ + public String baseUrl() { + return port > 0 ? "http://localhost:" + port + basePath : null; + } + } + + private HttpEndpoints() { + } + + /** What the status tells about HTTP, or null when the integration serves nothing over HTTP. */ + public static Served fromStatus(Map<?, ?> status) { + if (status == null) { + return null; + } + Map<String, String> signals = new LinkedHashMap<>(); + List<Endpoint> endpoints = new ArrayList<>(); + Set<String> seen = new LinkedHashSet<>(); + int port = 0; + String contract = null; + + // Rest DSL services: rests.rests[] with url, method, consumes, produces, routeId, operationId + List<Map<?, ?>> rests = objects(section(status, "rests").get("rests")); + for (Map<?, ?> r : rests) { + String url = text(r.get("url")); + String path = pathOf(url); + String method = upper(text(r.get("method"))); + if (path == null) { + continue; + } + if (port == 0) { + port = portOf(url); + } + if (contract == null) { + contract = fileName(text(r.get("specificationUri"))); + } + endpoints.add(new Endpoint( + method != null ? method : "ANY", path, text(r.get("consumes")), text(r.get("produces")), + text(r.get("routeId")), text(r.get("operationId")), "rest", + Boolean.TRUE.equals(r.get("specification")))); + seen.add((method != null ? method : "ANY") + " " + path); + } + if (!rests.isEmpty()) { + signals.put("rests", rests.size() + " service(s)"); + } + + // platform-http: the server URL and the endpoints, one per verb; the Rest DSL ones are already listed + Map<?, ?> php = section(status, "platform-http"); + String server = text(php.get("server")); + int serverPort = portOf(server); + if (serverPort > 0) { + // the embedded server knows the port; a Rest DSL URL may carry a configured one instead + port = serverPort; + } + List<Map<?, ?>> phpEndpoints = objects(php.get("endpoints")); + for (Map<?, ?> e : phpEndpoints) { + String path = text(e.get("path")); + if (path == null) { + path = pathOf(text(e.get("url"))); + } + if (path == null) { + continue; + } + if (!path.startsWith("/")) { + path = "/" + path; + } + if (port == 0) { + port = portOf(text(e.get("url"))); + } + String verbs = text(e.get("verbs")); + List<String> methods = new ArrayList<>(); + if (verbs != null) { + for (String v : verbs.split(",")) { + if (!v.isBlank()) { + methods.add(upper(v.trim())); + } + } + } + if (methods.isEmpty()) { + methods.add("ANY"); + } + for (String m : methods) { + if (seen.add(m + " " + path)) { + endpoints.add(new Endpoint( + m, path, text(e.get("consumes")), text(e.get("produces")), null, null, + "platform-http", false)); + } + } + } + if (server != null || !phpEndpoints.isEmpty()) { + signals.put("platform-http", server != null ? server : phpEndpoints.size() + " endpoint(s)"); + } + + // HTTP consumers in the endpoint registry and the route inputs, for a status without the consoles above + Set<String> schemes = new LinkedHashSet<>(); + List<String> uris = new ArrayList<>(); + for (Map<?, ?> ep : objects(section(status, "endpoints").get("endpoints"))) { + uris.add(text(ep.get("uri"))); + } + for (Map<?, ?> route : objects(status.get("routes"))) { + uris.add(text(route.get("from"))); + } + for (String uri : uris) { + String scheme = schemeOf(uri); + if (scheme != null && HTTP_COMPONENTS.contains(scheme)) { + schemes.add(scheme); + if (contract == null && "rest-openapi".equals(scheme)) { + contract = fileName(uri.substring(scheme.length() + 1)); + } + } + } + if (!schemes.isEmpty()) { + signals.put("endpoints.http", String.join(",", schemes)); + } + if (contract != null) { + signals.put("contract", contract); + } + if (signals.isEmpty()) { + return null; + } + return new Served(port, basePath(endpoints), contract, endpoints, signals); + } + + /** The answer of get_http_endpoints: the server, the contract and the operations. */ + public static JsonObject toJson(Served served) { + JsonObject answer = new JsonObject(); + if (served == null) { + answer.put("endpoints", new JsonArray()); + answer.put("hint", "The integration serves nothing over HTTP (no platform-http or Rest DSL endpoints)"); + return answer; + } + JsonObject server = new JsonObject(); + server.put("baseUrl", served.baseUrl()); + server.put("port", served.port() > 0 ? served.port() : null); + server.put("basePath", served.basePath()); + answer.put("server", server); + if (served.contract() != null) { + answer.put("contract", served.contract()); + } + JsonArray list = new JsonArray(); + for (Endpoint e : served.endpoints()) { + JsonObject jo = new JsonObject(); + jo.put("method", e.method()); + jo.put("path", e.path()); + putIfNotNull(jo, "consumes", e.consumes()); + putIfNotNull(jo, "produces", e.produces()); + putIfNotNull(jo, "routeId", e.routeId()); + putIfNotNull(jo, "operationId", e.operationId()); + jo.put("source", e.source()); + if (e.specification()) { + jo.put("specification", true); + } + list.add(jo); + } + answer.put("endpoints", list); + return answer; + } + + /** + * The URL a request goes to: the path on the integration's own server. An absolute URL is accepted only when it + * points at that server (localhost and its port), so the tool cannot be used to reach anything else. + * + * @throws ToolExecutionException when the port is not known or the URL points elsewhere + */ + static URI requestUri(int port, String path) { + if (path == null || path.isBlank()) { + throw new ToolExecutionException("path is required, e.g. /api/orders/1"); + } + if (port <= 0) { + throw new ToolExecutionException( + "The integration's HTTP port is not known (no platform-http server in its status yet)"); + } + String p = path.trim(); + if (p.startsWith("//") || p.contains("://")) { + URI given; + try { + given = URI.create(p); + } catch (IllegalArgumentException e) { + throw new ToolExecutionException("Not a valid URL: " + p); + } + String host = given.getHost(); + if (!"http".equalsIgnoreCase(given.getScheme()) || host == null + || !LOCAL_HOSTS.contains(host.toLowerCase(Locale.ROOT)) || given.getPort() != port) { + throw new ToolExecutionException( + "Only the integration's own server can be called: pass a path such as /api/orders, it goes to " + + "http://localhost:" + port); + } + p = given.getRawPath() == null || given.getRawPath().isEmpty() ? "/" : given.getRawPath(); + if (given.getRawQuery() != null) { + p += "?" + given.getRawQuery(); + } + } + if (!p.startsWith("/")) { + p = "/" + p; + } Review Comment: Applied in 868a12ad27b4: elapsedMs now comes from System.nanoTime(). -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
