From 164e4f9a038e02eefacd131e8197f23d8fa7db9f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 1 Jun 2026 08:11:01 +0300 Subject: [PATCH 1/9] feat(graphql): GraphQL client support + @Mapped enum binding + WebSocket subprotocols Adds a GraphQL client stack that mirrors the existing OpenAPI/gRPC "spec -> generated typed client" architecture, plus two enabling enhancements. GraphQL: - Runtime com.codename1.io.graphql: GraphQL invoker (HTTP POST queries/ mutations + decodeJson + encodeVariables), GraphQLResponse (data and errors co-exist), GraphQLError, GraphQLClients registry, and GraphQLSubscription over com.codename1.io.WebSocket (graphql-transport-ws). - Annotations com.codename1.annotations.graphql: @GraphQLClient, @Query/@Mutation/@Subscription, @Var (reuses @Header and @Mapped). - GraphQLClientAnnotationProcessor emits Impl + cn1app.GraphQLClientBootstrap. - cn1:generate-graphql mojo with hand-written SDL + operation-document parsers; precise operations mode and schema-only bounded-depth mode. - Bootstrap wired into JavaSEPort + Executor like the REST/gRPC ones. @Mapped enum binding: - AnnotatedClass.isEnum() + PropertyTypeKind.enumType(); MappingAnnotationProcessor upgrades REFERENCE->ENUM and detects List, emitting name()/valueOf (unknown -> null) across JSON and XML. GraphQL response/input fields now use real enum types. WebSocket subprotocols: - WebSocket.subprotocols(String...) + getSelectedSubprotocol(), threaded through WebSocketImpl. Sec-WebSocket-Protocol implemented in JavaSE, Android, iOS (webSocketTaskWithURL:protocols: + didOpenWithProtocol:), and JavaScript. GraphQL subscriptions offer graphql-transport-ws. Tests: GenerateGraphQLMojoTest, GraphQLClientAnnotationProcessorTest, GraphQLResponseTest, plus enum round-trip and subprotocol negotiation tests for the mapper and WebSocket (core mock + JavaSE RFC 6455 echo). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../annotations/graphql/GraphQLClient.java | 43 + .../annotations/graphql/Mutation.java | 33 + .../codename1/annotations/graphql/Query.java | 33 + .../annotations/graphql/Subscription.java | 34 + .../codename1/annotations/graphql/Var.java | 29 + .../src/com/codename1/impl/WebSocketImpl.java | 32 + .../src/com/codename1/io/WebSocket.java | 23 + .../src/com/codename1/io/graphql/GraphQL.java | 317 ++++++ .../codename1/io/graphql/GraphQLClients.java | 72 ++ .../codename1/io/graphql/GraphQLError.java | 83 ++ .../codename1/io/graphql/GraphQLResponse.java | 91 ++ .../io/graphql/GraphQLSubscription.java | 337 ++++++ .../codename1/io/graphql/package-info.java | 23 + .../impl/android/AndroidWebSocketImpl.java | 10 + .../com/codename1/impl/javase/JavaSEPort.java | 3 +- .../impl/javase/JavaSEWebSocketImpl.java | 10 + .../impl/html5/HTML5WebSocketImpl.java | 42 +- Ports/iOSPort/nativeSources/IOSNative.m | 7 +- Ports/iOSPort/nativeSources/WebSocketImpl.h | 2 +- Ports/iOSPort/nativeSources/WebSocketImpl.m | 14 +- .../src/com/codename1/impl/ios/IOSNative.java | 2 +- .../codename1/impl/ios/IOSWebSocketImpl.java | 22 +- .../java/com/codename1/builders/Executor.java | 3 + .../codename1/maven/GenerateGraphQLMojo.java | 963 ++++++++++++++++++ .../maven/GraphQLOperationModel.java | 433 ++++++++ .../codename1/maven/GraphQLSchemaModel.java | 506 +++++++++ .../maven/annotations/AnnotatedClass.java | 3 + .../GraphQLClientAnnotationProcessor.java | 524 ++++++++++ .../MappingAnnotationProcessor.java | 91 +- .../maven/processors/PropertyTypeKind.java | 9 + ...ame1.maven.annotations.AnnotationProcessor | 1 + .../maven/GenerateGraphQLMojoTest.java | 175 ++++ .../GraphQLClientAnnotationProcessorTest.java | 194 ++++ .../MappingAnnotationProcessorTest.java | 64 ++ .../java/com/codename1/io/WebSocketTest.java | 27 + .../io/graphql/GraphQLResponseTest.java | 145 +++ .../impl/javase/JavaSEWebSocketImplTest.java | 57 +- 37 files changed, 4437 insertions(+), 20 deletions(-) create mode 100644 CodenameOne/src/com/codename1/annotations/graphql/GraphQLClient.java create mode 100644 CodenameOne/src/com/codename1/annotations/graphql/Mutation.java create mode 100644 CodenameOne/src/com/codename1/annotations/graphql/Query.java create mode 100644 CodenameOne/src/com/codename1/annotations/graphql/Subscription.java create mode 100644 CodenameOne/src/com/codename1/annotations/graphql/Var.java create mode 100644 CodenameOne/src/com/codename1/io/graphql/GraphQL.java create mode 100644 CodenameOne/src/com/codename1/io/graphql/GraphQLClients.java create mode 100644 CodenameOne/src/com/codename1/io/graphql/GraphQLError.java create mode 100644 CodenameOne/src/com/codename1/io/graphql/GraphQLResponse.java create mode 100644 CodenameOne/src/com/codename1/io/graphql/GraphQLSubscription.java create mode 100644 CodenameOne/src/com/codename1/io/graphql/package-info.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateGraphQLMojo.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GraphQLOperationModel.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GraphQLSchemaModel.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/GraphQLClientAnnotationProcessor.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/GenerateGraphQLMojoTest.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/GraphQLClientAnnotationProcessorTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/io/graphql/GraphQLResponseTest.java diff --git a/CodenameOne/src/com/codename1/annotations/graphql/GraphQLClient.java b/CodenameOne/src/com/codename1/annotations/graphql/GraphQLClient.java new file mode 100644 index 0000000000..0c15b29376 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/graphql/GraphQLClient.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + */ +package com.codename1.annotations.graphql; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Marks an interface as a GraphQL client that the build-time +/// annotation processor wires up to a generated implementation. Each +/// abstract method carries one of [Query], [Mutation] or +/// [Subscription] holding the GraphQL operation document, zero or more +/// [Var]-annotated parameters supplying its variables, an optional +/// `@Header("Authorization") String` bearer token, and a trailing +/// callback: +/// +/// - `OnComplete>` for [Query] / [Mutation]; +/// - `GraphQLSubscription.Handler` for [Subscription], in which case +/// the method returns a `GraphQLSubscription` handle. +/// +/// The processor emits a `Impl` class and registers it with +/// [com.codename1.io.graphql.GraphQLClients] so the interface's +/// `static T of(String endpoint)` factory can return an instance +/// without the project source referencing the impl directly. Mirrors +/// [com.codename1.annotations.rest.RestClient] and +/// [com.codename1.annotations.grpc.GrpcClient]. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface GraphQLClient { + /// Optional default GraphQL endpoint URL baked into the generated + /// `of()` factory's documentation. The effective endpoint is the + /// argument passed to `of(String endpoint)`; this value is purely + /// informational and may be left empty. + String value() default ""; +} diff --git a/CodenameOne/src/com/codename1/annotations/graphql/Mutation.java b/CodenameOne/src/com/codename1/annotations/graphql/Mutation.java new file mode 100644 index 0000000000..20bf69a800 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/graphql/Mutation.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + */ +package com.codename1.annotations.graphql; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Declares a [GraphQLClient] method as a GraphQL **mutation**. +/// Identical in shape to [Query] -- the [#value()] is the operation +/// document and [Var]-annotated parameters supply its `$variables` -- +/// but sent as a mutation. The method ends with an +/// `OnComplete>` callback. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface Mutation { + /// The GraphQL operation document, e.g. + /// `mutation AddReview($ep: Episode!, $review: ReviewInput!) { createReview(episode: $ep, review: $review) { stars } }`. + String value(); + + /// The operation name to send in the request's `operationName` + /// field. Optional; required only when [#value()] declares more + /// than one operation. + String operationName() default ""; +} diff --git a/CodenameOne/src/com/codename1/annotations/graphql/Query.java b/CodenameOne/src/com/codename1/annotations/graphql/Query.java new file mode 100644 index 0000000000..f0fb71cf47 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/graphql/Query.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + */ +package com.codename1.annotations.graphql; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Declares a [GraphQLClient] method as a GraphQL **query**. The +/// [#value()] is the operation document sent verbatim to the server; +/// the method's [Var]-annotated parameters supply its `$variables`. +/// The method ends with an `OnComplete>` callback +/// whose `T` is the generated `@Mapped` response-data type. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface Query { + /// The GraphQL operation document, e.g. + /// `query HeroName($episode: Episode) { hero(episode: $episode) { name } }`. + String value(); + + /// The operation name to send in the request's `operationName` + /// field. Optional; required only when [#value()] declares more + /// than one operation. + String operationName() default ""; +} diff --git a/CodenameOne/src/com/codename1/annotations/graphql/Subscription.java b/CodenameOne/src/com/codename1/annotations/graphql/Subscription.java new file mode 100644 index 0000000000..66c8729a48 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/graphql/Subscription.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + */ +package com.codename1.annotations.graphql; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Declares a [GraphQLClient] method as a GraphQL **subscription**, +/// streamed over a WebSocket using the `graphql-transport-ws` +/// protocol. The [#value()] is the operation document and +/// [Var]-annotated parameters supply its `$variables`. The method takes +/// a trailing `GraphQLSubscription.Handler` and returns a +/// `GraphQLSubscription` handle whose `cancel()` ends the stream. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface Subscription { + /// The GraphQL operation document, e.g. + /// `subscription OnReview($ep: Episode!) { reviewAdded(episode: $ep) { stars } }`. + String value(); + + /// The operation name to send in the `subscribe` message's + /// `operationName` field. Optional; required only when [#value()] + /// declares more than one operation. + String operationName() default ""; +} diff --git a/CodenameOne/src/com/codename1/annotations/graphql/Var.java b/CodenameOne/src/com/codename1/annotations/graphql/Var.java new file mode 100644 index 0000000000..f464edee62 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/graphql/Var.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + */ +package com.codename1.annotations.graphql; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Binds a [GraphQLClient] method parameter to a GraphQL operation +/// variable. The [#value()] is the variable name as it appears (without +/// the `$`) in the operation document's variable definitions. The +/// argument is serialised into the request's `variables` object: +/// strings/numbers/booleans/lists/maps pass through directly, enums +/// serialise as their `name()`, and any other object is treated as an +/// `@Mapped` business object. A null argument omits the variable. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.PARAMETER) +public @interface Var { + /// The GraphQL variable name (without the leading `$`). + String value(); +} diff --git a/CodenameOne/src/com/codename1/impl/WebSocketImpl.java b/CodenameOne/src/com/codename1/impl/WebSocketImpl.java index e27c614776..c4c9989258 100644 --- a/CodenameOne/src/com/codename1/impl/WebSocketImpl.java +++ b/CodenameOne/src/com/codename1/impl/WebSocketImpl.java @@ -35,6 +35,11 @@ public abstract class WebSocketImpl { private final String url; private WebSocketEventSink sink; + private String[] requestedSubprotocols; + // Written by the port's connect/handshake thread, read by the user's + // connect handler -- volatile to publish the value across threads. + @SuppressWarnings("PMD.AvoidUsingVolatile") + private volatile String selectedSubprotocol; protected WebSocketImpl(String url) { this.url = url; @@ -52,6 +57,33 @@ protected final WebSocketEventSink sink() { return sink; } + /// Sets the subprotocols (RFC 6455 `Sec-WebSocket-Protocol`) the client + /// offers, in preference order. Called by the public facade before + /// `connect(int)`; ports include them in the handshake. Null or empty + /// means "no subprotocol negotiation". + public final void setRequestedSubprotocols(String[] protocols) { + this.requestedSubprotocols = protocols; + } + + /// The subprotocols the client offered, or null when none were set. + /// Ports read this while building the handshake. + protected final String[] requestedSubprotocols() { + return requestedSubprotocols; + } + + /// Records the subprotocol the server selected. Ports call this once + /// the handshake completes, before firing `sink().onConnect()`, so the + /// value is visible to the user's connect handler. + protected final void setSelectedSubprotocol(String protocol) { + this.selectedSubprotocol = protocol; + } + + /// The subprotocol the server selected, or null when none was + /// negotiated (or the connection has not completed yet). + public String getSelectedSubprotocol() { + return selectedSubprotocol; + } + /// Initiate the connection. May return immediately and complete /// asynchronously; success is signalled via `sink().onConnect()`. /// diff --git a/CodenameOne/src/com/codename1/io/WebSocket.java b/CodenameOne/src/com/codename1/io/WebSocket.java index 18753e553b..9ba59014f6 100644 --- a/CodenameOne/src/com/codename1/io/WebSocket.java +++ b/CodenameOne/src/com/codename1/io/WebSocket.java @@ -220,6 +220,29 @@ public WebSocket onError(ErrorHandler handler) { return this; } + /// Offer one or more subprotocols (RFC 6455 `Sec-WebSocket-Protocol`), + /// in preference order, to negotiate during the handshake. Must be + /// called before [connect]. After the connection opens, + /// [getSelectedSubprotocol] returns the one the server chose (or null). + /// Returns `this` for chaining. + /// + /// ``` + /// WebSocket.build("wss://api.example.com/graphql") + /// .subprotocols("graphql-transport-ws") + /// .onConnect(w -> Log.p("using " + w.getSelectedSubprotocol())) + /// .connect(); + /// ``` + public WebSocket subprotocols(String... protocols) { + impl.setRequestedSubprotocols(protocols); + return this; + } + + /// The subprotocol the server selected during the handshake, or null + /// when none was negotiated. Valid once the [ConnectHandler] has fired. + public String getSelectedSubprotocol() { + return impl.getSelectedSubprotocol(); + } + /// Start the handshake using the platform default connect timeout. /// Returns `this` for chaining; success is signalled asynchronously /// via the registered [ConnectHandler]. diff --git a/CodenameOne/src/com/codename1/io/graphql/GraphQL.java b/CodenameOne/src/com/codename1/io/graphql/GraphQL.java new file mode 100644 index 0000000000..fb8509b29e --- /dev/null +++ b/CodenameOne/src/com/codename1/io/graphql/GraphQL.java @@ -0,0 +1,317 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.io.graphql; + +import com.codename1.io.ConnectionRequest; +import com.codename1.io.Data; +import com.codename1.io.JSONParser; +import com.codename1.mapping.Mapper; +import com.codename1.mapping.Mappers; +import com.codename1.ui.CN; +import com.codename1.util.OnComplete; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// High-level GraphQL-over-HTTP invoker used by generated +/// `@GraphQLClient` implementations. Queries and mutations are sent as +/// an HTTP `POST` with a JSON body +/// `{"query":...,"operationName":...,"variables":{...}}` and a +/// `Content-Type: application/json` header; the JSON response envelope +/// (`{"data":...,"errors":[...]}`) is parsed into a typed +/// [GraphQLResponse]. Subscriptions are delegated to +/// [GraphQLSubscription] over a WebSocket. +/// +/// Mirrors [com.codename1.io.grpc.GrpcWeb]. All methods are static; +/// generated impls call [#execute] / [#subscribe] and never touch +/// `ConnectionRequest` directly. +public final class GraphQL { + + /// Content type for GraphQL-over-HTTP requests. + public static final String CONTENT_TYPE = "application/json"; + + private GraphQL() { + } + + /// Sends a unary query or mutation and invokes `callback` with the + /// decoded [GraphQLResponse]. `variables` may be null or empty. + /// The `operationName` may be null when the document declares a + /// single operation. + public static void execute( + String endpoint, + String bearerToken, + String operationName, + String document, + Map variables, + Class dataType, + final OnComplete> callback) { + if (callback == null) { + throw new IllegalArgumentException("callback must not be null"); + } + String body = buildRequestBody(operationName, document, variables); + + GraphQLConnection conn = new GraphQLConnection(dataType, callback); + conn.setUrl(endpoint); + conn.setHttpMethod("POST"); + conn.setPost(true); + conn.setContentType(CONTENT_TYPE); + conn.addRequestHeader("Accept", CONTENT_TYPE); + if (bearerToken != null && bearerToken.length() > 0) { + conn.addRequestHeader("Authorization", bearerToken); + } + conn.setRequestBody(new Data.StringData(body)); // StringData defaults to UTF-8 + CN.addToQueue(conn); + } + + /// Opens a GraphQL subscription over a WebSocket and streams mapped + /// `data` payloads to `handler`. Returns a handle whose + /// [GraphQLSubscription#cancel()] tears the subscription down. + /// + /// The `endpoint` is the GraphQL HTTP endpoint; its scheme is + /// rewritten to `ws`/`wss` for the WebSocket connection. Pass a + /// `ws://`/`wss://` URL directly to override that heuristic. + public static GraphQLSubscription subscribe( + String endpoint, + String bearerToken, + String operationName, + String document, + Map variables, + Class dataType, + GraphQLSubscription.Handler handler) { + return GraphQLSubscription.start( + toWebSocketUrl(endpoint), bearerToken, operationName, + document, variables, dataType, handler); + } + + /// Builds the JSON request body. Public so tests can verify it + /// without the network path. + public static String buildRequestBody(String operationName, String document, + Map variables) { + Map root = new LinkedHashMap(); + root.put("query", document); + if (operationName != null && operationName.length() > 0) { + root.put("operationName", operationName); + } + if (variables != null && !variables.isEmpty()) { + root.put("variables", JSONParser.rawJson(encodeVariables(variables))); + } + return JSONParser.toJson(root); + } + + /// Serialises a variable-name -> value map to a JSON object string. + /// Strings, numbers, booleans, `List`s, `Map`s and null pass + /// through the framework JSON writer; enums serialise as their + /// `name()`; any other object is assumed to be `@Mapped` and is + /// converted via [Mappers#toJson(Object)] and spliced in verbatim. + /// Public so generated impls and tests can reuse it. + public static String encodeVariables(Map variables) { + return JSONParser.toJson(toJsonTree(variables)); + } + + @SuppressWarnings("unchecked") + private static Object toJsonTree(Object value) { + if (value == null) { + return null; + } + if (value instanceof String || value instanceof Number || value instanceof Boolean + || value instanceof JSONParser.RawJson) { + return value; + } + if (value instanceof Enum) { + return ((Enum) value).name(); + } + if (value instanceof Map) { + Map src = (Map) value; + Map out = new LinkedHashMap(); + for (Map.Entry e : src.entrySet()) { + out.put(String.valueOf(e.getKey()), toJsonTree(e.getValue())); + } + return out; + } + if (value instanceof List) { + List src = (List) value; + List out = new ArrayList(src.size()); + for (Object element : src) { + out.add(toJsonTree(element)); + } + return out; + } + if (value instanceof Object[]) { + Object[] src = (Object[]) value; + List out = new ArrayList(src.length); + for (Object element : src) { + out.add(toJsonTree(element)); + } + return out; + } + // Assume an @Mapped business object: serialise via the mapper + // registry and splice the fragment in unescaped. + return JSONParser.rawJson(Mappers.toJson(value)); + } + + /// Decodes a GraphQL JSON response envelope into a typed + /// [GraphQLResponse]. Public and side-effect free so unit tests can + /// replay canned bodies without a network round-trip. + public static GraphQLResponse decodeJson(byte[] body, int httpCode, Class dataType) { + if (body == null || body.length == 0) { + return new GraphQLResponse(httpCode, null, + Collections.emptyList(), "Empty response body"); + } + Map root; + try { + root = JSONParser.parseJSON(body); + } catch (IOException ioe) { + return new GraphQLResponse(httpCode, null, + Collections.emptyList(), + "Failed to parse GraphQL response: " + ioe.getMessage()); + } + if (root == null) { + return new GraphQLResponse(httpCode, null, + Collections.emptyList(), "Empty response body"); + } + List errors = parseErrors(root.get("errors")); + T data = mapData(root.get("data"), dataType); + String message = errors.isEmpty() ? null : errors.get(0).getMessage(); + return new GraphQLResponse(httpCode, data, errors, message); + } + + @SuppressWarnings("unchecked") + static T mapData(Object dataObj, Class dataType) { + if (!(dataObj instanceof Map) || dataType == null) { + return null; + } + Mapper mapper = Mappers.get(dataType); + if (mapper == null) { + return null; + } + return mapper.fromMap((Map) dataObj); + } + + @SuppressWarnings("unchecked") + static List parseErrors(Object errorsObj) { + if (!(errorsObj instanceof List)) { + return Collections.emptyList(); + } + List raw = (List) errorsObj; + List out = new ArrayList(raw.size()); + for (Object o : raw) { + if (!(o instanceof Map)) { + continue; + } + Map m = (Map) o; + Object msg = m.get("message"); + out.add(new GraphQLError( + msg == null ? "" : String.valueOf(msg), + (List) asList(m.get("path")), + (List>) asList(m.get("locations")), + (Map) asMap(m.get("extensions")))); + } + return out; + } + + private static List asList(Object o) { + return o instanceof List ? (List) o : null; + } + + private static Map asMap(Object o) { + return o instanceof Map ? (Map) o : null; + } + + /// Rewrites an `http`/`https` endpoint to `ws`/`wss`. URLs that + /// already use a WebSocket scheme are returned unchanged. + static String toWebSocketUrl(String endpoint) { + if (endpoint == null) { + return null; + } + if (endpoint.startsWith("https://")) { + return "wss://" + endpoint.substring("https://".length()); + } + if (endpoint.startsWith("http://")) { + return "ws://" + endpoint.substring("http://".length()); + } + return endpoint; + } + + // ---------------------------------------------------------------- + // HTTP transport + // ---------------------------------------------------------------- + + /// Subclasses `ConnectionRequest` to suppress the framework's modal + /// error dialog and surface every outcome through the user's + /// callback instead. Mirrors `GrpcWeb.GrpcConnection`. + private static final class GraphQLConnection extends ConnectionRequest { + private final Class dataType; + private final OnComplete> callback; + private boolean failed; + private int failedCode; + private String failedMessage; + + GraphQLConnection(Class dataType, OnComplete> callback) { + this.dataType = dataType; + this.callback = callback; + setFailSilently(true); + setReadResponseForErrors(true); + } + + @Override + protected void handleErrorResponseCode(int code, String message) { + failed = true; + failedCode = code; + failedMessage = message; + // Swallow the framework default; reported via the callback + // in postResponse. + } + + @Override + protected void handleException(Exception err) { + failed = true; + failedCode = 0; + failedMessage = err.getMessage(); + } + + @Override + protected void postResponse() { + super.postResponse(); + if (failed) { + callback.completed(new GraphQLResponse(failedCode, null, + Collections.emptyList(), failedMessage)); + return; + } + callback.completed(decodeJson(getResponseData(), getResponseCode(), dataType)); + } + + @Override + public boolean equals(Object o) { + return this == o; + } + + @Override + public int hashCode() { + return System.identityHashCode(this); + } + } +} diff --git a/CodenameOne/src/com/codename1/io/graphql/GraphQLClients.java b/CodenameOne/src/com/codename1/io/graphql/GraphQLClients.java new file mode 100644 index 0000000000..f25e14a3c5 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/graphql/GraphQLClients.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.io.graphql; + +import java.util.HashMap; +import java.util.Map; + +/// Runtime registry that wires `@GraphQLClient`-annotated interfaces +/// to the build-time-generated implementations. The generated +/// `cn1app.GraphQLClientBootstrap` calls [#register(Class, Factory)] +/// for every GraphQL interface in the project; user code reaches them +/// via the `static of(String endpoint)` factory that +/// `cn1:generate-graphql` puts on each interface, and that factory in +/// turn calls [#create(Class, String)] here. +/// +/// Mirrors [com.codename1.io.rest.RestClients] and +/// [com.codename1.io.grpc.GrpcClients]. +public final class GraphQLClients { + + private static final Map, Factory> REGISTRY = new HashMap, Factory>(); + + private GraphQLClients() { + } + + /// Registers a factory for a `@GraphQLClient`-annotated interface. + public static void register(Class apiType, Factory factory) { + if (apiType == null || factory == null) { + return; + } + REGISTRY.put(apiType, factory); + } + + /// Returns a freshly-built client for the requested API bound to + /// `endpoint` (the GraphQL HTTP endpoint URL). + @SuppressWarnings("unchecked") + public static T create(Class apiType, String endpoint) { + Factory factory = (Factory) REGISTRY.get(apiType); + if (factory == null) { + throw new IllegalStateException( + "No GraphQLClient impl registered for " + apiType.getName() + + " -- did cn1:process-annotations run?"); + } + return factory.create(endpoint); + } + + /// Factory the generated bootstrap registers per API interface. + /// Single-method interface -- not `java.util.function.Function` + /// -- so CLDC-targeted builds remain happy. + public interface Factory { + T create(String endpoint); + } +} diff --git a/CodenameOne/src/com/codename1/io/graphql/GraphQLError.java b/CodenameOne/src/com/codename1/io/graphql/GraphQLError.java new file mode 100644 index 0000000000..6c4add84b1 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/graphql/GraphQLError.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.io.graphql; + +import java.util.List; +import java.util.Map; + +/// One entry from a GraphQL response `errors` array. A GraphQL server +/// may return errors alongside partial `data`, so an error here does +/// not necessarily mean the whole request failed -- inspect +/// [GraphQLResponse#getData()] as well. +/// +/// The spec-defined keys are surfaced directly: `message` (always +/// present), `path` (the response field path the error applies to), +/// `locations` (line/column positions in the request document), and +/// the open-ended `extensions` object. Unknown keys are ignored. +public final class GraphQLError { + + private final String message; + private final List path; + private final List> locations; + private final Map extensions; + + public GraphQLError(String message, List path, + List> locations, + Map extensions) { + this.message = message; + this.path = path; + this.locations = locations; + this.extensions = extensions; + } + + /// The human-readable error description. Never null in a + /// spec-compliant response, but defaults to an empty string when + /// the server omits it. + public String getMessage() { + return message; + } + + /// The response path the error applies to (string field names and + /// integer list indices), or null when the server did not supply + /// one. + public List getPath() { + return path; + } + + /// Source `{line, column}` locations in the request document, or + /// null when absent. + public List> getLocations() { + return locations; + } + + /// The open-ended `extensions` object (often carries an error + /// `code`), or null when absent. + public Map getExtensions() { + return extensions; + } + + @Override + public String toString() { + return message == null ? "GraphQLError" : message; + } +} diff --git a/CodenameOne/src/com/codename1/io/graphql/GraphQLResponse.java b/CodenameOne/src/com/codename1/io/graphql/GraphQLResponse.java new file mode 100644 index 0000000000..3dd8f9976f --- /dev/null +++ b/CodenameOne/src/com/codename1/io/graphql/GraphQLResponse.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.io.graphql; + +import java.util.Collections; +import java.util.List; + +/// Result of a GraphQL query or mutation. Mirrors the shape of +/// [com.codename1.io.rest.Response] and +/// [com.codename1.io.grpc.GrpcResponse] so call sites feel familiar, +/// but reflects a GraphQL-specific reality: a single response can +/// carry **both** mapped `data` and a non-empty `errors` array (a +/// partial result). Always check [#hasErrors()] in addition to +/// [#getData()]. +/// +/// [#getResponseCode()] returns the underlying HTTP status (usually +/// `200`, since GraphQL surfaces logical failures in the body rather +/// than via HTTP status). `0` is used for transport-level failures +/// that never reached an HTTP response. +public final class GraphQLResponse { + + private final int httpCode; + private final T data; + private final List errors; + private final String responseErrorMessage; + + public GraphQLResponse(int httpCode, T data, List errors, + String responseErrorMessage) { + this.httpCode = httpCode; + this.data = data; + this.errors = errors == null ? Collections.emptyList() : errors; + this.responseErrorMessage = responseErrorMessage; + } + + /// The mapped `data` payload, or null when the server returned no + /// data (a fatal error, or an empty/transport-failed response). + public T getData() { + return data; + } + + /// The `errors` array, never null (empty when the call succeeded + /// cleanly). + public List getErrors() { + return errors; + } + + /// `true` when the response carried at least one GraphQL error. + public boolean hasErrors() { + return !errors.isEmpty(); + } + + /// The underlying HTTP status code. `0` signals a transport-level + /// failure (network error, unparseable body) that never produced + /// an HTTP response. + public int getResponseCode() { + return httpCode; + } + + /// `true` iff the response carried no GraphQL errors. Note this is + /// independent of [#getData()] being non-null -- a valid response + /// to a nullable root field can be error-free with null data. + public boolean isOk() { + return errors.isEmpty(); + } + + /// The first error message (a transport-failure description when + /// the call never reached the server), or null on clean success. + public String getResponseErrorMessage() { + return responseErrorMessage; + } +} diff --git a/CodenameOne/src/com/codename1/io/graphql/GraphQLSubscription.java b/CodenameOne/src/com/codename1/io/graphql/GraphQLSubscription.java new file mode 100644 index 0000000000..748156c613 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/graphql/GraphQLSubscription.java @@ -0,0 +1,337 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.io.graphql; + +import com.codename1.io.JSONParser; +import com.codename1.io.Log; +import com.codename1.io.WebSocket; +import com.codename1.ui.CN; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// Handle for a live GraphQL subscription. Implements the +/// `graphql-transport-ws` message protocol over +/// [com.codename1.io.WebSocket] (introduced in core alongside this +/// package): `connection_init` -> `connection_ack` -> `subscribe`, +/// then a stream of `next` payloads terminated by `complete` or +/// `error`. +/// +/// Each `next` payload's `data` object is mapped to `T` and delivered +/// to [Handler#onNext(Object)]. All handler callbacks are dispatched on +/// the Codename One EDT (matching the `OnComplete` semantics the query +/// and mutation paths use), even though the underlying WebSocket fires +/// on a background thread. +/// +/// The connection offers the `graphql-transport-ws` subprotocol during +/// the WebSocket handshake (RFC 6455 `Sec-WebSocket-Protocol`), as the +/// graphql-ws specification requires. +public final class GraphQLSubscription { + + /// The graphql-ws (graphql-transport-ws) WebSocket subprotocol name. + static final String SUBPROTOCOL = "graphql-transport-ws"; + + /// Receives the events of one subscription. All methods run on the + /// Codename One EDT. + public interface Handler { + /// A `next` payload arrived and its `data` mapped to `T`. When + /// the payload also carried `errors`, `response.hasErrors()` is + /// true and `response.getData()` may still be non-null. + void onNext(GraphQLResponse response); + + /// The subscription failed (an `error` message, a transport + /// error, or a non-clean close). No further events follow. + void onError(GraphQLResponse response); + + /// The subscription ended cleanly (server `complete`, or a + /// caller [#cancel()]). No further events follow. + void onComplete(); + } + + private static final String OPERATION_ID = "1"; + + private final Class dataType; + private final String operationName; + private final String document; + private final Map variables; + private final String bearerToken; + private final Handler handler; + + private WebSocket socket; + private boolean terminated; + + private GraphQLSubscription(Class dataType, String operationName, String document, + Map variables, String bearerToken, + Handler handler) { + this.dataType = dataType; + this.operationName = operationName; + this.document = document; + this.variables = variables; + this.bearerToken = bearerToken; + this.handler = handler; + } + + static GraphQLSubscription start(String wsUrl, String bearerToken, String operationName, + String document, Map variables, + Class dataType, Handler handler) { + if (handler == null) { + throw new IllegalArgumentException("handler must not be null"); + } + final GraphQLSubscription sub = new GraphQLSubscription( + dataType, operationName, document, variables, bearerToken, handler); + WebSocket ws = WebSocket.build(wsUrl) + .subprotocols(SUBPROTOCOL) + .onConnect(new WebSocket.ConnectHandler() { + @Override + public void onConnect(WebSocket w) { + sub.sendConnectionInit(w); + } + }) + .onTextMessage(new WebSocket.TextHandler() { + @Override + public void onText(WebSocket w, String message) { + sub.onMessage(w, message); + } + }) + .onClose(new WebSocket.CloseHandler() { + @Override + public void onClose(WebSocket w, int statusCode, String reason) { + // 1000 (normal) and 1005 (no status) are clean. + if (statusCode == 1000 || statusCode == 1005) { + sub.finishComplete(); + } else { + sub.finishError(transportError( + "WebSocket closed: " + statusCode + + (reason == null || reason.length() == 0 ? "" : " " + reason))); + } + } + }) + .onError(new WebSocket.ErrorHandler() { + @Override + public void onError(WebSocket w, Exception ex) { + sub.finishError(transportError( + ex == null ? "WebSocket error" : ex.getMessage())); + } + }); + sub.socket = ws; + ws.connect(); + return sub; + } + + /// Cancels the subscription: sends a `complete` to the server and + /// closes the socket. Idempotent; safe to call from any thread. + public void cancel() { + WebSocket w; + synchronized (this) { + if (terminated) { + return; + } + terminated = true; + w = socket; + } + if (w != null) { + try { + w.send(message("complete", OPERATION_ID, null)); + } catch (Throwable ignored) { + // Socket may already be closing; closing below covers it. + } + try { + w.close(); + } catch (Throwable ignored) { + Log.e(ignored); + } + } + } + + // ---------------------------------------------------------------- + // Protocol handling (runs on the WebSocket background thread) + // ---------------------------------------------------------------- + + private void sendConnectionInit(WebSocket w) { + Map init = new LinkedHashMap(); + init.put("type", "connection_init"); + if (bearerToken != null && bearerToken.length() > 0) { + Map payload = new LinkedHashMap(); + payload.put("Authorization", bearerToken); + init.put("payload", JSONParser.rawJson(JSONParser.toJson(payload))); + } + safeSend(w, JSONParser.toJson(init)); + } + + @SuppressWarnings("unchecked") + private void onMessage(WebSocket w, String text) { + Map msg; + try { + msg = JSONParser.parseJSON(text); + } catch (Exception e) { + // Malformed frame -- log and drop it rather than tear the + // subscription down over one bad message. + Log.e(e); + return; + } + if (msg == null) { + return; + } + String type = str(msg.get("type")); + if ("connection_ack".equals(type)) { + safeSend(w, subscribeMessage()); + } else if ("next".equals(type)) { + deliverNext(msg.get("payload")); + } else if ("error".equals(type)) { + List errors = GraphQL.parseErrors(asErrorsArray(msg.get("payload"))); + finishError(new GraphQLResponse(0, null, errors, + errors.isEmpty() ? "Subscription error" : errors.get(0).getMessage())); + } else if ("complete".equals(type)) { + finishComplete(); + WebSocket s = socket; + if (s != null) { + try { + s.close(); + } catch (Throwable ignored) { + Log.e(ignored); + } + } + } else if ("ping".equals(type)) { + safeSend(w, "{\"type\":\"pong\"}"); + } + // connection_error / unknown types are ignored. + } + + @SuppressWarnings("unchecked") + private void deliverNext(Object payload) { + synchronized (this) { + if (terminated) { + return; + } + } + Object dataObj = null; + Object errorsObj = null; + if (payload instanceof Map) { + Map p = (Map) payload; + dataObj = p.get("data"); + errorsObj = p.get("errors"); + } + Object data = GraphQL.mapData(dataObj, dataType); + List errors = GraphQL.parseErrors(errorsObj); + String message = errors.isEmpty() ? null : errors.get(0).getMessage(); + final GraphQLResponse response = + new GraphQLResponse(200, data, errors, message); + dispatch(new Runnable() { + @Override + public void run() { + ((Handler) handler).onNext(response); + } + }); + } + + private void finishComplete() { + synchronized (this) { + if (terminated) { + return; + } + terminated = true; + } + dispatch(new Runnable() { + @Override + public void run() { + handler.onComplete(); + } + }); + } + + @SuppressWarnings("unchecked") + private void finishError(final GraphQLResponse response) { + synchronized (this) { + if (terminated) { + return; + } + terminated = true; + } + dispatch(new Runnable() { + @Override + public void run() { + ((Handler) handler).onError(response); + } + }); + } + + // ---------------------------------------------------------------- + // Helpers + // ---------------------------------------------------------------- + + private String subscribeMessage() { + return message("subscribe", OPERATION_ID, + GraphQL.buildRequestBody(operationName, document, variables)); + } + + /// Builds a `graphql-transport-ws` envelope. When `payloadJson` is + /// non-null it is spliced in as the raw `payload` object. + private static String message(String type, String id, String payloadJson) { + Map m = new LinkedHashMap(); + m.put("id", id); + m.put("type", type); + if (payloadJson != null) { + m.put("payload", JSONParser.rawJson(payloadJson)); + } + return JSONParser.toJson(m); + } + + private static void safeSend(WebSocket w, String text) { + try { + w.send(text); + } catch (Throwable t) { + Log.e(t); + } + } + + private static void dispatch(Runnable r) { + CN.callSerially(r); + } + + private static GraphQLResponse transportError(String message) { + return new GraphQLResponse(0, null, + Collections.emptyList(), message); + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } + + /// The `error` message `payload` is an array of error objects; + /// wrap it so [GraphQL#parseErrors(Object)] (which expects the + /// value of an `errors` key) can consume it. + private static Object asErrorsArray(Object payload) { + if (payload instanceof List) { + return payload; + } + if (payload instanceof Map) { + List one = new ArrayList(1); + one.add(payload); + return one; + } + return null; + } +} diff --git a/CodenameOne/src/com/codename1/io/graphql/package-info.java b/CodenameOne/src/com/codename1/io/graphql/package-info.java new file mode 100644 index 0000000000..20dec554b2 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/graphql/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + */ +/// GraphQL client runtime. [GraphQL] sends queries and mutations over +/// HTTP `POST` and parses the `{"data":...,"errors":[...]}` envelope; +/// [GraphQLResponse] is the typed result wrapper (data and errors can +/// co-exist as a partial result); [GraphQLError] is one entry of the +/// errors array; [GraphQLSubscription] streams subscription payloads +/// over a WebSocket; [GraphQLClients] is the per-`@GraphQLClient` +/// factory registry that the build-time-generated +/// `cn1app.GraphQLClientBootstrap` populates. +/// +/// End-to-end usage is documented on +/// [com.codename1.annotations.graphql] -- the user-facing entry point +/// is the generated `.of(endpoint)` factory rather than any +/// class in this package directly. +package com.codename1.io.graphql; diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidWebSocketImpl.java b/Ports/Android/src/com/codename1/impl/android/AndroidWebSocketImpl.java index 9583200ea4..b844a6d771 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidWebSocketImpl.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidWebSocketImpl.java @@ -153,6 +153,15 @@ private void doHandshake(int connectTimeoutMs) throws IOException { req.append("Connection: Upgrade\r\n"); req.append("Sec-WebSocket-Key: ").append(key).append("\r\n"); req.append("Sec-WebSocket-Version: 13\r\n"); + String[] subs = requestedSubprotocols(); + if (subs != null && subs.length > 0) { + req.append("Sec-WebSocket-Protocol: "); + for (int i = 0; i < subs.length; i++) { + if (i > 0) req.append(", "); + req.append(subs[i]); + } + req.append("\r\n"); + } req.append("\r\n"); out.write(req.toString().getBytes(ASCII)); out.flush(); @@ -194,6 +203,7 @@ private void doHandshake(int connectTimeoutMs) throws IOException { if (accept == null || !accept.equals(expected)) { throw new IOException("Sec-WebSocket-Accept mismatch"); } + setSelectedSubprotocol(headers.get("sec-websocket-protocol")); } private static String readHeaderLine(InputStream in) throws IOException { diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 96646b367d..a916145f22 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -7683,7 +7683,8 @@ public void postInit() { "cn1app.DaoBootstrap", "cn1app.RestClientBootstrap", "cn1app.ProtoBootstrap", - "cn1app.GrpcClientBootstrap"}) { + "cn1app.GrpcClientBootstrap", + "cn1app.GraphQLClientBootstrap"}) { try { Class.forName(bootstrap).newInstance(); } catch (ClassNotFoundException ignored) { diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWebSocketImpl.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWebSocketImpl.java index 93a42df99d..ed50c455a4 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWebSocketImpl.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWebSocketImpl.java @@ -154,6 +154,15 @@ private void doHandshake(int connectTimeoutMs) throws IOException { req.append("Connection: Upgrade\r\n"); req.append("Sec-WebSocket-Key: ").append(key).append("\r\n"); req.append("Sec-WebSocket-Version: 13\r\n"); + String[] subs = requestedSubprotocols(); + if (subs != null && subs.length > 0) { + req.append("Sec-WebSocket-Protocol: "); + for (int i = 0; i < subs.length; i++) { + if (i > 0) req.append(", "); + req.append(subs[i]); + } + req.append("\r\n"); + } req.append("\r\n"); out.write(req.toString().getBytes(StandardCharsets.ISO_8859_1)); out.flush(); @@ -195,6 +204,7 @@ private void doHandshake(int connectTimeoutMs) throws IOException { if (accept == null || !accept.equals(expected)) { throw new IOException("Sec-WebSocket-Accept mismatch"); } + setSelectedSubprotocol(headers.get("sec-websocket-protocol")); } private static String readHeaderLine(InputStream in) throws IOException { diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5WebSocketImpl.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5WebSocketImpl.java index 2bc5b7f3b5..5b9350a7c7 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5WebSocketImpl.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5WebSocketImpl.java @@ -57,12 +57,19 @@ public void connect(int connectTimeoutMs) { // connectTimeoutMs is unused: browser WebSockets don't expose a // per-connect timeout. Users who need one should wrap connect() // with their own Display.callSerially-driven timer. - final BrowserWebSocket w = createSocket(getUrl()); + // Common path (no subprotocols) stays byte-identical to the + // single-arg createSocket; only request protocols when asked. + String csv = subprotocolsCsv(); + final BrowserWebSocket w = csv == null + ? createSocket(getUrl()) + : createSocketWithProtocols(getUrl(), csv); ws = w; w.addEventListener("open", new EventListener() { @Override public void handleEvent(Event evt) { state = WebSocketState.OPEN; + String selected = socketProtocol(w); + setSelectedSubprotocol(selected == null || selected.length() == 0 ? null : selected); sink().onConnect(); } }); @@ -95,15 +102,42 @@ public void handleEvent(Event evt) { }); } - // The only @JSBody: we need `new WebSocket`. The URL is a Java String, which - // is not auto-marshalled into the script, so convert it with the runtime's - // jvm.toNativeString (the same helper the generated code uses everywhere). + /// Comma-joined view of the requested subprotocols, or null when none + /// were set. createSocket splits it back into an array for the browser + /// `WebSocket(url, protocols)` constructor. + private String subprotocolsCsv() { + String[] subs = requestedSubprotocols(); + if (subs == null || subs.length == 0) { + return null; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < subs.length; i++) { + if (i > 0) sb.append(','); + sb.append(subs[i]); + } + return sb.toString(); + } + + // The @JSBody constructors: we need `new WebSocket`. Java String args are + // not auto-marshalled into the script, so convert them with the runtime's + // jvm.toNativeString (the helper the generated code uses everywhere). @JSBody(params = {"url"}, script = "var w = new WebSocket(jvm.toNativeString(url));\n" + "w.binaryType = 'arraybuffer';\n" + "return w;") private static native BrowserWebSocket createSocket(String url); + // protocolsCsv is a non-null comma-separated list, split into the array + // the browser `WebSocket(url, protocols)` constructor expects. + @JSBody(params = {"url", "protocolsCsv"}, script = + "var w = new WebSocket(jvm.toNativeString(url), jvm.toNativeString(protocolsCsv).split(','));\n" + + "w.binaryType = 'arraybuffer';\n" + + "return w;") + private static native BrowserWebSocket createSocketWithProtocols(String url, String protocolsCsv); + + @JSBody(params = {"w"}, script = "return '' + (w.protocol || '');") + private static native String socketProtocol(BrowserWebSocket w); + @JSBody(params = {"e"}, script = "return (typeof e.data === 'string');") private static native boolean eventDataIsString(JSObject e); diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index b1ad753018..5aa823620f 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -9261,10 +9261,13 @@ JAVA_LONG com_codename1_impl_ios_IOSNative_createWebSocketNative___int_java_lang return (JAVA_LONG)impl; } -void com_codename1_impl_ios_IOSNative_connectWebSocketNative___long_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG handle, JAVA_INT timeoutMs) { +void com_codename1_impl_ios_IOSNative_connectWebSocketNative___long_int_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG handle, JAVA_INT timeoutMs, JAVA_OBJECT subprotocolsCsv) { POOL_BEGIN(); CN1WebSocketImpl* impl = (BRIDGE_CAST CN1WebSocketImpl*)((void *)handle); - [impl connectWithTimeoutMs:timeoutMs]; + NSString* csv = subprotocolsCsv == NULL ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG subprotocolsCsv); + NSArray* protocols = (csv != nil && [csv length] > 0) + ? [csv componentsSeparatedByString:@","] : nil; + [impl connectWithTimeoutMs:timeoutMs protocols:protocols]; POOL_END(); } diff --git a/Ports/iOSPort/nativeSources/WebSocketImpl.h b/Ports/iOSPort/nativeSources/WebSocketImpl.h index 0fe2ab0a62..3db6472a35 100644 --- a/Ports/iOSPort/nativeSources/WebSocketImpl.h +++ b/Ports/iOSPort/nativeSources/WebSocketImpl.h @@ -36,7 +36,7 @@ API_AVAILABLE(ios(13.0)) } -(instancetype)initWithId:(int)cid url:(NSString*)urlString; --(void)connectWithTimeoutMs:(int)timeoutMs; +-(void)connectWithTimeoutMs:(int)timeoutMs protocols:(NSArray*)protocols; -(void)closeConnection; -(void)sendText:(NSString*)message; -(void)sendBinary:(NSData*)data; diff --git a/Ports/iOSPort/nativeSources/WebSocketImpl.m b/Ports/iOSPort/nativeSources/WebSocketImpl.m index fd39ff4c72..53fbdcd2ed 100644 --- a/Ports/iOSPort/nativeSources/WebSocketImpl.m +++ b/Ports/iOSPort/nativeSources/WebSocketImpl.m @@ -53,7 +53,7 @@ -(void)dealloc { [super dealloc]; } --(void)connectWithTimeoutMs:(int)timeoutMs { +-(void)connectWithTimeoutMs:(int)timeoutMs protocols:(NSArray*)protocols { if (closed || url == nil) { return; } @@ -63,7 +63,11 @@ -(void)connectWithTimeoutMs:(int)timeoutMs { } if (@available(iOS 13.0, *)) { session = [[NSURLSession sessionWithConfiguration:cfg delegate:self delegateQueue:nil] retain]; - task = [[session webSocketTaskWithURL:url] retain]; + if (protocols != nil && [protocols count] > 0) { + task = [[session webSocketTaskWithURL:url protocols:protocols] retain]; + } else { + task = [[session webSocketTaskWithURL:url] retain]; + } [task resume]; [self armReceive]; } @@ -168,8 +172,10 @@ -(void)fireErrorWithMessage:(NSString*)msg { -(void)URLSession:(NSURLSession*)sess webSocketTask:(NSURLSessionWebSocketTask*)t didOpenWithProtocol:(NSString*)protocol API_AVAILABLE(ios(13.0)) { - com_codename1_impl_ios_IOSWebSocketImpl_fireConnect___int( - CN1_THREAD_GET_STATE_PASS_ARG connectionId); + JAVA_OBJECT jprotocol = fromNSString(CN1_THREAD_GET_STATE_PASS_ARG + (protocol != nil ? protocol : @"")); + com_codename1_impl_ios_IOSWebSocketImpl_fireConnect___int_java_lang_String( + CN1_THREAD_GET_STATE_PASS_ARG connectionId, jprotocol); } -(void)URLSession:(NSURLSession*)sess webSocketTask:(NSURLSessionWebSocketTask*)t diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index cda442de91..a8de471af5 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -613,7 +613,7 @@ native void fillGradient(int kind, int stopCount, float[] positions, float[] pre public native void writeToSocketStream(long socket, byte[] data, int offset, int len); public native long createWebSocketNative(int connectionId, String url); - public native void connectWebSocketNative(long handle, int connectTimeoutMs); + public native void connectWebSocketNative(long handle, int connectTimeoutMs, String subprotocolsCsv); public native void closeWebSocketNative(long handle); public native void sendWebSocketTextNative(long handle, String text); public native void sendWebSocketBinaryNative(long handle, byte[] data); diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSWebSocketImpl.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSWebSocketImpl.java index df7d02d127..8d9c68189e 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSWebSocketImpl.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSWebSocketImpl.java @@ -60,7 +60,23 @@ class IOSWebSocketImpl extends WebSocketImpl { @Override public void connect(int connectTimeoutMs) { - IOSImplementation.nativeInstance.connectWebSocketNative(nativePtr, connectTimeoutMs); + IOSImplementation.nativeInstance.connectWebSocketNative( + nativePtr, connectTimeoutMs, subprotocolsCsv()); + } + + /// Comma-joined view of the requested subprotocols (or null), split + /// back into an NSArray by the native task creation. + private String subprotocolsCsv() { + String[] subs = requestedSubprotocols(); + if (subs == null || subs.length == 0) { + return null; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < subs.length; i++) { + if (i > 0) sb.append(','); + sb.append(subs[i]); + } + return sb.toString(); } @Override @@ -95,12 +111,14 @@ public WebSocketState getReadyState() { /* ---------- static dispatch from native code ---------- */ - public static void fireConnect(int connectionId) { + public static void fireConnect(int connectionId, String selectedProtocol) { IOSWebSocketImpl ws = lookup(connectionId); if (ws == null) { return; } ws.state = WebSocketState.OPEN; + ws.setSelectedSubprotocol(selectedProtocol == null + || selectedProtocol.length() == 0 ? null : selectedProtocol); ws.sink().onConnect(); } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java index c7375ff8e9..72d6156f98 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -2055,6 +2055,9 @@ protected static String annotationFrameworksInstallSource(File sourceZip, String if (projectHasBootstrap(sourceZip, "cn1app/GrpcClientBootstrap.class")) { sb.append(indent).append("new cn1app.GrpcClientBootstrap();\n"); } + if (projectHasBootstrap(sourceZip, "cn1app/GraphQLClientBootstrap.class")) { + sb.append(indent).append("new cn1app.GraphQLClientBootstrap();\n"); + } return sb.toString(); } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateGraphQLMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateGraphQLMojo.java new file mode 100644 index 0000000000..90488dc073 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateGraphQLMojo.java @@ -0,0 +1,963 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven; + +import com.codename1.maven.GraphQLOperationModel.Document; +import com.codename1.maven.GraphQLOperationModel.Field; +import com.codename1.maven.GraphQLOperationModel.FragmentDef; +import com.codename1.maven.GraphQLOperationModel.FragmentSpread; +import com.codename1.maven.GraphQLOperationModel.InlineFragment; +import com.codename1.maven.GraphQLOperationModel.OperationDef; +import com.codename1.maven.GraphQLOperationModel.Selection; +import com.codename1.maven.GraphQLOperationModel.VarDef; +import com.codename1.maven.GraphQLSchemaModel.ArgDef; +import com.codename1.maven.GraphQLSchemaModel.EnumDef; +import com.codename1.maven.GraphQLSchemaModel.FieldDef; +import com.codename1.maven.GraphQLSchemaModel.ObjectTypeDef; +import com.codename1.maven.GraphQLSchemaModel.Schema; +import com.codename1.maven.GraphQLSchemaModel.TypeRef; + +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.project.MavenProject; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/// Generates user-edited Codename One GraphQL client sources from a +/// GraphQL SDL schema (and, optionally, a set of operation documents). +/// +/// Invocation: +/// +/// ``` +/// mvn cn1:generate-graphql -Dcn1.graphql.schema=schema.graphqls \ +/// -Dcn1.graphql.operations=ops.graphql \ +/// -Dcn1.graphql.basePackage=com.example.api +/// ``` +/// +/// Two modes: +/// +/// - **Operations mode** (when `cn1.graphql.operations` is supplied): +/// for each named `query` / `mutation` / `subscription` in the +/// operation document(s), emits one client-interface method plus the +/// precise `@Mapped` response classes matching that operation's +/// selection set. This is the recommended, precise path. +/// - **Schema-only quick-start mode** (no operations file): for each +/// field of the root `Query` / `Mutation` / `Subscription` type emits +/// one client method whose selection set is auto-expanded to a bounded +/// depth (`cn1.graphql.maxDepth`, default 2), stopping at cycles. A +/// convenience that may over- or under-fetch; prefer operations mode +/// for production. +/// +/// GraphQL enums map to a generated Java enum in response classes, input +/// classes, and variable parameters alike (the JSON mapper binds enums via +/// their `name()`); custom scalars fall back to `String`. +@Mojo(name = "generate-graphql", + defaultPhase = LifecyclePhase.NONE, + requiresProject = true, + threadSafe = true) +public class GenerateGraphQLMojo extends AbstractMojo { + + @Parameter(defaultValue = "${project}", readonly = true) + private MavenProject project; + + /// Path or URL of the GraphQL SDL schema file + /// (`.graphqls` / `.graphql`). Override via + /// `-Dcn1.graphql.schema=...`. + @Parameter(property = "cn1.graphql.schema") + private String schema; + + /// Optional path to a GraphQL operation-document file (or a + /// directory of `.graphql` files). When supplied the generator runs + /// in the precise operations mode; when omitted it falls back to the + /// schema-only quick-start mode. Override via + /// `-Dcn1.graphql.operations=...`. + @Parameter(property = "cn1.graphql.operations") + private String operations; + + /// Java base package the generated sources are emitted under. + /// Override via `-Dcn1.graphql.basePackage=...`. + @Parameter(property = "cn1.graphql.basePackage") + private String basePackage; + + /// Simple name of the generated `@GraphQLClient` interface. + @Parameter(property = "cn1.graphql.clientName", defaultValue = "GraphQLApi") + private String clientName; + + /// Optional default endpoint URL recorded on the generated + /// `@GraphQLClient` annotation (informational only; the effective + /// endpoint is the argument to `of(String endpoint)`). + @Parameter(property = "cn1.graphql.endpoint", defaultValue = "") + private String endpoint; + + /// Maximum selection-set depth used by schema-only quick-start mode. + @Parameter(property = "cn1.graphql.maxDepth", defaultValue = "2") + private int maxDepth; + + /// Output directory for the generated sources. Defaults to + /// `${project.basedir}/src/main/java` because the emitted code is + /// user-edited. + @Parameter(property = "cn1.graphql.outputDirectory", + defaultValue = "${project.basedir}/src/main/java") + private File outputDirectory; + + /// When `true` (default) existing files at the destination are + /// overwritten. Pass `-Dcn1.graphql.overwrite=false` to keep your + /// hand-edits and only emit missing files. + @Parameter(property = "cn1.graphql.overwrite", defaultValue = "true") + private boolean overwrite; + + @Override + public void execute() throws MojoExecutionException, MojoFailureException { + String effSchema = effective(schema, "cn1.graphql.schema"); + String effPackage = effective(basePackage, "cn1.graphql.basePackage"); + if (effSchema == null || effSchema.length() == 0) { + throw new MojoFailureException("No schema supplied. Pass " + + "-Dcn1.graphql.schema= or configure ."); + } + if (effPackage == null || effPackage.length() == 0) { + throw new MojoFailureException("No base package supplied. Pass " + + "-Dcn1.graphql.basePackage= or configure ."); + } + File schemaFile = new File(effSchema); + if (!schemaFile.exists()) { + throw new MojoFailureException("Schema file not found: " + schemaFile); + } + + Schema parsedSchema; + try { + String src = new String(Files.readAllBytes(schemaFile.toPath()), StandardCharsets.UTF_8); + parsedSchema = new GraphQLSchemaModel.Parser(src, schemaFile.getName()).parse(); + } catch (IOException ioe) { + throw new MojoFailureException("Could not read " + schemaFile + ": " + ioe.getMessage(), ioe); + } catch (GraphQLSchemaModel.ParseException ppe) { + throw new MojoFailureException(ppe.getMessage(), ppe); + } + + Document doc = null; + String effOps = effective(operations, "cn1.graphql.operations"); + if (effOps != null && effOps.length() > 0) { + try { + doc = parseOperations(new File(effOps)); + } catch (IOException ioe) { + throw new MojoFailureException("Could not read operations " + effOps + + ": " + ioe.getMessage(), ioe); + } catch (GraphQLOperationModel.ParseException ppe) { + throw new MojoFailureException(ppe.getMessage(), ppe); + } + } + + int target = GenerateOpenApiMojo.parseJavaVersion(detectJavaTargetString()); + boolean emitRecords = target >= 17; + getLog().info("cn1:generate-graphql target=" + target + " emitRecords=" + emitRecords + + " basePackage=" + effPackage + + " mode=" + (doc != null ? "operations" : "schema-only")); + + Generator gen = new Generator(parsedSchema, doc, effPackage, outputDirectory, + overwrite, emitRecords, endpoint, clientName, maxDepth, getLog()); + try { + gen.run(); + } catch (IOException ioe) { + throw new MojoExecutionException("Failed to write generated sources: " + + ioe.getMessage(), ioe); + } + } + + private Document parseOperations(File path) throws IOException { + StringBuilder all = new StringBuilder(); + if (path.isDirectory()) { + File[] files = path.listFiles(); + if (files != null) { + for (File f : files) { + if (f.isFile() && (f.getName().endsWith(".graphql") || f.getName().endsWith(".gql"))) { + all.append(new String(Files.readAllBytes(f.toPath()), StandardCharsets.UTF_8)); + all.append('\n'); + } + } + } + } else { + if (!path.exists()) { + throw new IOException("operations path not found: " + path); + } + all.append(new String(Files.readAllBytes(path.toPath()), StandardCharsets.UTF_8)); + } + return new GraphQLOperationModel.Parser(all.toString(), path.getName()).parse(); + } + + private String effective(String configured, String prop) { + if (configured != null && configured.length() > 0) return configured; + return System.getProperty(prop); + } + + private String detectJavaTargetString() { + String release = null, targetProp = null; + if (project != null && project.getProperties() != null) { + release = project.getProperties().getProperty("maven.compiler.release"); + targetProp = project.getProperties().getProperty("maven.compiler.target"); + } + if (release == null) release = System.getProperty("maven.compiler.release"); + if (targetProp == null) targetProp = System.getProperty("maven.compiler.target"); + return release != null ? release : targetProp; + } + + // ---------------------------------------------------------------- + // Generator + // ---------------------------------------------------------------- + + static final class Generator { + private final Schema schema; + private final Document operations; + private final String basePackage; + private final File outputDir; + private final boolean overwrite; + private final boolean emitRecords; + private final String endpoint; + private final String clientName; + private final int maxDepth; + private final org.apache.maven.plugin.logging.Log log; + + private final Set emitted = new LinkedHashSet(); + private final List methods = new ArrayList(); + private final Set methodNames = new LinkedHashSet(); + private File pkgDir; + + Generator(Schema schema, Document operations, String basePackage, File outputDir, + boolean overwrite, boolean emitRecords, String endpoint, String clientName, + int maxDepth, org.apache.maven.plugin.logging.Log log) { + this.schema = schema; + this.operations = operations; + this.basePackage = basePackage; + this.outputDir = outputDir; + this.overwrite = overwrite; + this.emitRecords = emitRecords; + this.endpoint = endpoint; + this.clientName = clientName; + this.maxDepth = maxDepth; + this.log = log; + } + + void run() throws IOException { + pkgDir = new File(outputDir, basePackage.replace('.', '/')); + ensureDir(pkgDir); + if (operations != null && !operations.operations.isEmpty()) { + generateFromOperations(); + } else { + generateFromSchema(); + } + emitClientInterface(); + log.info("Generated " + methods.size() + " GraphQL operation method(s) and " + + emitted.size() + " model class(es) under " + outputDir); + } + + // -- operations mode ----------------------------------------- + + private void generateFromOperations() throws IOException { + for (OperationDef op : operations.operations) { + String rootType = rootTypeFor(op.kind); + String opName = op.name != null ? op.name : deriveName(op); + String methodName = unique(lowerFirst(javaName(opName))); + String dataClass = cap(opName) + "Data"; + emitResponseClass(dataClass, rootType, op.selections); + + List params = new ArrayList(); + for (VarDef v : op.vars) { + Param p = new Param(); + p.varName = v.name; + p.name = javaName(v.name); + p.javaType = javaTypeForVar(v.type); + params.add(p); + } + + String document = minify(op.rawText + appendedFragments(op.directSpreads)); + methods.add(buildMethod(op.kind, methodName, document, op.name, params, dataClass)); + } + } + + private String deriveName(OperationDef op) { + for (Selection s : op.selections) { + if (s instanceof Field) { + return ((Field) s).name; + } + } + return "operation"; + } + + /// Transitively gathers the fragment definitions referenced by + /// `directSpreads` and returns their raw text appended (so the + /// request document is self-contained). + private String appendedFragments(Set directSpreads) { + Set resolved = new LinkedHashSet(); + collectFragments(directSpreads, resolved); + StringBuilder sb = new StringBuilder(); + for (String name : resolved) { + FragmentDef fd = operations.fragments.get(name); + if (fd != null) { + sb.append('\n').append(fd.rawText); + } + } + return sb.toString(); + } + + private void collectFragments(Set spreads, Set into) { + for (String name : spreads) { + if (into.add(name)) { + FragmentDef fd = operations.fragments.get(name); + if (fd != null) { + collectFragments(fd.directSpreads, into); + } + } + } + } + + /// Emits the `@Mapped` response class for `selections` resolved + /// against `typeName`, recursing into nested object selections. + private void emitResponseClass(String className, String typeName, List selections) + throws IOException { + if (!emitted.add(className)) { + return; + } + Map fields = new LinkedHashMap(); + collectSelections(typeName, selections, fields); + + List modelFields = new ArrayList(); + for (Resolved r : fields.values()) { + ModelField mf = new ModelField(); + mf.jsonName = r.responseKey; + mf.javaName = javaName(r.responseKey); + if ("__typename".equals(r.name)) { + mf.javaType = "String"; + modelFields.add(mf); + continue; + } + FieldDef def = findField(r.contextType, r.name); + if (def == null) { + log.warn("cn1:generate-graphql: field '" + r.name + "' not found on type '" + + r.contextType + "'; emitting it as String"); + mf.javaType = "String"; + modelFields.add(mf); + continue; + } + String baseName = def.type.baseName(); + if (!r.subselections.isEmpty()) { + String nested = className + "_" + cap(r.responseKey); + emitResponseClass(nested, baseName, r.subselections); + mf.javaType = wrapList(def.type, nested); + } else { + mf.javaType = wrapList(def.type, leafMappedJava(baseName)); + } + modelFields.add(mf); + } + emitMapped(className, modelFields); + } + + private void collectSelections(String typeName, List sels, + Map out) { + for (Selection s : sels) { + if (s instanceof Field) { + Field f = (Field) s; + String key = f.responseKey(); + Resolved r = out.get(key); + if (r == null) { + r = new Resolved(); + r.responseKey = key; + r.name = f.name; + r.contextType = typeName; + out.put(key, r); + } + r.subselections.addAll(f.selections); + } else if (s instanceof FragmentSpread) { + FragmentDef fd = operations.fragments.get(((FragmentSpread) s).fragmentName); + if (fd != null) { + collectSelections(fd.typeCondition, fd.selections, out); + } + } else if (s instanceof InlineFragment) { + InlineFragment inf = (InlineFragment) s; + String t = inf.typeCondition != null ? inf.typeCondition : typeName; + collectSelections(t, inf.selections, out); + } + } + } + + // -- schema-only mode ---------------------------------------- + + private void generateFromSchema() throws IOException { + emitRootMethods(schema.queryType, GraphQLOperationModel.OP_QUERY, "Query"); + emitRootMethods(schema.mutationType, GraphQLOperationModel.OP_MUTATION, "Mutation"); + emitRootMethods(schema.subscriptionType, GraphQLOperationModel.OP_SUBSCRIPTION, "Subscription"); + } + + private void emitRootMethods(String rootTypeName, String kind, String roleWord) + throws IOException { + ObjectTypeDef root = schema.object(rootTypeName); + if (root == null) { + return; + } + for (FieldDef f : root.fields) { + String methodName = unique(lowerFirst(javaName(f.name))); + String dataClass = cap(f.name) + roleWord + "Data"; + String baseName = f.type.baseName(); + + String fieldSelection; + String fieldJavaType; + if (schema.isObject(baseName) && schema.object(baseName) != null) { + String fieldClass = dataClass + "_" + cap(f.name); + Set visited = new LinkedHashSet(); + visited.add(baseName); + fieldSelection = autoSelect(baseName, fieldClass, maxDepth, visited); + fieldJavaType = wrapList(f.type, fieldClass); + } else { + fieldSelection = ""; + fieldJavaType = wrapList(f.type, leafMappedJava(baseName)); + } + + ModelField wrapperField = new ModelField(); + wrapperField.jsonName = f.name; + wrapperField.javaName = javaName(f.name); + wrapperField.javaType = fieldJavaType; + List one = new ArrayList(); + one.add(wrapperField); + if (emitted.add(dataClass)) { + emitMapped(dataClass, one); + } + + List params = new ArrayList(); + StringBuilder varDefs = new StringBuilder(); + StringBuilder argList = new StringBuilder(); + for (ArgDef a : f.args) { + Param p = new Param(); + p.varName = a.name; + p.name = javaName(a.name); + p.javaType = javaTypeForVar(a.type); + params.add(p); + if (varDefs.length() > 0) varDefs.append(", "); + varDefs.append('$').append(a.name).append(": ").append(typeRefToSdl(a.type)); + if (argList.length() > 0) argList.append(", "); + argList.append(a.name).append(": $").append(a.name); + } + + String opName = cap(methodName); + StringBuilder docB = new StringBuilder(); + docB.append(kind).append(' ').append(opName); + if (varDefs.length() > 0) docB.append('(').append(varDefs).append(')'); + docB.append(" { ").append(f.name); + if (argList.length() > 0) docB.append('(').append(argList).append(')'); + if (fieldSelection.length() > 0) docB.append(' ').append(fieldSelection); + docB.append(" }"); + + methods.add(buildMethod(kind, methodName, minify(docB.toString()), opName, params, dataClass)); + } + } + + /// Auto-expands an object type's selection set to `depth` levels, + /// emitting the matching `@Mapped` class and returning the + /// selection-set string `{ ... }`. Object fields are expanded + /// only while depth remains and their type is not already on the + /// current path (cycle guard); skipped object fields are logged. + private String autoSelect(String typeName, String className, int depth, Set visited) + throws IOException { + ObjectTypeDef o = schema.object(typeName); + List modelFields = new ArrayList(); + List tokens = new ArrayList(); + if (o != null) { + for (FieldDef f : o.fields) { + String baseName = f.type.baseName(); + boolean leaf = !(schema.isObject(baseName) && schema.object(baseName) != null); + if (leaf) { + if (schema.unions.containsKey(baseName)) { + continue; // unions need explicit fragments; skip in quick-start + } + tokens.add(f.name); + ModelField mf = new ModelField(); + mf.jsonName = f.name; + mf.javaName = javaName(f.name); + mf.javaType = wrapList(f.type, leafMappedJava(baseName)); + modelFields.add(mf); + } else { + if (depth <= 1 || visited.contains(baseName)) { + log.debug("cn1:generate-graphql: omitting field '" + f.name + + "' of type '" + baseName + "' (depth/cycle limit)"); + continue; + } + String nested = className + "_" + cap(f.name); + Set v2 = new LinkedHashSet(visited); + v2.add(baseName); + String childSel = autoSelect(baseName, nested, depth - 1, v2); + tokens.add(f.name + " " + childSel); + ModelField mf = new ModelField(); + mf.jsonName = f.name; + mf.javaName = javaName(f.name); + mf.javaType = wrapList(f.type, nested); + modelFields.add(mf); + } + } + } + if (tokens.isEmpty()) { + tokens.add("__typename"); + ModelField mf = new ModelField(); + mf.jsonName = "__typename"; + mf.javaName = "__typename".equals(javaName("__typename")) ? "typename" : javaName("__typename"); + mf.javaType = "String"; + modelFields.add(mf); + } + if (emitted.add(className)) { + emitMapped(className, modelFields); + } + StringBuilder sel = new StringBuilder("{ "); + for (int i = 0; i < tokens.size(); i++) { + if (i > 0) sel.append(' '); + sel.append(tokens.get(i)); + } + sel.append(" }"); + return sel.toString(); + } + + // -- variable / input typing --------------------------------- + + private String javaTypeForVar(TypeRef t) throws IOException { + if (t.list) { + return "java.util.List<" + javaTypeForVar(t.element) + ">"; + } + String base = t.name; + String sc = scalarJava(base); + if (sc != null) return sc; + if (schema.isEnum(base)) { + ensureEnumEmitted(base); + return base; + } + if (schema.isInput(base)) { + ensureInputEmitted(base); + return base; + } + // Object type used as a variable is invalid GraphQL; custom + // scalars and anything unknown fall back to String. + return "String"; + } + + private void ensureEnumEmitted(String name) throws IOException { + if (!emitted.add(name)) return; + EnumDef e = schema.enums.get(name); + if (e == null) return; + StringBuilder sb = new StringBuilder(512); + sb.append("// Generated by cn1:generate-graphql.\n"); + sb.append("package ").append(basePackage).append(";\n\n"); + sb.append("public enum ").append(name).append(" {\n"); + for (int i = 0; i < e.values.size(); i++) { + sb.append(" ").append(e.values.get(i)); + sb.append(i == e.values.size() - 1 ? "\n" : ",\n"); + } + sb.append("}\n"); + writeClass(name, sb.toString()); + } + + private void ensureInputEmitted(String name) throws IOException { + if (!emitted.add(name)) return; + ObjectTypeDef in = schema.inputs.get(name); + if (in == null) return; + List fields = new ArrayList(); + for (FieldDef f : in.fields) { + ModelField mf = new ModelField(); + mf.jsonName = f.name; + mf.javaName = javaName(f.name); + mf.javaType = javaTypeForInputField(f.type); + fields.add(mf); + } + emitMapped(name, fields); + } + + private String javaTypeForInputField(TypeRef t) throws IOException { + if (t.list) { + return "java.util.List<" + javaTypeForInputField(t.element) + ">"; + } + String base = t.name; + String sc = scalarJava(base); + if (sc != null) return sc; + if (schema.isEnum(base)) { + ensureEnumEmitted(base); + return base; + } + if (schema.isInput(base)) { + ensureInputEmitted(base); + return base; + } + // Custom scalars map to String in @Mapped fields. + return "String"; + } + + // -- method + interface emission ----------------------------- + + private String buildMethod(String kind, String methodName, String document, + String operationName, List params, String dataClass) { + boolean subscription = GraphQLOperationModel.OP_SUBSCRIPTION.equals(kind); + StringBuilder sb = new StringBuilder(512); + String anno = subscription ? "Subscription" + : (GraphQLOperationModel.OP_MUTATION.equals(kind) ? "Mutation" : "Query"); + sb.append(" @").append(anno).append("(\"").append(escapeJava(document)).append('"'); + if (operationName != null && operationName.length() > 0) { + sb.append(", operationName = \"").append(escapeJava(operationName)).append('"'); + } + sb.append(")\n"); + String ret = subscription ? "GraphQLSubscription" : "void"; + sb.append(" ").append(ret).append(' ').append(methodName).append('('); + for (Param p : params) { + sb.append("@Var(\"").append(escapeJava(p.varName)).append("\") ") + .append(p.javaType).append(' ').append(p.name).append(", "); + } + sb.append("@Header(\"Authorization\") String bearerToken, "); + if (subscription) { + sb.append("GraphQLSubscription.Handler<").append(dataClass).append("> handler);\n\n"); + } else { + sb.append("OnComplete> callback);\n\n"); + } + return sb.toString(); + } + + private void emitClientInterface() throws IOException { + File f = new File(pkgDir, clientName + ".java"); + if (f.exists() && !overwrite) { + log.debug("skip existing " + f); + return; + } + StringBuilder sb = new StringBuilder(2048); + sb.append("// Generated by cn1:generate-graphql.\n"); + sb.append("package ").append(basePackage).append(";\n\n"); + sb.append("import com.codename1.annotations.graphql.GraphQLClient;\n"); + sb.append("import com.codename1.annotations.graphql.Query;\n"); + sb.append("import com.codename1.annotations.graphql.Mutation;\n"); + sb.append("import com.codename1.annotations.graphql.Subscription;\n"); + sb.append("import com.codename1.annotations.graphql.Var;\n"); + sb.append("import com.codename1.annotations.rest.Header;\n"); + sb.append("import com.codename1.io.graphql.GraphQLClients;\n"); + sb.append("import com.codename1.io.graphql.GraphQLResponse;\n"); + sb.append("import com.codename1.io.graphql.GraphQLSubscription;\n"); + sb.append("import com.codename1.util.OnComplete;\n\n"); + sb.append("@GraphQLClient(\"").append(escapeJava(endpoint == null ? "" : endpoint)).append("\")\n"); + sb.append("public interface ").append(clientName).append(" {\n\n"); + for (String m : methods) { + sb.append(m); + } + sb.append(" static ").append(clientName).append(" of(String endpoint) {\n"); + sb.append(" return GraphQLClients.create(").append(clientName).append(".class, endpoint);\n"); + sb.append(" }\n"); + sb.append("}\n"); + writeFile(f, sb.toString()); + } + + // -- @Mapped model emission ---------------------------------- + + private void emitMapped(String className, List fields) throws IOException { + File f = new File(pkgDir, className + ".java"); + if (f.exists() && !overwrite) { + log.debug("skip existing " + f); + return; + } + boolean usesList = false; + for (ModelField mf : fields) { + if (mf.javaType.startsWith("java.util.List")) { usesList = true; break; } + } + StringBuilder sb = new StringBuilder(1024); + sb.append("// Generated by cn1:generate-graphql.\n"); + sb.append("package ").append(basePackage).append(";\n\n"); + sb.append("import com.codename1.annotations.JsonProperty;\n"); + sb.append("import com.codename1.annotations.Mapped;\n"); + if (usesList) sb.append("import java.util.List;\n"); + sb.append("\n@Mapped\n"); + if (emitRecords) { + sb.append("public record ").append(className).append("(\n"); + for (int i = 0; i < fields.size(); i++) { + ModelField mf = fields.get(i); + if (i > 0) sb.append(",\n"); + sb.append(" "); + if (!mf.javaName.equals(mf.jsonName)) { + sb.append("@JsonProperty(\"").append(escapeJava(mf.jsonName)).append("\") "); + } + sb.append(shortList(mf.javaType)).append(' ').append(mf.javaName); + } + sb.append("\n) {}\n"); + } else { + sb.append("public class ").append(className).append(" {\n"); + for (ModelField mf : fields) { + if (!mf.javaName.equals(mf.jsonName)) { + sb.append(" @JsonProperty(\"").append(escapeJava(mf.jsonName)).append("\")\n"); + } + sb.append(" public ").append(shortList(mf.javaType)).append(' ') + .append(mf.javaName).append(";\n"); + } + sb.append(" public ").append(className).append("() {}\n"); + sb.append("}\n"); + } + writeFile(f, sb.toString()); + } + + private void writeClass(String className, String content) throws IOException { + File f = new File(pkgDir, className + ".java"); + if (f.exists() && !overwrite) { + log.debug("skip existing " + f); + return; + } + writeFile(f, content); + } + + // -- type helpers -------------------------------------------- + + private String rootTypeFor(String kind) { + if (GraphQLOperationModel.OP_MUTATION.equals(kind)) return schema.mutationType; + if (GraphQLOperationModel.OP_SUBSCRIPTION.equals(kind)) return schema.subscriptionType; + return schema.queryType; + } + + private FieldDef findField(String typeName, String fieldName) { + ObjectTypeDef o = schema.object(typeName); + if (o == null) o = schema.inputs.get(typeName); + if (o == null) return null; + for (FieldDef f : o.fields) { + if (f.name.equals(fieldName)) return f; + } + return null; + } + + /// Boxed Java scalar for a GraphQL leaf scalar, or null when + /// `name` is not a built-in scalar. + private static String scalarJava(String name) { + if ("Int".equals(name)) return "Integer"; + if ("Float".equals(name)) return "Double"; + if ("Boolean".equals(name)) return "Boolean"; + if ("String".equals(name)) return "String"; + if ("ID".equals(name)) return "String"; + return null; + } + + /// Java type for a leaf field inside an `@Mapped` class. Built-in + /// scalars map to their boxed Java type; GraphQL enums map to a + /// generated Java enum (the JSON mapper binds enums via their + /// `name()`); custom scalars fall back to `String`. + private String leafMappedJava(String baseName) throws IOException { + String sc = scalarJava(baseName); + if (sc != null) return sc; + if (schema.isEnum(baseName)) { + ensureEnumEmitted(baseName); + return baseName; + } + return "String"; + } + + private String wrapList(TypeRef t, String elem) { + if (t.list) { + return "java.util.List<" + wrapList(t.element, elem) + ">"; + } + return elem; + } + + private static String typeRefToSdl(TypeRef t) { + if (t.list) { + return "[" + typeRefToSdl(t.element) + "]" + (t.nonNull ? "!" : ""); + } + return t.name + (t.nonNull ? "!" : ""); + } + + /// Rewrites `java.util.List<...>` to the short `List<...>` once + /// the file imports `java.util.List`. + private static String shortList(String javaType) { + return javaType.replace("java.util.List<", "List<"); + } + + // -- GraphQL document minifier ------------------------------- + + /// Collapses whitespace and strips `#` comments outside string + /// literals, producing a compact single-line operation document. + static String minify(String s) { + StringBuilder out = new StringBuilder(s.length()); + boolean lastSpace = true; // suppress leading space + int i = 0; + int n = s.length(); + while (i < n) { + char c = s.charAt(i); + if (c == '"') { + if (i + 3 <= n && s.charAt(i + 1) == '"' && s.charAt(i + 2) == '"') { + int end = s.indexOf("\"\"\"", i + 3); + int stop = end < 0 ? n : end + 3; + out.append(s, i, stop); + i = stop; + } else { + int j = i + 1; + while (j < n && s.charAt(j) != '"') { + if (s.charAt(j) == '\\') j++; + j++; + } + if (j < n) j++; + out.append(s, i, j); + i = j; + } + lastSpace = false; + continue; + } + if (c == '#') { + while (i < n && s.charAt(i) != '\n') i++; + continue; + } + if (Character.isWhitespace(c)) { + if (!lastSpace) { + out.append(' '); + lastSpace = true; + } + i++; + continue; + } + out.append(c); + lastSpace = false; + i++; + } + return out.toString().trim(); + } + + // -- name helpers (mirrors GenerateGrpcMojo) ----------------- + + private String unique(String base) { + if (methodNames.add(base)) return base; + int n = 2; + while (!methodNames.add(base + n)) n++; + return base + n; + } + + private static String lowerFirst(String s) { + if (s == null || s.length() == 0) return s; + return Character.toLowerCase(s.charAt(0)) + s.substring(1); + } + + private static String cap(String s) { + String j = javaName(s); + if (j.length() == 0) return j; + return Character.toUpperCase(j.charAt(0)) + j.substring(1); + } + + static String javaName(String name) { + if (name == null || name.length() == 0) return "field"; + StringBuilder sb = new StringBuilder(name.length()); + boolean upper = false; + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (c == '_') { + if (sb.length() > 0) upper = true; + continue; + } + if (!(Character.isLetterOrDigit(c))) { + continue; + } + sb.append(upper ? Character.toUpperCase(c) : c); + upper = false; + } + String candidate = sb.length() == 0 ? "field" : sb.toString(); + if (candidate.length() > 0 && Character.isDigit(candidate.charAt(0))) { + candidate = "_" + candidate; + } else if (candidate.length() > 0) { + candidate = Character.toLowerCase(candidate.charAt(0)) + candidate.substring(1); + } + if (isReservedWord(candidate)) candidate = candidate + "_"; + return candidate; + } + + private static String escapeJava(String s) { + if (s == null) return ""; + StringBuilder sb = new StringBuilder(s.length() + 4); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"': sb.append("\\\""); break; + case '\\': sb.append("\\\\"); break; + case '\n': sb.append("\\n"); break; + case '\r': sb.append("\\r"); break; + case '\t': sb.append("\\t"); break; + default: sb.append(c); + } + } + return sb.toString(); + } + + private static boolean isReservedWord(String s) { + return s.equals("abstract") || s.equals("assert") || s.equals("boolean") || s.equals("break") + || s.equals("byte") || s.equals("case") || s.equals("catch") || s.equals("char") + || s.equals("class") || s.equals("const") || s.equals("continue") || s.equals("default") + || s.equals("do") || s.equals("double") || s.equals("else") || s.equals("enum") + || s.equals("extends") || s.equals("final") || s.equals("finally") || s.equals("float") + || s.equals("for") || s.equals("goto") || s.equals("if") || s.equals("implements") + || s.equals("import") || s.equals("instanceof") || s.equals("int") || s.equals("interface") + || s.equals("long") || s.equals("native") || s.equals("new") || s.equals("null") + || s.equals("package") || s.equals("private") || s.equals("protected") || s.equals("public") + || s.equals("return") || s.equals("short") || s.equals("static") || s.equals("strictfp") + || s.equals("super") || s.equals("switch") || s.equals("synchronized") || s.equals("this") + || s.equals("throw") || s.equals("throws") || s.equals("transient") || s.equals("true") + || s.equals("false") || s.equals("try") || s.equals("void") || s.equals("volatile") + || s.equals("while") || s.equals("record"); + } + + private static void ensureDir(File f) throws IOException { + if (!f.exists() && !f.mkdirs()) { + throw new IOException("Could not create " + f); + } + } + + private static void writeFile(File f, String content) throws IOException { + FileOutputStream out = new FileOutputStream(f); + try { + out.write(content.getBytes(StandardCharsets.UTF_8)); + } finally { + out.close(); + } + } + } + + // ---------------------------------------------------------------- + // Small structs + // ---------------------------------------------------------------- + + static final class Param { + String varName; + String name; + String javaType; + } + + static final class ModelField { + String jsonName; + String javaName; + String javaType; + } + + static final class Resolved { + String responseKey; + String name; + String contextType; + final List subselections = new ArrayList(); + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GraphQLOperationModel.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GraphQLOperationModel.java new file mode 100644 index 0000000000..55f1dc7d21 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GraphQLOperationModel.java @@ -0,0 +1,433 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven; + +import com.codename1.maven.GraphQLSchemaModel.TypeRef; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/// GraphQL executable-document model (queries, mutations, +/// subscriptions, fragments) plus a hand-written parser. Used by +/// [GenerateGraphQLMojo]'s operations mode to synthesise precise typed +/// response models from the selection sets a client actually requests. +/// +/// The parser captures each definition's raw source slice so the +/// generator can emit the operation document verbatim (minified, with +/// referenced fragment definitions appended) into the `@Query` / +/// `@Mutation` / `@Subscription` annotation. +final class GraphQLOperationModel { + + static final String OP_QUERY = "query"; + static final String OP_MUTATION = "mutation"; + static final String OP_SUBSCRIPTION = "subscription"; + + /// One member of a selection set. + abstract static class Selection { + } + + static final class Field extends Selection { + String alias; // null when unaliased + String name; + final List selections = new ArrayList(); + + /// The response key this field appears under (alias if present). + String responseKey() { + return alias != null ? alias : name; + } + } + + static final class FragmentSpread extends Selection { + String fragmentName; + } + + static final class InlineFragment extends Selection { + String typeCondition; // null for `... { ... }` + final List selections = new ArrayList(); + } + + static final class VarDef { + String name; // without leading '$' + TypeRef type; + } + + static final class OperationDef { + String kind; // OP_QUERY / OP_MUTATION / OP_SUBSCRIPTION + String name; // null for anonymous operations + final List vars = new ArrayList(); + final List selections = new ArrayList(); + final Set directSpreads = new LinkedHashSet(); + String rawText; + } + + static final class FragmentDef { + String name; + String typeCondition; + final List selections = new ArrayList(); + final Set directSpreads = new LinkedHashSet(); + String rawText; + } + + static final class Document { + final List operations = new ArrayList(); + final Map fragments = new LinkedHashMap(); + } + + static final class ParseException extends RuntimeException { + ParseException(String msg) { + super(msg); + } + } + + private GraphQLOperationModel() { + } + + // ---------------------------------------------------------------- + // Parser + // ---------------------------------------------------------------- + + static final class Parser { + private final String src; + private final String file; + private int pos; + private int line = 1; + private Set currentSpreads; + + Parser(String src, String file) { + this.src = src; + this.file = file; + } + + Document parse() { + Document doc = new Document(); + while (true) { + skipIgnored(); + if (pos >= src.length()) break; + if (peekWord("fragment")) { + FragmentDef f = parseFragment(); + doc.fragments.put(f.name, f); + } else if (peekWord(OP_QUERY) || peekWord(OP_MUTATION) || peekWord(OP_SUBSCRIPTION)) { + doc.operations.add(parseOperation()); + } else if (peekChar('{')) { + doc.operations.add(parseAnonymousOperation()); + } else { + throw err("Unexpected token in operation document: '" + preview() + "'"); + } + } + return doc; + } + + private OperationDef parseOperation() { + int start = pos; + OperationDef op = new OperationDef(); + currentSpreads = op.directSpreads; + if (peekWord(OP_QUERY)) { consumeWord(OP_QUERY); op.kind = OP_QUERY; } + else if (peekWord(OP_MUTATION)) { consumeWord(OP_MUTATION); op.kind = OP_MUTATION; } + else { consumeWord(OP_SUBSCRIPTION); op.kind = OP_SUBSCRIPTION; } + skipIgnored(); + if (isNameStart(peekRaw())) { + op.name = readName(); + } + skipIgnored(); + if (peekChar('(')) { + parseVarDefs(op.vars); + } + skipDirectives(); + parseSelectionSet(op.selections); + op.rawText = src.substring(start, pos); + return op; + } + + private OperationDef parseAnonymousOperation() { + int start = pos; + OperationDef op = new OperationDef(); + op.kind = OP_QUERY; + currentSpreads = op.directSpreads; + parseSelectionSet(op.selections); + op.rawText = src.substring(start, pos); + return op; + } + + private FragmentDef parseFragment() { + int start = pos; + consumeWord("fragment"); + FragmentDef f = new FragmentDef(); + currentSpreads = f.directSpreads; + f.name = readName(); + consumeWord("on"); + f.typeCondition = readName(); + skipDirectives(); + parseSelectionSet(f.selections); + f.rawText = src.substring(start, pos); + return f; + } + + private void parseVarDefs(List out) { + expect('('); + while (true) { + skipIgnored(); + if (peekChar(')')) { pos++; break; } + expect('$'); + VarDef v = new VarDef(); + v.name = readName(); + expect(':'); + v.type = parseType(); + skipIgnored(); + if (peekChar('=')) { + pos++; + skipValue(); + } + skipDirectives(); + out.add(v); + } + } + + private TypeRef parseType() { + skipIgnored(); + TypeRef t = new TypeRef(); + if (peekChar('[')) { + pos++; + t.list = true; + t.element = parseType(); + skipIgnored(); + expect(']'); + } else { + t.name = readName(); + } + skipIgnored(); + if (peekChar('!')) { + pos++; + t.nonNull = true; + } + return t; + } + + private void parseSelectionSet(List out) { + expect('{'); + while (true) { + skipIgnored(); + if (peekChar('}')) { pos++; break; } + if (src.regionMatches(pos, "...", 0, 3)) { + pos += 3; + parseFragmentOrInline(out); + } else { + out.add(parseFieldSelection()); + } + } + } + + private void parseFragmentOrInline(List out) { + skipIgnored(); + if (peekWord("on")) { + consumeWord("on"); + InlineFragment inline = new InlineFragment(); + inline.typeCondition = readName(); + skipDirectives(); + parseSelectionSet(inline.selections); + out.add(inline); + } else if (peekChar('{')) { + InlineFragment inline = new InlineFragment(); + parseSelectionSet(inline.selections); + out.add(inline); + } else if (peekChar('@')) { + // `... @directive { ... }` inline fragment with no type + // condition. + skipDirectives(); + InlineFragment inline = new InlineFragment(); + parseSelectionSet(inline.selections); + out.add(inline); + } else { + FragmentSpread fs = new FragmentSpread(); + fs.fragmentName = readName(); + skipDirectives(); + if (currentSpreads != null) currentSpreads.add(fs.fragmentName); + out.add(fs); + } + } + + private Field parseFieldSelection() { + Field f = new Field(); + String first = readName(); + skipIgnored(); + if (peekChar(':')) { + pos++; + f.alias = first; + f.name = readName(); + } else { + f.name = first; + } + skipIgnored(); + if (peekChar('(')) { + skipBalanced('(', ')'); // arguments do not affect response typing + } + skipDirectives(); + skipIgnored(); + if (peekChar('{')) { + parseSelectionSet(f.selections); + } + return f; + } + + // -- skipping helpers ---------------------------------------- + + private void skipDirectives() { + while (true) { + skipIgnored(); + if (peekChar('@')) { + pos++; + readName(); + skipIgnored(); + if (peekChar('(')) { + skipBalanced('(', ')'); + } + } else { + break; + } + } + } + + private void skipValue() { + skipIgnored(); + if (pos >= src.length()) return; + char c = peekRaw(); + if (c == '[') { skipBalanced('[', ']'); return; } + if (c == '{') { skipBalanced('{', '}'); return; } + if (c == '"') { skipString(); return; } + if (c == '$') { pos++; readName(); return; } + while (pos < src.length()) { + char d = src.charAt(pos); + if (Character.isWhitespace(d) || d == ',' || d == ')' || d == ']' + || d == '}' || d == '@') break; + pos++; + } + } + + private void skipString() { + if (src.regionMatches(pos, "\"\"\"", 0, 3)) { + pos += 3; + int idx = src.indexOf("\"\"\"", pos); + pos = idx < 0 ? src.length() : idx + 3; + } else { + pos++; + while (pos < src.length() && src.charAt(pos) != '"') { + if (src.charAt(pos) == '\\') pos++; + pos++; + } + if (pos < src.length()) pos++; + } + } + + private void skipBalanced(char open, char close) { + skipIgnored(); + if (!peekChar(open)) return; + int depth = 0; + while (pos < src.length()) { + char c = src.charAt(pos); + if (c == '"') { skipString(); continue; } + if (c == open) depth++; + else if (c == close) { depth--; pos++; if (depth == 0) return; else continue; } + if (c == '\n') line++; + pos++; + } + } + + // -- token primitives ---------------------------------------- + + private boolean peekWord(String kw) { + skipIgnored(); + int n = kw.length(); + if (pos + n > src.length()) return false; + if (!src.regionMatches(pos, kw, 0, n)) return false; + if (pos + n < src.length() && isNamePart(src.charAt(pos + n))) return false; + return true; + } + + private void consumeWord(String kw) { + if (!peekWord(kw)) { + throw err("Expected '" + kw + "' but got '" + preview() + "'"); + } + pos += kw.length(); + } + + private boolean peekChar(char c) { + skipIgnored(); + return pos < src.length() && src.charAt(pos) == c; + } + + private char peekRaw() { + return pos < src.length() ? src.charAt(pos) : '\0'; + } + + private void expect(char c) { + skipIgnored(); + if (pos >= src.length() || src.charAt(pos) != c) { + throw err("Expected '" + c + "' but got '" + preview() + "'"); + } + pos++; + } + + private String readName() { + skipIgnored(); + int start = pos; + if (pos >= src.length() || !isNameStart(src.charAt(pos))) { + throw err("Expected name, got '" + preview() + "'"); + } + while (pos < src.length() && isNamePart(src.charAt(pos))) pos++; + return src.substring(start, pos); + } + + private void skipIgnored() { + while (pos < src.length()) { + char c = src.charAt(pos); + if (c == '\n') { line++; pos++; continue; } + if (Character.isWhitespace(c) || c == ',') { pos++; continue; } + if (c == '#') { + while (pos < src.length() && src.charAt(pos) != '\n') pos++; + continue; + } + return; + } + } + + private static boolean isNameStart(char c) { + return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + } + + private static boolean isNamePart(char c) { + return isNameStart(c) || (c >= '0' && c <= '9'); + } + + private String preview() { + if (pos >= src.length()) return ""; + return src.substring(pos, Math.min(pos + 16, src.length())); + } + + private ParseException err(String msg) { + return new ParseException(file + ":" + line + ": " + msg); + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GraphQLSchemaModel.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GraphQLSchemaModel.java new file mode 100644 index 0000000000..495effa7f6 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GraphQLSchemaModel.java @@ -0,0 +1,506 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// GraphQL SDL (Schema Definition Language) model plus a hand-written +/// recursive-descent parser. Mirrors the `ProtoParser` approach in +/// [GenerateGrpcMojo] -- no third-party dependency, tolerant of the +/// constructs `cn1:generate-graphql` does not need (descriptions, +/// directives) by skipping them. +/// +/// Supported: `type`, `input`, `interface`, `enum`, `union`, `scalar`, +/// and the root `schema { query/mutation/subscription }` block. +/// `extend`, custom `directive` definitions and descriptions are +/// skipped. +final class GraphQLSchemaModel { + + /// A reference to a type with its list / non-null wrappers, e.g. + /// `[Episode!]!`. Either `name` is set (a named type) or `list` is + /// true and `element` holds the inner reference. + static final class TypeRef { + boolean list; + boolean nonNull; + String name; // when !list + TypeRef element; // when list + + /// The innermost named type, unwrapping all list levels. + String baseName() { + TypeRef t = this; + while (t.list) { + t = t.element; + } + return t.name; + } + + boolean isList() { + return list; + } + } + + static final class ArgDef { + String name; + TypeRef type; + } + + static final class FieldDef { + String name; + TypeRef type; + final List args = new ArrayList(); + } + + /// An object, interface or input type. Input types reuse this with + /// `input == true` (their fields never have args). + static final class ObjectTypeDef { + String name; + boolean input; + final List fields = new ArrayList(); + } + + static final class EnumDef { + String name; + final List values = new ArrayList(); + } + + static final class Schema { + final Map objects = new LinkedHashMap(); + final Map inputs = new LinkedHashMap(); + final Map enums = new LinkedHashMap(); + final Map> unions = new LinkedHashMap>(); + final java.util.Set scalars = new java.util.LinkedHashSet(); + String queryType = "Query"; + String mutationType = "Mutation"; + String subscriptionType = "Subscription"; + + ObjectTypeDef object(String name) { + return objects.get(name); + } + + boolean isEnum(String name) { + return enums.containsKey(name); + } + + boolean isInput(String name) { + return inputs.containsKey(name); + } + + boolean isObject(String name) { + return objects.containsKey(name); + } + } + + static final class ParseException extends RuntimeException { + ParseException(String msg) { + super(msg); + } + } + + private GraphQLSchemaModel() { + } + + // ---------------------------------------------------------------- + // Parser + // ---------------------------------------------------------------- + + static final class Parser { + private final String src; + private final String file; + private int pos; + private int line = 1; + + Parser(String src, String file) { + this.src = src; + this.file = file; + } + + Schema parse() { + Schema schema = new Schema(); + while (true) { + skipIgnored(); + if (pos >= src.length()) break; + skipDescription(); + skipDirectivesAndKeywords(); + skipIgnored(); + if (pos >= src.length()) break; + if (peekWord("schema")) { + parseSchemaBlock(schema); + } else if (peekWord("type")) { + ObjectTypeDef o = parseObject("type"); + schema.objects.put(o.name, o); + } else if (peekWord("input")) { + ObjectTypeDef o = parseObject("input"); + o.input = true; + schema.inputs.put(o.name, o); + } else if (peekWord("interface")) { + ObjectTypeDef o = parseObject("interface"); + schema.objects.put(o.name, o); + } else if (peekWord("enum")) { + EnumDef e = parseEnum(); + schema.enums.put(e.name, e); + } else if (peekWord("union")) { + parseUnion(schema); + } else if (peekWord("scalar")) { + consumeWord("scalar"); + String n = readName(); + schema.scalars.add(n); + skipDirectives(); + } else if (peekWord("directive")) { + skipDirectiveDefinition(); + } else if (peekWord("extend")) { + consumeWord("extend"); + // Skip the extend keyword and re-loop to parse the + // following definition as if standalone (fields are + // merged into the map; good enough for codegen). + } else { + throw err("Unexpected token at top level: '" + preview() + "'"); + } + } + return schema; + } + + private void parseSchemaBlock(Schema schema) { + consumeWord("schema"); + skipDirectives(); + expect('{'); + while (true) { + skipIgnored(); + if (peekChar('}')) { pos++; break; } + String role = readName(); + expect(':'); + String typeName = readName(); + if ("query".equals(role)) schema.queryType = typeName; + else if ("mutation".equals(role)) schema.mutationType = typeName; + else if ("subscription".equals(role)) schema.subscriptionType = typeName; + } + } + + private ObjectTypeDef parseObject(String keyword) { + consumeWord(keyword); + ObjectTypeDef o = new ObjectTypeDef(); + o.name = readName(); + // Optional `implements A & B`. + skipIgnored(); + if (peekWord("implements")) { + consumeWord("implements"); + while (true) { + skipIgnored(); + if (peekChar('&')) { pos++; continue; } + if (peekChar('{') || peekChar('@')) break; + if (!isNameStart(peekRaw())) break; + readName(); // discard implemented interface name + } + } + skipDirectives(); + skipIgnored(); + if (!peekChar('{')) { + // Type with no field block (rare but legal). + return o; + } + expect('{'); + while (true) { + skipIgnored(); + if (peekChar('}')) { pos++; break; } + skipDescription(); + FieldDef f = parseField(); + o.fields.add(f); + } + return o; + } + + private FieldDef parseField() { + FieldDef f = new FieldDef(); + f.name = readName(); + skipIgnored(); + if (peekChar('(')) { + parseArgDefs(f.args); + } + expect(':'); + f.type = parseType(); + skipDirectives(); + return f; + } + + private void parseArgDefs(List out) { + expect('('); + while (true) { + skipIgnored(); + if (peekChar(')')) { pos++; break; } + skipDescription(); + ArgDef a = new ArgDef(); + a.name = readName(); + expect(':'); + a.type = parseType(); + // Optional default value. + skipIgnored(); + if (peekChar('=')) { + pos++; + skipValue(); + } + skipDirectives(); + skipIgnored(); + if (peekChar(',')) pos++; + out.add(a); + } + } + + private TypeRef parseType() { + skipIgnored(); + TypeRef t = new TypeRef(); + if (peekChar('[')) { + pos++; + t.list = true; + t.element = parseType(); + skipIgnored(); + expect(']'); + } else { + t.name = readName(); + } + skipIgnored(); + if (peekChar('!')) { + pos++; + t.nonNull = true; + } + return t; + } + + private EnumDef parseEnum() { + consumeWord("enum"); + EnumDef e = new EnumDef(); + e.name = readName(); + skipDirectives(); + expect('{'); + while (true) { + skipIgnored(); + if (peekChar('}')) { pos++; break; } + skipDescription(); + String v = readName(); + e.values.add(v); + skipDirectives(); + } + return e; + } + + private void parseUnion(Schema schema) { + consumeWord("union"); + String name = readName(); + skipDirectives(); + expect('='); + List members = new ArrayList(); + while (true) { + skipIgnored(); + if (peekChar('|')) { pos++; continue; } + if (!isNameStart(peekRaw())) break; + members.add(readName()); + skipIgnored(); + if (!peekChar('|')) break; + } + schema.unions.put(name, members); + } + + // -- skipping helpers ---------------------------------------- + + /// A no-op slot kept so the top-level loop reads cleanly; the + /// real skipping happens in [#skipIgnored()] and + /// [#skipDescription()]. + private void skipDirectivesAndKeywords() { + } + + private void skipDirectiveDefinition() { + consumeWord("directive"); + // `directive @name(args) on LOC | LOC` -- skip to the next + // top-level definition by scanning until a newline-led + // keyword. Simplest robust approach: consume through the + // `on` location list (identifiers, `|`, `@`, parens). + while (pos < src.length()) { + skipIgnored(); + if (pos >= src.length()) return; + char c = peekRaw(); + if (c == '@' ) { pos++; continue; } + if (c == '(') { skipBalanced('(', ')'); continue; } + if (c == '|') { pos++; continue; } + if (isNameStart(c)) { + String w = readName(); + if (isTopLevelKeyword(w)) { + // We overran into the next definition; rewind. + pos -= w.length(); + return; + } + continue; + } + return; + } + } + + private boolean isTopLevelKeyword(String w) { + return "type".equals(w) || "input".equals(w) || "interface".equals(w) + || "enum".equals(w) || "union".equals(w) || "scalar".equals(w) + || "schema".equals(w) || "directive".equals(w) || "extend".equals(w); + } + + private void skipDescription() { + skipIgnored(); + if (pos >= src.length()) return; + if (peekChar('"')) { + if (src.regionMatches(pos, "\"\"\"", 0, 3)) { + pos += 3; + int idx = src.indexOf("\"\"\"", pos); + pos = idx < 0 ? src.length() : idx + 3; + } else { + pos++; + while (pos < src.length() && src.charAt(pos) != '"') { + if (src.charAt(pos) == '\\') pos++; + pos++; + } + if (pos < src.length()) pos++; + } + } + } + + private void skipDirectives() { + while (true) { + skipIgnored(); + if (peekChar('@')) { + pos++; + readName(); + skipIgnored(); + if (peekChar('(')) { + skipBalanced('(', ')'); + } + } else { + break; + } + } + } + + /// Skips a GraphQL value (used for default values): scalars, + /// strings, enums, lists `[...]` and objects `{...}`. + private void skipValue() { + skipIgnored(); + if (pos >= src.length()) return; + char c = peekRaw(); + if (c == '[') { skipBalanced('[', ']'); return; } + if (c == '{') { skipBalanced('{', '}'); return; } + if (c == '"') { skipDescription(); return; } + // Scalar / enum / boolean / null token. + while (pos < src.length()) { + char d = src.charAt(pos); + if (Character.isWhitespace(d) || d == ',' || d == ')' || d == ']' + || d == '}' || d == '@') break; + pos++; + } + } + + private void skipBalanced(char open, char close) { + skipIgnored(); + if (!peekChar(open)) return; + int depth = 0; + while (pos < src.length()) { + char c = src.charAt(pos); + if (c == '"') { skipDescription(); continue; } + if (c == open) depth++; + else if (c == close) { depth--; pos++; if (depth == 0) return; else continue; } + if (c == '\n') line++; + pos++; + } + } + + // -- token primitives ---------------------------------------- + + private boolean peekWord(String kw) { + skipIgnored(); + int n = kw.length(); + if (pos + n > src.length()) return false; + if (!src.regionMatches(pos, kw, 0, n)) return false; + if (pos + n < src.length() && isNamePart(src.charAt(pos + n))) return false; + return true; + } + + private void consumeWord(String kw) { + if (!peekWord(kw)) { + throw err("Expected '" + kw + "' but got '" + preview() + "'"); + } + pos += kw.length(); + } + + private boolean peekChar(char c) { + skipIgnored(); + return pos < src.length() && src.charAt(pos) == c; + } + + private char peekRaw() { + return pos < src.length() ? src.charAt(pos) : '\0'; + } + + private void expect(char c) { + skipIgnored(); + if (pos >= src.length() || src.charAt(pos) != c) { + throw err("Expected '" + c + "' but got '" + preview() + "'"); + } + pos++; + } + + private String readName() { + skipIgnored(); + int start = pos; + if (pos >= src.length() || !isNameStart(src.charAt(pos))) { + throw err("Expected name, got '" + preview() + "'"); + } + while (pos < src.length() && isNamePart(src.charAt(pos))) pos++; + return src.substring(start, pos); + } + + private void skipIgnored() { + while (pos < src.length()) { + char c = src.charAt(pos); + if (c == '\n') { line++; pos++; continue; } + if (Character.isWhitespace(c) || c == ',') { pos++; continue; } + if (c == '#') { + while (pos < src.length() && src.charAt(pos) != '\n') pos++; + continue; + } + return; + } + } + + private static boolean isNameStart(char c) { + return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + } + + private static boolean isNamePart(char c) { + return isNameStart(c) || (c >= '0' && c <= '9'); + } + + private String preview() { + if (pos >= src.length()) return ""; + return src.substring(pos, Math.min(pos + 16, src.length())); + } + + private ParseException err(String msg) { + return new ParseException(file + ":" + line + ": " + msg); + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AnnotatedClass.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AnnotatedClass.java index c3503759da..f293a5edf9 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AnnotatedClass.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AnnotatedClass.java @@ -97,6 +97,9 @@ public final class AnnotatedClass { private static final int ACC_RECORD = 0x10000; + /// `true` when the class file's `ACC_ENUM` flag is set (a Java enum). + public boolean isEnum() { return (access & Opcodes.ACC_ENUM) != 0; } + /// Class-level annotations, keyed by JVM descriptor. public Map getClassAnnotations() { return annotations; } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/GraphQLClientAnnotationProcessor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/GraphQLClientAnnotationProcessor.java new file mode 100644 index 0000000000..d2a4bd49a9 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/GraphQLClientAnnotationProcessor.java @@ -0,0 +1,524 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven.processors; + +import com.codename1.maven.annotations.AbstractAnnotationProcessor; +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.AnnotationValues; +import com.codename1.maven.annotations.JavaSourceCompiler; +import com.codename1.maven.annotations.MethodInfo; +import com.codename1.maven.annotations.ProcessingException; +import com.codename1.maven.annotations.ProcessorContext; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +import org.objectweb.asm.Type; + +/// Build-time `@GraphQLClient` processor. Scans the project's compiled +/// classes for `@GraphQLClient`-annotated interfaces, validates them, +/// and emits: +/// +/// 1. One `Impl` per `@GraphQLClient` interface in the same +/// package. For each `@Query` / `@Mutation` method the impl builds a +/// variables map from the method's `@Var` parameters and chains +/// `com.codename1.io.graphql.GraphQL.execute(endpoint, bearer, +/// operationName, document, vars, ResponseData.class, callback)`. For +/// each `@Subscription` method it returns +/// `GraphQL.subscribe(...)`. +/// 2. A single `cn1app.GraphQLClientBootstrap` that registers every +/// accepted interface with `com.codename1.io.graphql.GraphQLClients`. +/// +/// Mirrors [GrpcClientAnnotationProcessor] in structure. +public final class GraphQLClientAnnotationProcessor extends AbstractAnnotationProcessor { + + public static final String GRAPHQL_CLIENT_DESC = "Lcom/codename1/annotations/graphql/GraphQLClient;"; + public static final String QUERY_DESC = "Lcom/codename1/annotations/graphql/Query;"; + public static final String MUTATION_DESC = "Lcom/codename1/annotations/graphql/Mutation;"; + public static final String SUBSCRIPTION_DESC = "Lcom/codename1/annotations/graphql/Subscription;"; + public static final String VAR_DESC = "Lcom/codename1/annotations/graphql/Var;"; + public static final String HEADER_DESC = "Lcom/codename1/annotations/rest/Header;"; + + static final String ONCOMPLETE_DESC = "Lcom/codename1/util/OnComplete;"; + static final String HANDLER_DESC = "Lcom/codename1/io/graphql/GraphQLSubscription$Handler;"; + + static final String BOOTSTRAP_BINARY = "cn1app.GraphQLClientBootstrap"; + static final String BOOTSTRAP_SIMPLE = "GraphQLClientBootstrap"; + static final String BOOTSTRAP_PACKAGE = "cn1app"; + + private static final Set DESCRIPTORS; + static { + Set s = new LinkedHashSet(); + s.add(GRAPHQL_CLIENT_DESC); + DESCRIPTORS = Collections.unmodifiableSet(s); + } + + private final TreeMap accepted = new TreeMap(); + + @Override + public Set getAnnotationDescriptors() { + return DESCRIPTORS; + } + + @Override + public void start(ProcessorContext ctx) throws ProcessingException { + accepted.clear(); + } + + @Override + public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws ProcessingException { + if (cls.isSynthetic()) return; + AnnotationValues clientAnno = cls.getClassAnnotation(GRAPHQL_CLIENT_DESC); + if (clientAnno == null) return; + if (!cls.isInterface()) { + ctx.error(cls, "@GraphQLClient requires an interface; " + + cls.getBinaryName() + " is not an interface"); + return; + } + if (!cls.isPublic()) { + ctx.error(cls, "@GraphQLClient interface " + cls.getBinaryName() + + " must be public"); + return; + } + + GraphQLApi api = new GraphQLApi(); + api.binaryName = cls.getBinaryName(); + api.simpleName = simpleName(api.binaryName); + api.packageName = packageOf(api.binaryName); + api.implSimpleName = api.simpleName + "Impl"; + api.implBinaryName = api.packageName.length() == 0 + ? api.implSimpleName + : api.packageName + "." + api.implSimpleName; + + boolean anyError = false; + for (MethodInfo m : cls.getMethods()) { + if (m.isStatic()) continue; + if (m.isSynthetic()) continue; + if (m.isConstructor()) continue; + if ((m.getAccess() & org.objectweb.asm.Opcodes.ACC_BRIDGE) != 0) continue; + if (!m.isAbstract()) continue; + + GraphQLMethod gm = parseMethod(cls, m, ctx); + if (gm == null) { + anyError = true; + continue; + } + api.methods.add(gm); + } + + if (!anyError) { + accepted.put(api.binaryName, api); + } + } + + /// Parses and validates one interface method. Returns null (and + /// reports the failure via `ctx.error`) when the method is not a + /// valid GraphQL operation. + private GraphQLMethod parseMethod(AnnotatedClass cls, MethodInfo m, ProcessorContext ctx) { + String label = cls.getBinaryName() + "." + m.getName(); + + AnnotationValues query = m.getAnnotation(QUERY_DESC); + AnnotationValues mutation = m.getAnnotation(MUTATION_DESC); + AnnotationValues subscription = m.getAnnotation(SUBSCRIPTION_DESC); + int opCount = (query != null ? 1 : 0) + (mutation != null ? 1 : 0) + (subscription != null ? 1 : 0); + if (opCount == 0) { + ctx.error(cls, "@GraphQLClient method " + label + + " must carry one of @Query, @Mutation or @Subscription"); + return null; + } + if (opCount > 1) { + ctx.error(cls, "@GraphQLClient method " + label + + " carries more than one of @Query / @Mutation / @Subscription"); + return null; + } + + GraphQLMethod gm = new GraphQLMethod(); + gm.name = m.getName(); + gm.descriptor = m.getDescriptor(); + gm.signature = m.getSignature(); + if (query != null) { + gm.kind = Kind.QUERY; + gm.document = query.getString("value"); + gm.operationName = query.getStringOrDefault("operationName", ""); + } else if (mutation != null) { + gm.kind = Kind.MUTATION; + gm.document = mutation.getString("value"); + gm.operationName = mutation.getStringOrDefault("operationName", ""); + } else { + gm.kind = Kind.SUBSCRIPTION; + gm.document = subscription.getString("value"); + gm.operationName = subscription.getStringOrDefault("operationName", ""); + } + if (gm.document == null || gm.document.length() == 0) { + ctx.error(cls, "@GraphQLClient method " + label + + " has an empty operation document"); + return null; + } + + Type[] paramTypes = Type.getArgumentTypes(gm.descriptor); + List> paramAnnotations = m.getParameterAnnotations(); + String[] genericSigs = RestClientAnnotationProcessor.parseGenericParameterSignatures( + gm.signature, paramTypes.length); + gm.paramTypes = paramTypes; + + for (int i = 0; i < paramTypes.length; i++) { + String descriptor = paramTypes[i].getDescriptor(); + Map pa = (paramAnnotations != null && i < paramAnnotations.size()) + ? paramAnnotations.get(i) : null; + AnnotationValues varAnno = pa == null ? null : pa.get(VAR_DESC); + AnnotationValues hdr = pa == null ? null : pa.get(HEADER_DESC); + + if (ONCOMPLETE_DESC.equals(descriptor)) { + if (gm.callbackIndex >= 0 || gm.handlerIndex >= 0) { + ctx.error(cls, "@GraphQLClient method " + label + + " declares more than one callback"); + return null; + } + gm.callbackIndex = i; + String payloadSig = genericSigs == null ? null : genericSigs[i]; + gm.responseBinaryName = RestClientAnnotationProcessor.extractResponsePayload(payloadSig); + continue; + } + if (HANDLER_DESC.equals(descriptor)) { + if (gm.callbackIndex >= 0 || gm.handlerIndex >= 0) { + ctx.error(cls, "@GraphQLClient method " + label + + " declares more than one callback"); + return null; + } + gm.handlerIndex = i; + String payloadSig = genericSigs == null ? null : genericSigs[i]; + gm.responseBinaryName = extractHandlerPayload(payloadSig); + continue; + } + if (hdr != null) { + String hv = hdr.getStringOrDefault("value", ""); + if ("Authorization".equalsIgnoreCase(hv)) { + gm.bearerIndex = i; + continue; + } + ctx.error(cls, "@GraphQLClient method " + label + + " carries @Header(\"" + hv + "\") -- only @Header(\"Authorization\") " + + "is honoured for GraphQL clients in this release"); + return null; + } + if (varAnno != null) { + GraphQLVar v = new GraphQLVar(); + v.paramIndex = i; + v.name = varAnno.getStringOrDefault("value", ""); + v.primitive = paramTypes[i].getSort() != Type.OBJECT && paramTypes[i].getSort() != Type.ARRAY; + if (v.name.length() == 0) { + ctx.error(cls, "@GraphQLClient method " + label + + " has an @Var with an empty variable name at position " + i); + return null; + } + gm.vars.add(v); + continue; + } + ctx.error(cls, "@GraphQLClient method " + label + + " has an unrecognised parameter at position " + i + + " (descriptor " + descriptor + "); expected @Var-annotated variables, " + + "an optional @Header(\"Authorization\") String, and a trailing callback"); + return null; + } + + if (gm.kind == Kind.SUBSCRIPTION) { + if (gm.handlerIndex < 0) { + ctx.error(cls, "@Subscription method " + label + + " must end with a GraphQLSubscription.Handler parameter"); + return null; + } + Type ret = Type.getReturnType(gm.descriptor); + if (!"com.codename1.io.graphql.GraphQLSubscription".equals( + ret.getClassName())) { + ctx.error(cls, "@Subscription method " + label + + " must return com.codename1.io.graphql.GraphQLSubscription"); + return null; + } + } else { + if (gm.callbackIndex < 0) { + ctx.error(cls, "@" + (gm.kind == Kind.QUERY ? "Query" : "Mutation") + " method " + label + + " must end with an OnComplete> callback"); + return null; + } + } + return gm; + } + + @Override + public void finish(ProcessorContext ctx) throws ProcessingException { + if (ctx.hasErrors()) return; + if (accepted.isEmpty()) return; + + Map sources = new LinkedHashMap(); + for (GraphQLApi api : accepted.values()) { + sources.put(api.implBinaryName, generateImplSource(api)); + } + sources.put(BOOTSTRAP_BINARY, generateBootstrapSource(accepted.values())); + + try { + List cp = new ArrayList(); + cp.add(ctx.getOutputClassDir()); + JavaSourceCompiler.compile(sources, ctx.getOutputClassDir(), cp); + } catch (IOException ioe) { + throw new ProcessingException("Could not compile generated @GraphQLClient sources: " + + ioe.getMessage(), ioe); + } + ctx.getLog().info("cn1: generated " + accepted.size() + + " @GraphQLClient impl(s) + " + BOOTSTRAP_BINARY); + } + + // ---------------------------------------------------------------- + // Source generation + // ---------------------------------------------------------------- + + private static String generateImplSource(GraphQLApi api) { + StringBuilder sb = new StringBuilder(2048); + if (api.packageName.length() > 0) { + sb.append("package ").append(api.packageName).append(";\n\n"); + } + sb.append("// Auto-generated by cn1:process-annotations. Do not edit.\n"); + sb.append("@SuppressWarnings({\"all\"})\n"); + sb.append("public final class ").append(api.implSimpleName) + .append(" implements ").append(api.binaryName).append(" {\n\n"); + sb.append(" private final String endpoint;\n\n"); + sb.append(" public ").append(api.implSimpleName).append("(String endpoint) {\n"); + sb.append(" this.endpoint = endpoint;\n"); + sb.append(" }\n\n"); + for (GraphQLMethod gm : api.methods) { + emitMethod(sb, gm); + } + sb.append("}\n"); + return sb.toString(); + } + + private static void emitMethod(StringBuilder sb, GraphQLMethod gm) { + String returnType = gm.kind == Kind.SUBSCRIPTION + ? "com.codename1.io.graphql.GraphQLSubscription" : "void"; + sb.append(" public ").append(returnType).append(' ').append(gm.name).append("("); + for (int i = 0; i < gm.paramTypes.length; i++) { + if (i > 0) sb.append(", "); + sb.append(paramJavaType(gm, i)).append(" p").append(i); + } + sb.append(") {\n"); + + sb.append(" java.util.Map _vars = new java.util.LinkedHashMap();\n"); + for (GraphQLVar v : gm.vars) { + String arg = "p" + v.paramIndex; + if (v.primitive) { + sb.append(" _vars.put(\"").append(escape(v.name)).append("\", ") + .append(arg).append(");\n"); + } else { + sb.append(" if (").append(arg).append(" != null) _vars.put(\"") + .append(escape(v.name)).append("\", ").append(arg).append(");\n"); + } + } + + String bearerExpr = gm.bearerIndex < 0 ? "null" : "p" + gm.bearerIndex; + String opNameExpr = (gm.operationName == null || gm.operationName.length() == 0) + ? "null" : "\"" + escape(gm.operationName) + "\""; + String docExpr = "\"" + escape(gm.document) + "\""; + String dataClass = gm.responseBinaryName + ".class"; + + if (gm.kind == Kind.SUBSCRIPTION) { + String handlerExpr = "p" + gm.handlerIndex; + sb.append(" return com.codename1.io.graphql.GraphQL.subscribe(\n") + .append(" endpoint, ").append(bearerExpr).append(", ") + .append(opNameExpr).append(",\n") + .append(" ").append(docExpr).append(",\n") + .append(" _vars, ").append(dataClass).append(", ") + .append(handlerExpr).append(");\n"); + } else { + String callbackExpr = "p" + gm.callbackIndex; + sb.append(" com.codename1.io.graphql.GraphQL.execute(\n") + .append(" endpoint, ").append(bearerExpr).append(", ") + .append(opNameExpr).append(",\n") + .append(" ").append(docExpr).append(",\n") + .append(" _vars, ").append(dataClass).append(", ") + .append(callbackExpr).append(");\n"); + } + sb.append(" }\n\n"); + } + + /// Returns the Java type literal for the impl-method parameter at + /// position `i`. The callback / handler re-uses the response payload + /// type so the impl method's signature stays parameterized the way + /// the user declared it; everything else uses the erased descriptor + /// type. + private static String paramJavaType(GraphQLMethod gm, int i) { + if (i == gm.callbackIndex) { + return "com.codename1.util.OnComplete>"; + } + if (i == gm.handlerIndex) { + return "com.codename1.io.graphql.GraphQLSubscription.Handler<" + + gm.responseBinaryName + ">"; + } + String descriptor = gm.paramTypes[i].getDescriptor(); + if (descriptor.startsWith("L") && descriptor.endsWith(";")) { + return descriptor.substring(1, descriptor.length() - 1).replace('/', '.'); + } + if (descriptor.startsWith("[")) { + return gm.paramTypes[i].getClassName(); // e.g. java.lang.String[] + } + return primitiveJava(descriptor); + } + + private static String primitiveJava(String descriptor) { + if ("I".equals(descriptor)) return "int"; + if ("J".equals(descriptor)) return "long"; + if ("F".equals(descriptor)) return "float"; + if ("D".equals(descriptor)) return "double"; + if ("Z".equals(descriptor)) return "boolean"; + if ("B".equals(descriptor)) return "byte"; + if ("S".equals(descriptor)) return "short"; + if ("C".equals(descriptor)) return "char"; + return "java.lang.Object"; + } + + private static String generateBootstrapSource(Iterable apis) { + StringBuilder sb = new StringBuilder(1024); + sb.append("package ").append(BOOTSTRAP_PACKAGE).append(";\n\n"); + sb.append("// Auto-generated by cn1:process-annotations. Do not edit.\n"); + sb.append("///\n"); + sb.append("/// GraphQL-client bootstrap. The iOS / Android per-build application\n"); + sb.append("/// stub instantiates this class before Display.init (the build\n"); + sb.append("/// server probes the project zip for it and emits the install\n"); + sb.append("/// line conditionally); JavaSEPort picks it up via Class.forName\n"); + sb.append("/// for the simulator and desktop runs.\n"); + sb.append("@SuppressWarnings({\"all\"})\n"); + sb.append("public final class ").append(BOOTSTRAP_SIMPLE).append(" {\n"); + sb.append(" public ").append(BOOTSTRAP_SIMPLE).append("() {\n"); + for (GraphQLApi api : apis) { + sb.append(" com.codename1.io.graphql.GraphQLClients.register(") + .append(api.binaryName) + .append(".class, new com.codename1.io.graphql.GraphQLClients.Factory<") + .append(api.binaryName).append(">() {\n"); + sb.append(" public ").append(api.binaryName).append(" create(String endpoint) {\n"); + sb.append(" return new ").append(api.implBinaryName).append("(endpoint);\n"); + sb.append(" }\n"); + sb.append(" });\n"); + } + sb.append(" }\n"); + sb.append("}\n"); + return sb.toString(); + } + + // ---------------------------------------------------------------- + // Signature helpers + // ---------------------------------------------------------------- + + /// Extracts the payload type `T` from a + /// `GraphQLSubscription.Handler` parameter's generic signature + /// `Lcom/codename1/io/graphql/GraphQLSubscription$Handler;`. + /// Returns `java.lang.Object` when the signature is missing. + static String extractHandlerPayload(String paramSignature) { + if (paramSignature == null) return "java.lang.Object"; + int lt = paramSignature.indexOf('<'); + int gt = paramSignature.lastIndexOf('>'); + if (lt < 0 || gt < 0 || lt > gt) return "java.lang.Object"; + String inner = paramSignature.substring(lt + 1, gt); + if (inner.startsWith("L") && inner.endsWith(";")) { + // Strip any nested generics on the payload itself. + int payloadGt = inner.indexOf('<'); + if (payloadGt >= 0) { + return inner.substring(1, payloadGt).replace('/', '.'); + } + return inner.substring(1, inner.length() - 1).replace('/', '.'); + } + return "java.lang.Object"; + } + + // ---------------------------------------------------------------- + // Misc + // ---------------------------------------------------------------- + + private static String packageOf(String binary) { + int dot = binary.lastIndexOf('.'); + return dot < 0 ? "" : binary.substring(0, dot); + } + + private static String simpleName(String binary) { + int dot = binary.lastIndexOf('.'); + return dot < 0 ? binary : binary.substring(dot + 1); + } + + private static String escape(String s) { + if (s == null) return ""; + StringBuilder b = new StringBuilder(s.length() + 4); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"': b.append("\\\""); break; + case '\\': b.append("\\\\"); break; + case '\n': b.append("\\n"); break; + case '\r': b.append("\\r"); break; + case '\t': b.append("\\t"); break; + default: b.append(c); + } + } + return b.toString(); + } + + // ---------------------------------------------------------------- + // Accumulators + // ---------------------------------------------------------------- + + enum Kind { QUERY, MUTATION, SUBSCRIPTION } + + static final class GraphQLApi { + String binaryName; + String packageName; + String simpleName; + String implBinaryName; + String implSimpleName; + final List methods = new ArrayList(); + } + + static final class GraphQLMethod { + String name; + String descriptor; + String signature; + Kind kind; + String document; + String operationName; + int bearerIndex = -1; + int callbackIndex = -1; + int handlerIndex = -1; + String responseBinaryName = "java.lang.Object"; + final List vars = new ArrayList(); + Type[] paramTypes; + } + + static final class GraphQLVar { + int paramIndex; + String name; + boolean primitive; + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java index df0030a852..7ab7014b99 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java @@ -166,6 +166,17 @@ public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws Proces MappedField mf = new MappedField(); mf.name = f.getName(); mf.kind = PropertyTypeKind.of(f); + // The base classifier never reflects, so an enum-typed field + // arrives here as REFERENCE (and a List as a LIST whose + // element is unrecognised). Upgrade them using the compiled + // class index so enums serialise as their name(). + if (mf.kind.kind == PropertyTypeKind.Kind.REFERENCE + && isEnumType(mf.kind.binaryName, ctx)) { + mf.kind = PropertyTypeKind.enumType(mf.kind.binaryName); + } else if (mf.kind.kind == PropertyTypeKind.Kind.LIST + && isEnumType(mf.kind.elementBinaryName, ctx)) { + mf.elementIsEnum = true; + } mf.useAccessor = useAccessor; mf.getterName = getterName; mf.setterName = setterName; @@ -311,6 +322,7 @@ private static String fieldType(MappedField f) { case REFERENCE: case PROPERTY: case LIST_PROPERTY: + case ENUM: return f.kind.binaryName; case LIST: return "java.util.List<" + f.kind.elementBinaryName + ">"; default: @@ -511,6 +523,10 @@ private static void emitFieldToMap(StringBuilder sb, MappedField f, boolean isRe sb.append(" m.put(").append(key).append(", ").append(read) .append(" == null ? null : com.codename1.util.Base64.encode(").append(read).append("));\n"); return; + case ENUM: + sb.append(" m.put(").append(key).append(", ").append(read) + .append(" == null ? null : ").append(read).append(".name());\n"); + return; case PROPERTY: sb.append(" m.put(").append(key).append(", ").append(read).append(".get());\n"); return; @@ -524,7 +540,10 @@ private static void emitFieldToMap(StringBuilder sb, MappedField f, boolean isRe } sb.append(" if (_src != null) {\n"); sb.append(" for (Object _e : _src) {\n"); - if (isScalarBinary(f.kind.elementBinaryName)) { + if (f.elementIsEnum) { + sb.append(" _l.add(_e == null ? null : ((") + .append(f.kind.elementBinaryName).append(") _e).name());\n"); + } else if (isScalarBinary(f.kind.elementBinaryName)) { sb.append(" _l.add(_e);\n"); } else if ("java.util.Date".equals(f.kind.elementBinaryName)) { sb.append(" _l.add(_e == null ? null : Long.valueOf(((java.util.Date) _e).getTime()));\n"); @@ -598,6 +617,16 @@ private static void emitFieldFromMap(StringBuilder sb, MappedField f, boolean is case PROPERTY: emitPropertySetFromJsonValue(sb, f, isRecord); break; + case ENUM: { + String bin = f.kind.binaryName; + sb.append(" if (_v instanceof ").append(bin).append(") {\n"); + sb.append(" ").append(writeStmt(f, isRecord, "(" + bin + ") _v")).append("\n"); + sb.append(" } else {\n"); + sb.append(" try { ").append(writeStmt(f, isRecord, bin + ".valueOf(_v.toString())")).append(" }\n"); + sb.append(" catch (IllegalArgumentException _ex) { ").append(writeStmt(f, isRecord, "null")).append(" }\n"); + sb.append(" }\n"); + break; + } case REFERENCE: sb.append(" com.codename1.mapping.Mapper _nm = com.codename1.mapping.Mappers.get(").append(f.kind.binaryName).append(".class);\n"); sb.append(" if (_nm != null && _v instanceof java.util.Map) {\n"); @@ -610,7 +639,17 @@ private static void emitFieldFromMap(StringBuilder sb, MappedField f, boolean is // a record's LIST we materialize a local _l and assign it to // the per-component local that later feeds the canonical ctor. sb.append(" if (_v instanceof java.util.List) {\n"); - if (isScalarBinary(f.kind.elementBinaryName)) { + if (f.elementIsEnum) { + String elem = f.kind.elementBinaryName; + if (f.kind.kind == PropertyTypeKind.Kind.LIST) { + sb.append(" java.util.ArrayList<").append(elem).append("> _l = new java.util.ArrayList<").append(elem).append(">();\n"); + emitEnumListLoop(sb, elem, "_l"); + sb.append(" ").append(writeStmt(f, isRecord, "_l")).append("\n"); + } else { + sb.append(" ").append(readExpr(f, isRecord)).append(".clear();\n"); + emitEnumListLoop(sb, elem, readExpr(f, isRecord)); + } + } else if (isScalarBinary(f.kind.elementBinaryName)) { if (f.kind.kind == PropertyTypeKind.Kind.LIST) { sb.append(" java.util.ArrayList<").append(f.kind.elementBinaryName).append("> _l = new java.util.ArrayList<").append(f.kind.elementBinaryName).append(">();\n"); sb.append(" for (Object _e : (java.util.List) _v) { _l.add((").append(f.kind.elementBinaryName).append(") _e); }\n"); @@ -652,6 +691,18 @@ private static void emitFieldFromMap(StringBuilder sb, MappedField f, boolean is sb.append(" }\n"); } + /// Emits a loop that converts a `List` of JSON values into enum + /// constants (`name()` strings, or pass-through enum instances), + /// adding each to `sink`. Unknown names decode to null. + private static void emitEnumListLoop(StringBuilder sb, String elem, String sink) { + sb.append(" for (Object _e : (java.util.List) _v) {\n"); + sb.append(" ").append(elem).append(" _ev = null;\n"); + sb.append(" if (_e instanceof ").append(elem).append(") { _ev = (").append(elem).append(") _e; }\n"); + sb.append(" else if (_e != null) { try { _ev = ").append(elem).append(".valueOf(_e.toString()); } catch (IllegalArgumentException _ex) { _ev = null; } }\n"); + sb.append(" ").append(sink).append(".add(_ev);\n"); + sb.append(" }\n"); + } + private static void emitPropertySetFromJsonValue(StringBuilder sb, MappedField f, boolean isRecord) { // PROPERTY fields are rejected upstream for records; read through // `readExpr` anyway so the helper is safe if it ever runs in the @@ -699,6 +750,7 @@ private static void emitFieldToXml(StringBuilder sb, MappedField f, boolean isRe switch (f.kind.kind) { case STRING: case INT: case LONG: case SHORT: case BYTE: case CHAR: case DOUBLE: case FLOAT: case BOOLEAN: case DATE: case BYTE_ARRAY: + case ENUM: case PROPERTY: sb.append(" {\n"); sb.append(" String _s = "); @@ -732,7 +784,12 @@ private static void emitFieldToXml(StringBuilder sb, MappedField f, boolean isRe sb.append(" if (_src != null) {\n"); sb.append(" for (Object _e : _src) {\n"); sb.append(" com.codename1.xml.Element _el = new com.codename1.xml.Element(\"").append(escape(f.xmlName)).append("\");\n"); - if (isScalarBinary(f.kind.elementBinaryName) || "java.util.Date".equals(f.kind.elementBinaryName)) { + if (f.elementIsEnum) { + sb.append(" if (_e != null) {\n"); + sb.append(" _el.addChild(new com.codename1.xml.Element(((") + .append(f.kind.elementBinaryName).append(") _e).name(), true));\n"); + sb.append(" }\n"); + } else if (isScalarBinary(f.kind.elementBinaryName) || "java.util.Date".equals(f.kind.elementBinaryName)) { sb.append(" if (_e != null) {\n"); if ("java.util.Date".equals(f.kind.elementBinaryName)) { sb.append(" _el.addChild(new com.codename1.xml.Element(String.valueOf(((java.util.Date) _e).getTime()), true));\n"); @@ -805,7 +862,12 @@ private static void emitFieldFromXml(StringBuilder sb, MappedField f, boolean is private static void emitListElementFromXml(StringBuilder sb, MappedField f, String elemVar, String sink) { String elem = f.kind.elementBinaryName; - if (isScalarBinary(elem)) { + if (f.elementIsEnum) { + sb.append(" String _s = textOf(").append(elemVar).append(");\n"); + sb.append(" if (_s != null) { try { ").append(sink).append(".add(") + .append(elem).append(".valueOf(_s)); } catch (IllegalArgumentException _ex) { ") + .append(sink).append(".add(null); } }\n"); + } else if (isScalarBinary(elem)) { sb.append(" String _s = textOf(").append(elemVar).append(");\n"); sb.append(" if (_s != null) {\n"); if ("java.lang.String".equals(elem)) { @@ -841,6 +903,8 @@ private static void emitScalarToString(StringBuilder sb, MappedField f, boolean case INT: case LONG: case SHORT: case BYTE: case CHAR: case DOUBLE: case FLOAT: case BOOLEAN: sb.append("String.valueOf(").append(read).append(")"); break; + case ENUM: + sb.append(read).append(" == null ? null : ").append(read).append(".name()"); break; case DATE: sb.append(read).append(" == null ? null : String.valueOf(").append(read).append(".getTime())"); break; case BYTE_ARRAY: @@ -886,6 +950,12 @@ private static void emitScalarFromString(StringBuilder sb, MappedField f, String sb.append(" ").append(writeStmt(f, isRecord, "new java.util.Date(Long.parseLong(" + src + "))")).append("\n"); break; case BYTE_ARRAY: sb.append(" ").append(writeStmt(f, isRecord, "com.codename1.util.Base64.decode(" + src + ".getBytes())")).append("\n"); break; + case ENUM: { + String bin = f.kind.binaryName; + sb.append(" try { ").append(writeStmt(f, isRecord, bin + ".valueOf(" + src + ")")).append(" }\n"); + sb.append(" catch (IllegalArgumentException _ex) { ").append(writeStmt(f, isRecord, "null")).append(" }\n"); + break; + } case PROPERTY: { String elem = f.kind.elementBinaryName; if ("java.lang.String".equals(elem)) { @@ -928,6 +998,16 @@ private static boolean isScalarBinary(String binary) { || "java.lang.Boolean".equals(binary); } + /// True when `binaryName` names a Java enum present in the compiled + /// class index. Returns false for enums outside the annotation scan + /// (e.g. library enums) -- those stay REFERENCE and behave as before. + private static boolean isEnumType(String binaryName, ProcessorContext ctx) { + if (binaryName == null || binaryName.length() == 0) return false; + com.codename1.maven.annotations.AnnotatedClass c = + ctx.lookup(binaryName.replace('.', '/')); + return c != null && c.isEnum(); + } + private static boolean canBeAttribute(PropertyTypeKind k) { if (k.isScalar()) return true; if (k.kind == PropertyTypeKind.Kind.PROPERTY) { @@ -1091,5 +1171,8 @@ static final class MappedField { /// mutate through their own `.set(...)` / `.add(...)` API; the /// field reference itself is not replaced). String setterName; + /// `true` when this field is a `List` whose element type `E` + /// is a Java enum -- elements serialise as their `name()`. + boolean elementIsEnum; } } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/PropertyTypeKind.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/PropertyTypeKind.java index 70f42de71c..0701bbeea1 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/PropertyTypeKind.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/PropertyTypeKind.java @@ -62,6 +62,15 @@ private PropertyTypeKind(Kind kind, String binaryName, String elementBinaryName) this.elementBinaryName = elementBinaryName; } + /// Reclassifies a reference type as [Kind#ENUM]. The base classifier + /// in [#of(FieldInfo)] never reflects, so it cannot tell an enum from + /// any other reference type; the processor that owns the compiled + /// class index calls this once it confirms the referenced class + /// carries the `ACC_ENUM` flag. + public static PropertyTypeKind enumType(String binaryName) { + return new PropertyTypeKind(Kind.ENUM, binaryName, null); + } + public static PropertyTypeKind of(FieldInfo field) { String desc = field.getDescriptor(); if (desc == null || desc.length() == 0) { diff --git a/maven/codenameone-maven-plugin/src/main/resources/META-INF/services/com.codename1.maven.annotations.AnnotationProcessor b/maven/codenameone-maven-plugin/src/main/resources/META-INF/services/com.codename1.maven.annotations.AnnotationProcessor index 212927fabb..278323fafa 100644 --- a/maven/codenameone-maven-plugin/src/main/resources/META-INF/services/com.codename1.maven.annotations.AnnotationProcessor +++ b/maven/codenameone-maven-plugin/src/main/resources/META-INF/services/com.codename1.maven.annotations.AnnotationProcessor @@ -5,3 +5,4 @@ com.codename1.maven.processors.OrmAnnotationProcessor com.codename1.maven.processors.RestClientAnnotationProcessor com.codename1.maven.processors.ProtoMessageAnnotationProcessor com.codename1.maven.processors.GrpcClientAnnotationProcessor +com.codename1.maven.processors.GraphQLClientAnnotationProcessor diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/GenerateGraphQLMojoTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/GenerateGraphQLMojoTest.java new file mode 100644 index 0000000000..d8972806a0 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/GenerateGraphQLMojoTest.java @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + */ +package com.codename1.maven; + +import com.codename1.maven.GraphQLOperationModel.Document; +import com.codename1.maven.GraphQLSchemaModel.Schema; + +import org.apache.maven.plugin.logging.SystemStreamLog; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Drives the GraphQL SDL + operation parsers and generator against an +/// inline Star Wars fixture, verifying both the precise operations mode +/// and the schema-only quick-start mode. +class GenerateGraphQLMojoTest { + + private static final String SCHEMA = + "\"\"\" the root \"\"\"\n" + + "schema { query: Query mutation: Mutation subscription: Subscription }\n" + + "type Query {\n" + + " hero(episode: Episode): Character\n" + + " review(episode: Episode!): Review\n" + + " version: String\n" + + "}\n" + + "type Mutation { createReview(episode: Episode!, review: ReviewInput!): Review }\n" + + "type Subscription { reviewAdded(episode: Episode!): Review }\n" + + "type Character {\n" + + " id: ID!\n" + + " name: String!\n" + + " friends: [Character]\n" + + " appearsIn: [Episode!]!\n" + + "}\n" + + "type Review { stars: Int! commentary: String }\n" + + "input ReviewInput { stars: Int! commentary: String favoriteColor: ColorInput }\n" + + "input ColorInput { red: Int! green: Int! blue: Int! }\n" + + "enum Episode { NEWHOPE EMPIRE JEDI }\n"; + + private static final String OPERATIONS = + "query HeroName($episode: Episode) {\n" + + " hero(episode: $episode) { ...HeroFields friends { name } }\n" + + "}\n" + + "fragment HeroFields on Character { name }\n" + + "mutation AddReview($ep: Episode!, $review: ReviewInput!) {\n" + + " createReview(episode: $ep, review: $review) { stars commentary }\n" + + "}\n" + + "subscription OnReview($ep: Episode!) {\n" + + " reviewAdded(episode: $ep) { stars }\n" + + "}\n"; + + private Schema schema() { + return new GraphQLSchemaModel.Parser(SCHEMA, "schema.graphqls").parse(); + } + + private Document ops() { + return new GraphQLOperationModel.Parser(OPERATIONS, "ops.graphql").parse(); + } + + @Test + void operationsModeEmitsRecordsAndInterface(@TempDir Path tmp) throws Exception { + File out = tmp.toFile(); + new GenerateGraphQLMojo.Generator(schema(), ops(), "com.example.sw", out, true, + /*emitRecords*/ true, "https://api/graphql", "StarWarsApi", 2, + new SystemStreamLog()).run(); + + String hero = read(out, "com/example/sw/HeroNameData.java"); + assertTrue(hero.contains("public record HeroNameData("), "data root record; was:\n" + hero); + assertTrue(hero.contains("HeroNameData_Hero hero"), "nested object field; was:\n" + hero); + + String heroHero = read(out, "com/example/sw/HeroNameData_Hero.java"); + assertTrue(heroHero.contains("String name"), "fragment field flattened in; was:\n" + heroHero); + assertTrue(heroHero.contains("List friends"), + "list of nested objects; was:\n" + heroHero); + assertTrue(heroHero.contains("import java.util.List;"), "List import present"); + + String review = read(out, "com/example/sw/ReviewInput.java"); + assertTrue(review.contains("@Mapped"), "input is @Mapped; was:\n" + review); + assertTrue(review.contains("Integer stars"), "Int! -> Integer; was:\n" + review); + assertTrue(review.contains("ColorInput favoriteColor"), "nested input class; was:\n" + review); + assertTrue(new File(out, "com/example/sw/ColorInput.java").exists(), + "nested input ColorInput emitted"); + + String episode = read(out, "com/example/sw/Episode.java"); + assertTrue(episode.contains("public enum Episode"), "enum emitted; was:\n" + episode); + assertTrue(episode.contains("NEWHOPE"), "enum values; was:\n" + episode); + + String api = read(out, "com/example/sw/StarWarsApi.java"); + assertTrue(api.contains("@GraphQLClient(\"https://api/graphql\")"), "client anno; was:\n" + api); + assertTrue(api.contains("query HeroName($episode: Episode) { hero(episode: $episode) " + + "{ ...HeroFields friends { name } } }"), + "minified operation document embedded; was:\n" + api); + assertTrue(api.contains("fragment HeroFields on Character { name }"), + "referenced fragment appended to document; was:\n" + api); + assertTrue(api.contains("@Var(\"episode\") Episode episode"), "typed enum variable; was:\n" + api); + assertTrue(api.contains("@Header(\"Authorization\") String bearerToken"), + "bearer token param; was:\n" + api); + assertTrue(api.contains("OnComplete> callback"), + "query callback type; was:\n" + api); + assertTrue(api.contains("@Var(\"review\") ReviewInput review"), + "input-object variable param; was:\n" + api); + assertTrue(api.contains("GraphQLSubscription onReview("), + "subscription returns handle; was:\n" + api); + assertTrue(api.contains("GraphQLSubscription.Handler handler"), + "subscription handler param; was:\n" + api); + assertTrue(api.contains("static StarWarsApi of(String endpoint)"), "of factory; was:\n" + api); + assertTrue(api.contains("GraphQLClients.create(StarWarsApi.class, endpoint)"), + "of delegates to registry"); + } + + @Test + void operationsModeEmitsClassesOnJava8(@TempDir Path tmp) throws Exception { + File out = tmp.toFile(); + new GenerateGraphQLMojo.Generator(schema(), ops(), "com.example.sw", out, true, + /*emitRecords*/ false, "", "StarWarsApi", 2, new SystemStreamLog()).run(); + String hero = read(out, "com/example/sw/HeroNameData.java"); + assertTrue(hero.contains("public class HeroNameData {"), "class form on Java 8; was:\n" + hero); + assertTrue(hero.contains("public HeroNameData_Hero hero;"), "public field; was:\n" + hero); + } + + @Test + void schemaOnlyModeAutoExpandsBoundedDepth(@TempDir Path tmp) throws Exception { + File out = tmp.toFile(); + new GenerateGraphQLMojo.Generator(schema(), /*operations*/ null, "com.example.sw", out, true, + true, "", "StarWarsApi", 2, new SystemStreamLog()).run(); + + String api = read(out, "com/example/sw/StarWarsApi.java"); + assertTrue(api.contains("hero("), "root query field hero; was:\n" + api); + assertTrue(api.contains("version("), "scalar root field version; was:\n" + api); + assertTrue(api.contains("GraphQLSubscription reviewAdded("), + "subscription root field; was:\n" + api); + assertTrue(api.contains("hero(episode: $episode)"), + "auto-selection wires the field argument; was:\n" + api); + + String heroData = read(out, "com/example/sw/HeroQueryData_Hero.java"); + assertTrue(heroData.contains("id") && heroData.contains("name"), + "scalar fields expanded; was:\n" + heroData); + assertTrue(heroData.contains("List appearsIn"), + "[Episode!]! expands to List (enum now bindable); was:\n" + heroData); + assertFalse(heroData.contains("friends"), + "recursive Character field omitted at depth/cycle limit; was:\n" + heroData); + } + + @Test + void minifyStripsCommentsAndCollapsesWhitespace() { + String in = "query Q {\n # a comment\n field\n}\n"; + assertEquals("query Q { field }", GenerateGraphQLMojo.Generator.minify(in)); + } + + @Test + void minifyPreservesStringLiterals() { + String in = "mutation { set(text: \"a b\") }"; + assertEquals("mutation { set(text: \"a b\") }", GenerateGraphQLMojo.Generator.minify(in)); + } + + @Test + void javaNameConvertsSnakeAndReserved() { + assertEquals("fooBar", GenerateGraphQLMojo.Generator.javaName("foo_bar")); + assertEquals("class_", GenerateGraphQLMojo.Generator.javaName("class")); + } + + private static String read(File outDir, String rel) throws Exception { + File f = new File(outDir, rel); + assertTrue(f.exists(), "expected generated file " + rel); + return new String(Files.readAllBytes(f.toPath()), StandardCharsets.UTF_8); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/GraphQLClientAnnotationProcessorTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/GraphQLClientAnnotationProcessorTest.java new file mode 100644 index 0000000000..e7bfaa8724 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/GraphQLClientAnnotationProcessorTest.java @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + */ +package com.codename1.maven.processors; + +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.ClassScanner; +import com.codename1.maven.annotations.JavaSourceCompiler; +import com.codename1.maven.annotations.ProcessorContext; + +import org.apache.maven.plugin.logging.SystemStreamLog; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.net.URL; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/// End-to-end test for `GraphQLClientAnnotationProcessor`. Compiles a +/// `@GraphQLClient` fixture with a query and a subscription, runs the +/// processor, and asserts the generated impl chains +/// `GraphQL.execute` / `GraphQL.subscribe` and that the bootstrap +/// registers the interface with `GraphQLClients`. +public class GraphQLClientAnnotationProcessorTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void emitsImplAndBootstrap() throws Exception { + File classes = tmp.newFolder("classes"); + Map sources = new LinkedHashMap(); + sources.put("com.example.sw.HeroNameData", + "package com.example.sw;\n" + + "import com.codename1.annotations.Mapped;\n" + + "@Mapped public class HeroNameData {\n" + + " public String hero;\n" + + " public HeroNameData() {}\n" + + "}\n"); + sources.put("com.example.sw.StarWarsApi", + "package com.example.sw;\n" + + "import com.codename1.annotations.graphql.*;\n" + + "import com.codename1.annotations.rest.Header;\n" + + "import com.codename1.io.graphql.GraphQLResponse;\n" + + "import com.codename1.io.graphql.GraphQLSubscription;\n" + + "import com.codename1.util.OnComplete;\n" + + "@GraphQLClient(\"https://api/graphql\")\n" + + "public interface StarWarsApi {\n" + + " @Query(\"query HeroName($episode: String) { hero(episode: $episode) { name } }\")\n" + + " void heroName(@Var(\"episode\") String episode,\n" + + " @Header(\"Authorization\") String bearerToken,\n" + + " OnComplete> callback);\n" + + " @Subscription(\"subscription OnReview { reviewAdded { stars } }\")\n" + + " GraphQLSubscription onReview(@Header(\"Authorization\") String bearerToken,\n" + + " GraphQLSubscription.Handler handler);\n" + + "}\n"); + JavaSourceCompiler.compile(sources, classes, Arrays.asList(testClassesDir())); + + ProcessorContext ctx = runProcessor(classes); + if (ctx.hasErrors()) failProcessor(ctx); + + assertTrue("expected StarWarsApiImpl.class", + new File(classes, "com/example/sw/StarWarsApiImpl.class").exists()); + assertTrue("expected GraphQLClientBootstrap.class", + new File(classes, "cn1app/GraphQLClientBootstrap.class").exists()); + + String implSrc = generateImplSourceForFixture(classes); + assertTrue("impl should call GraphQL.execute; was:\n" + implSrc, + implSrc.contains("com.codename1.io.graphql.GraphQL.execute")); + assertTrue("impl should call GraphQL.subscribe; was:\n" + implSrc, + implSrc.contains("return com.codename1.io.graphql.GraphQL.subscribe")); + assertTrue("impl should build the variables map", + implSrc.contains("_vars.put(\"episode\"")); + assertTrue("impl should embed the operation document", + implSrc.contains("hero(episode: $episode)")); + assertTrue("impl should reference the response data class", + implSrc.contains("com.example.sw.HeroNameData.class")); + + String bootSrc = generateBootstrapSourceForFixture(classes); + assertTrue("bootstrap should register StarWarsApi; was:\n" + bootSrc, + bootSrc.contains("GraphQLClients.register(com.example.sw.StarWarsApi.class")); + assertTrue("bootstrap should instantiate StarWarsApiImpl", + bootSrc.contains("new com.example.sw.StarWarsApiImpl(endpoint)")); + } + + @Test + public void rejectsMethodWithoutOperation() throws Exception { + File classes = tmp.newFolder("classes"); + Map sources = new LinkedHashMap(); + sources.put("com.example.x.Data", + "package com.example.x;\n" + + "import com.codename1.annotations.Mapped;\n" + + "@Mapped public class Data { public Data() {} }\n"); + sources.put("com.example.x.Bad", + "package com.example.x;\n" + + "import com.codename1.annotations.graphql.GraphQLClient;\n" + + "import com.codename1.io.graphql.GraphQLResponse;\n" + + "import com.codename1.util.OnComplete;\n" + + "@GraphQLClient\n" + + "public interface Bad {\n" + + " void unmarked(OnComplete> cb);\n" + + "}\n"); + JavaSourceCompiler.compile(sources, classes, Arrays.asList(testClassesDir())); + ProcessorContext ctx = runProcessor(classes); + assertTrue("expected error on method without @Query/@Mutation/@Subscription", ctx.hasErrors()); + } + + @Test + public void rejectsGraphQLClientOnConcreteClass() throws Exception { + File classes = tmp.newFolder("classes"); + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource("com.example.Bad", + "package com.example;\n" + + "import com.codename1.annotations.graphql.GraphQLClient;\n" + + "@GraphQLClient public class Bad {}\n"), + classes, Arrays.asList(testClassesDir())); + ProcessorContext ctx = runProcessor(classes); + assertTrue("expected error on @GraphQLClient applied to a class", ctx.hasErrors()); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + private ProcessorContext runProcessor(File classesDir) throws Exception { + Map index = ClassScanner.scan(classesDir); + GraphQLClientAnnotationProcessor proc = new GraphQLClientAnnotationProcessor(); + ProcessorContext ctx = new ProcessorContext(classesDir, tmp.newFolder(), + index, new SystemStreamLog()); + proc.start(ctx); + for (AnnotatedClass cls : index.values()) { + if (!cls.getClassAnnotations().isEmpty()) proc.processClass(cls, ctx); + } + proc.finish(ctx); + return ctx; + } + + private String generateImplSourceForFixture(File classesDir) throws Exception { + GraphQLClientAnnotationProcessor proc = primedProcessor(classesDir); + java.lang.reflect.Field accepted = GraphQLClientAnnotationProcessor.class + .getDeclaredField("accepted"); + accepted.setAccessible(true); + java.util.TreeMap map = (java.util.TreeMap) accepted.get(proc); + Object api = map.values().iterator().next(); + java.lang.reflect.Method m = GraphQLClientAnnotationProcessor.class + .getDeclaredMethod("generateImplSource", + Class.forName("com.codename1.maven.processors." + + "GraphQLClientAnnotationProcessor$GraphQLApi")); + m.setAccessible(true); + return (String) m.invoke(null, api); + } + + private String generateBootstrapSourceForFixture(File classesDir) throws Exception { + GraphQLClientAnnotationProcessor proc = primedProcessor(classesDir); + java.lang.reflect.Field accepted = GraphQLClientAnnotationProcessor.class + .getDeclaredField("accepted"); + accepted.setAccessible(true); + java.util.TreeMap map = (java.util.TreeMap) accepted.get(proc); + java.lang.reflect.Method m = GraphQLClientAnnotationProcessor.class + .getDeclaredMethod("generateBootstrapSource", Iterable.class); + m.setAccessible(true); + return (String) m.invoke(null, map.values()); + } + + private GraphQLClientAnnotationProcessor primedProcessor(File classesDir) throws Exception { + Map index = ClassScanner.scan(classesDir); + GraphQLClientAnnotationProcessor proc = new GraphQLClientAnnotationProcessor(); + ProcessorContext ctx = new ProcessorContext(classesDir, tmp.newFolder(), + index, new SystemStreamLog()); + proc.start(ctx); + for (AnnotatedClass cls : index.values()) { + if (!cls.getClassAnnotations().isEmpty()) proc.processClass(cls, ctx); + } + return proc; + } + + private static void failProcessor(ProcessorContext ctx) { + StringBuilder sb = new StringBuilder("GraphQL processor reported errors:\n"); + for (ProcessorContext.ProcessingError e : ctx.getErrors()) sb.append(' ').append(e).append('\n'); + fail(sb.toString()); + } + + private static File testClassesDir() throws Exception { + URL url = GraphQLClientAnnotationProcessorTest.class.getProtectionDomain() + .getCodeSource().getLocation(); + return new File(url.toURI()); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java index e9b85b8c59..b5e1326eae 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java @@ -18,12 +18,15 @@ import java.lang.reflect.Method; import java.net.URL; import java.net.URLClassLoader; +import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -90,6 +93,67 @@ public void pojoRoundTripsThroughGeneratedMapper() throws Exception { } } + @Test + public void enumFieldsRoundTripThroughJson() throws Exception { + File classes = tmp.newFolder("classes"); + Map sources = new LinkedHashMap(); + sources.put("com.example.Color", + "package com.example;\n" + + "public enum Color { RED, GREEN, BLUE }\n"); + sources.put("com.example.Paint", + "package com.example;\n" + + "import com.codename1.annotations.Mapped;\n" + + "import java.util.List;\n" + + "@Mapped public class Paint {\n" + + " public Color primary;\n" + + " public List palette;\n" + + " public Paint() {}\n" + + "}\n"); + JavaSourceCompiler.compile(sources, classes, Arrays.asList(testClassesDir())); + runProcessorOrFail(classes); + + try (URLClassLoader cl = childLoader(classes)) { + Class colorCls = cl.loadClass("com.example.Color"); + Class paintCls = cl.loadClass("com.example.Paint"); + Class mapperCls = cl.loadClass("com.example.PaintCn1Mapper"); + Object mapper = mapperCls.newInstance(); + Method valueOf = colorCls.getMethod("valueOf", String.class); + Object red = valueOf.invoke(null, "RED"); + Object blue = valueOf.invoke(null, "BLUE"); + + Object paint = paintCls.newInstance(); + paintCls.getField("primary").set(paint, red); + List pal = new ArrayList(); + pal.add(red); + pal.add(blue); + paintCls.getField("palette").set(paint, pal); + + // toMap: enum -> name(); List -> List. + Method toMap = mapperCls.getMethod("toMap", paintCls); + @SuppressWarnings("unchecked") + Map json = (Map) toMap.invoke(mapper, paint); + assertEquals("RED", json.get("primary")); + assertEquals(Arrays.asList("RED", "BLUE"), json.get("palette")); + + // fromMap: name -> enum constant; list of names -> list of enums. + Map in = new LinkedHashMap(); + in.put("primary", "GREEN"); + in.put("palette", Arrays.asList("BLUE", "RED")); + Method fromMap = mapperCls.getMethod("fromMap", Map.class); + Object restored = fromMap.invoke(mapper, in); + assertEquals(valueOf.invoke(null, "GREEN"), paintCls.getField("primary").get(restored)); + List rpal = (List) paintCls.getField("palette").get(restored); + assertEquals(blue, rpal.get(0)); + assertEquals(red, rpal.get(1)); + + // Unknown enum names decode to null rather than throwing. + Map bad = new LinkedHashMap(); + bad.put("primary", "MAGENTA"); + Object r2 = fromMap.invoke(mapper, bad); + assertNull(paintCls.getField("primary").get(r2)); + } + } + @Test public void propertyFieldRoundTripsThroughJsonAndXml() throws Exception { File classes = compileFixture( diff --git a/maven/core-unittests/src/test/java/com/codename1/io/WebSocketTest.java b/maven/core-unittests/src/test/java/com/codename1/io/WebSocketTest.java index 46704ed75a..95a53fdb7d 100644 --- a/maven/core-unittests/src/test/java/com/codename1/io/WebSocketTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/io/WebSocketTest.java @@ -12,6 +12,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; @@ -55,6 +56,23 @@ void connectAndTextMessageRouteToHandlers() { assertEquals(Arrays.asList("hello", "world"), messages); } + @Test + void subprotocolsAreOfferedAndSelectionSurfaced() { + WebSocket ws = WebSocket.build("ws://test") + .subprotocols("graphql-transport-ws", "chat") + .connect(); + + // The facade hands the offered list to the impl before connect. + assertArrayEquals(new String[] { "graphql-transport-ws", "chat" }, + mockImpl.testRequestedSubprotocols()); + + // The platform records the server's pick before firing onConnect; + // the facade surfaces it. + mockImpl.testSelect("graphql-transport-ws"); + mockImpl.testSink().onConnect(); + assertEquals("graphql-transport-ws", ws.getSelectedSubprotocol()); + } + @Test void binaryHandlerReceivesBytes() { List received = new ArrayList<>(); @@ -236,5 +254,14 @@ public WebSocketState getReadyState() { WebSocketEventSink testSink() { return super.sink(); } + + /// Test-only accessors for the subprotocol plumbing on the base. + String[] testRequestedSubprotocols() { + return super.requestedSubprotocols(); + } + + void testSelect(String protocol) { + super.setSelectedSubprotocol(protocol); + } } } diff --git a/maven/core-unittests/src/test/java/com/codename1/io/graphql/GraphQLResponseTest.java b/maven/core-unittests/src/test/java/com/codename1/io/graphql/GraphQLResponseTest.java new file mode 100644 index 0000000000..b1cd93eafe --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/io/graphql/GraphQLResponseTest.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + */ +package com.codename1.io.graphql; + +import com.codename1.io.JSONParser; +import com.codename1.mapping.Mapper; +import com.codename1.mapping.Mappers; +import com.codename1.xml.Element; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Unit tests for the GraphQL runtime envelope: request-body building, +/// variable encoding, and the `data` / `errors` response decode path. +/// None of these touch the network, so they exercise the pure helpers +/// directly. +class GraphQLResponseTest { + + enum Episode { NEWHOPE, EMPIRE, JEDI } + + static final class HeroData { + String hero; + } + + /// Minimal hand-written mapper so `decodeJson` can map `data` to a + /// type without the build-time annotation processor. + private static void registerHeroMapper() { + Mappers.register(new Mapper() { + public Class type() { return HeroData.class; } + public Map toMap(HeroData instance) { + Map m = new LinkedHashMap(); + m.put("hero", instance.hero); + return m; + } + public HeroData fromMap(Map map) { + HeroData d = new HeroData(); + Object h = map.get("hero"); + d.hero = h == null ? null : String.valueOf(h); + return d; + } + public String xmlRootName() { return "heroData"; } + public void writeXml(HeroData instance, Element root) { } + public HeroData readXml(Element root) { return new HeroData(); } + }); + } + + @Test + void buildRequestBodyCarriesQueryOperationAndVariables() throws Exception { + Map vars = new LinkedHashMap(); + vars.put("episode", Episode.JEDI); + String body = GraphQL.buildRequestBody("HeroName", + "query HeroName($episode: Episode) { hero(episode: $episode) { name } }", vars); + + Map parsed = JSONParser.parseJSON(body); + assertEquals("query HeroName($episode: Episode) { hero(episode: $episode) { name } }", + parsed.get("query")); + assertEquals("HeroName", parsed.get("operationName")); + Object v = parsed.get("variables"); + assertTrue(v instanceof Map, "variables should be an object"); + assertEquals("JEDI", ((Map) v).get("episode")); + } + + @Test + void encodeVariablesSerialisesScalarsEnumsListsAndMaps() throws Exception { + Map vars = new LinkedHashMap(); + vars.put("episode", Episode.EMPIRE); + vars.put("count", Integer.valueOf(3)); + vars.put("flag", Boolean.TRUE); + List tags = new ArrayList(); + tags.add("a"); + tags.add("b"); + vars.put("tags", tags); + Map nested = new LinkedHashMap(); + nested.put("k", "v"); + vars.put("nested", nested); + + String encoded = GraphQL.encodeVariables(vars); + // Boolean is asserted against the raw JSON: JSONParser.parseJSON + // re-reads `true` as the String "true" with its default config. + assertTrue(encoded.contains("\"flag\":true"), "boolean literal; was: " + encoded); + + Map parsed = JSONParser.parseJSON(encoded); + assertEquals("EMPIRE", parsed.get("episode")); + assertEquals(3L, ((Number) parsed.get("count")).longValue()); + assertTrue(parsed.get("tags") instanceof List); + assertEquals(2, ((List) parsed.get("tags")).size()); + assertEquals("v", ((Map) parsed.get("nested")).get("k")); + } + + @Test + void decodeMapsDataToTypedObject() { + registerHeroMapper(); + byte[] body = "{\"data\":{\"hero\":\"R2-D2\"}}".getBytes(StandardCharsets.UTF_8); + GraphQLResponse r = GraphQL.decodeJson(body, 200, HeroData.class); + assertTrue(r.isOk()); + assertFalse(r.hasErrors()); + assertNotNull(r.getData()); + assertEquals("R2-D2", r.getData().hero); + assertEquals(200, r.getResponseCode()); + } + + @Test + void decodeExtractsErrorsAlongsidePartialData() { + registerHeroMapper(); + byte[] body = ("{\"data\":{\"hero\":null}," + + "\"errors\":[{\"message\":\"boom\",\"path\":[\"hero\"]}]}") + .getBytes(StandardCharsets.UTF_8); + GraphQLResponse r = GraphQL.decodeJson(body, 200, HeroData.class); + assertTrue(r.hasErrors()); + assertFalse(r.isOk()); + assertEquals(1, r.getErrors().size()); + assertEquals("boom", r.getErrors().get(0).getMessage()); + assertEquals("boom", r.getResponseErrorMessage()); + assertNotNull(r.getData()); // partial result: data object present, hero null + assertNull(r.getData().hero); + } + + @Test + void decodeEmptyBodyIsTransportFailure() { + GraphQLResponse r = GraphQL.decodeJson(new byte[0], 0, HeroData.class); + assertNull(r.getData()); + assertNotNull(r.getResponseErrorMessage()); + } + + @Test + void webSocketUrlRewritesScheme() { + assertEquals("wss://api.example.com/graphql", + GraphQL.toWebSocketUrl("https://api.example.com/graphql")); + assertEquals("ws://localhost:8080/graphql", + GraphQL.toWebSocketUrl("http://localhost:8080/graphql")); + assertEquals("wss://already/ws", GraphQL.toWebSocketUrl("wss://already/ws")); + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEWebSocketImplTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEWebSocketImplTest.java index 3a1060477d..3d48654ea8 100644 --- a/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEWebSocketImplTest.java +++ b/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEWebSocketImplTest.java @@ -121,6 +121,51 @@ public void onError(Exception ex) { "no errors during normal session"); } + @Test + void negotiatesSubprotocol() throws Exception { + final CountDownLatch connected = new CountDownLatch(1); + final AtomicReference errored = new AtomicReference(); + + JavaSEWebSocketImpl impl = new JavaSEWebSocketImpl( + "ws://127.0.0.1:" + server.port() + "/"); + impl.setRequestedSubprotocols(new String[] { "graphql-transport-ws", "chat" }); + impl.setEventSink(new WebSocketEventSink() { + @Override + public void onConnect() { + connected.countDown(); + } + + @Override + public void onTextMessage(String message) { + } + + @Override + public void onBinaryMessage(byte[] message) { + } + + @Override + public void onClose(int code, String reason) { + } + + @Override + public void onError(Exception ex) { + errored.set(ex); + } + }); + impl.connect(0); + + org.junit.jupiter.api.Assertions.assertTrue( + connected.await(3, TimeUnit.SECONDS), "handshake completes"); + // The client offered both protocols in the Sec-WebSocket-Protocol header... + org.junit.jupiter.api.Assertions.assertEquals("graphql-transport-ws, chat", + server.lastRequestedProtocols); + // ...and surfaces the server's selection. + org.junit.jupiter.api.Assertions.assertEquals("graphql-transport-ws", + impl.getSelectedSubprotocol()); + org.junit.jupiter.api.Assertions.assertNull(errored.get()); + impl.close(); + } + @Test void largePayloadSurvivesFragmentation() throws Exception { final CountDownLatch connected = new CountDownLatch(1); @@ -175,6 +220,7 @@ private static final class EchoServer { private static final String GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; private ServerSocket serverSocket; private Thread thread; + private volatile String lastRequestedProtocols; void start() throws IOException { serverSocket = new ServerSocket(); @@ -235,13 +281,22 @@ private void handle(Socket s) { } } String key = headers.get("sec-websocket-key"); + String requestedProtocols = headers.get("sec-websocket-protocol"); + lastRequestedProtocols = requestedProtocols; MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); String accept = Base64.getEncoder().encodeToString( sha1.digest((key + GUID).getBytes(ASCII))); + String protocolHeader = ""; + if (requestedProtocols != null && requestedProtocols.length() > 0) { + // Negotiate by selecting the first offered subprotocol. + String selected = requestedProtocols.split(",")[0].trim(); + protocolHeader = "Sec-WebSocket-Protocol: " + selected + "\r\n"; + } String resp = "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" - + "Sec-WebSocket-Accept: " + accept + "\r\n\r\n"; + + "Sec-WebSocket-Accept: " + accept + "\r\n" + + protocolHeader + "\r\n"; out.write(resp.getBytes(ASCII)); out.flush(); From 42368b84ef9fa87ad014bcf0cba915d2cec144dc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:06:58 +0300 Subject: [PATCH 2/9] docs(developer-guide): add GraphQL client (generate-graphql) appendix Documents cn1:generate-graphql, @GraphQLClient, the operations vs schema-only modes, generated output, HTTP/WebSocket wire protocol, and scope. Wired into Maven-Appendix-Goals.adoc beside the OpenAPI and gRPC goal sections. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../developer-guide/Maven-Appendix-Goals.adoc | 2 + .../appendix_goal_generate_graphql.adoc | 221 ++++++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 docs/developer-guide/appendix_goal_generate_graphql.adoc diff --git a/docs/developer-guide/Maven-Appendix-Goals.adoc b/docs/developer-guide/Maven-Appendix-Goals.adoc index 3331d57800..422af13a0d 100644 --- a/docs/developer-guide/Maven-Appendix-Goals.adoc +++ b/docs/developer-guide/Maven-Appendix-Goals.adoc @@ -27,6 +27,8 @@ include::appendix_goal_generate_openapi.adoc[] include::appendix_goal_generate_grpc.adoc[] +include::appendix_goal_generate_graphql.adoc[] + include::appendix_goal_guibuilder.adoc[] include::appendix_goal_generate_archetype.adoc[] diff --git a/docs/developer-guide/appendix_goal_generate_graphql.adoc b/docs/developer-guide/appendix_goal_generate_graphql.adoc new file mode 100644 index 0000000000..3d8a8575ea --- /dev/null +++ b/docs/developer-guide/appendix_goal_generate_graphql.adoc @@ -0,0 +1,221 @@ +=== Generate GraphQL client (`generate-graphql`) + +Generates a typed Codename One GraphQL client from a GraphQL SDL +schema, optionally driven by a set of operation documents. Writes one +`@Mapped` record (Java 17+) or class (Java 8 target) per response shape +and input type, a Java `enum` per GraphQL `enum`, and one +`@GraphQLClient`-annotated interface carrying one method per operation. +The generated files land in `common/src/main/java` so the project owns +the contract; the matching client implementation and JSON mappers are +emitted into `common/target/generated-sources` by the build-time +annotation processors so the project source stays clean. + +The mojo is paired with the existing `process-annotations` pipeline: +the generated `@Mapped` types trigger per-class JSON mapper generation +and a `cn1app.MapperBootstrap` entry, while `@GraphQLClient` triggers +per-interface `Impl` generation chained through +`com.codename1.io.graphql.GraphQL.execute(...)` (queries and mutations) +or `com.codename1.io.graphql.GraphQL.subscribe(...)` (subscriptions), +plus a `cn1app.GraphQLClientBootstrap` that wires everything to the +`com.codename1.io.graphql.GraphQLClients` registry (same splice pattern +as `@Mapped` mappers and `@GrpcClient` clients). + +==== Usage example + +[source, bash] +---- +mvn -pl common cn1:generate-graphql \ + -Dcn1.graphql.schema=schema.graphqls \ + -Dcn1.graphql.operations=operations.graphql \ + -Dcn1.graphql.basePackage=com.example.starwars +---- + +Configuration: + +[cols="1,3", options="header"] +|=== +| Property | Description + +| `-Dcn1.graphql.schema=PATH` +| Local path to the GraphQL SDL schema (`.graphqls` / `.graphql`). +Required: it supplies the type graph used to resolve field types. + +| `-Dcn1.graphql.basePackage=PKG` +| Java package the generated sources are written under. Models, enums, +input types, and the `@GraphQLClient` interface all land directly in +``. + +| `-Dcn1.graphql.operations=PATH` (optional) +| Path to a `.graphql` operation-document file, or a directory of them. +When supplied the generator runs in the precise operations mode (one +method and one response-type tree per named operation). When omitted it +falls back to the schema-only quick-start mode. + +| `-Dcn1.graphql.clientName=NAME` (optional) +| Simple name of the generated `@GraphQLClient` interface. Defaults to +`GraphQLApi`. + +| `-Dcn1.graphql.endpoint=URL` (optional) +| Default endpoint URL recorded on the generated annotation. The +effective endpoint is always the argument passed to `of(String endpoint)`. + +| `-Dcn1.graphql.maxDepth=N` (optional) +| Maximum selection-set depth used by the schema-only quick-start mode. +Defaults to `2`. + +| `-Dcn1.graphql.outputDirectory=DIR` (optional) +| Defaults to `${project.basedir}/src/main/java`. + +| `-Dcn1.graphql.overwrite=false` (optional) +| Defaults to `true`. Set to `false` to preserve user edits to existing +files (only missing files are written). +|=== + +==== Generation modes + +Unlike OpenAPI paths or gRPC service methods, a GraphQL schema does not +enumerate the operations a client wants -- the client chooses which +fields to select. The goal therefore supports two modes: + +* **Operations mode** (recommended): supply `cn1.graphql.operations`. +For each named `query` / `mutation` / `subscription` the generator emits +one interface method plus a precise `@Mapped` response-type tree that +mirrors exactly that operation's selection set (nested selections become +nested generated types). Referenced fragment definitions are inlined +into the request document automatically. +* **Schema-only quick-start mode**: omit `cn1.graphql.operations`. For +each field of the root `Query` / `Mutation` / `Subscription` type the +generator emits one method whose selection set is auto-expanded to +`cn1.graphql.maxDepth` levels, stopping at recursive types. This is a +convenience for getting started; it may over- or under-fetch, so prefer +operations mode for production code. + +==== Generated output + +For a Star Wars schema and an `operations.graphql` declaring +`query HeroName`, `mutation AddReview`, and +`subscription OnReview`, the goal emits under `common/src/main/java`: + +[listing] +---- +com/example/starwars/ + Episode.java // GraphQL enum -> Java enum + ReviewInput.java // @Mapped input type + HeroNameData.java // @Mapped query response root + HeroNameData_Hero.java // nested selection type + AddReviewData.java // @Mapped mutation response root + OnReviewData.java // @Mapped subscription response root + StarWarsApi.java // @GraphQLClient interface +---- + +GraphQL enums map to a generated Java `enum` in response types, input +types, and method variables alike -- the JSON mapper binds enums by +their `name()`. Built-in scalars map to their boxed Java type +(`Int` -> `Integer`, `Float` -> `Double`, `Boolean` -> `Boolean`, +`String` / `ID` -> `String`); custom scalars fall back to `String`. + +The `@GraphQLClient` interface looks like: + +[source, java] +---- +@GraphQLClient("https://api.example.com/graphql") +public interface StarWarsApi { + + @Query("query HeroName($episode: Episode) { hero(episode: $episode) { name } }") + void heroName(@Var("episode") Episode episode, + @Header("Authorization") String bearerToken, + OnComplete> callback); + + @Mutation("mutation AddReview($ep: Episode!, $review: ReviewInput!) { createReview(episode: $ep, review: $review) { stars } }") + void addReview(@Var("ep") Episode ep, @Var("review") ReviewInput review, + @Header("Authorization") String bearerToken, + OnComplete> callback); + + @Subscription("subscription OnReview($ep: Episode!) { reviewAdded(episode: $ep) { stars } }") + GraphQLSubscription onReview(@Var("ep") Episode ep, + @Header("Authorization") String bearerToken, + GraphQLSubscription.Handler handler); + + static StarWarsApi of(String endpoint) { + return GraphQLClients.create(StarWarsApi.class, endpoint); + } +} +---- + +Call sites use the static factory. Queries and mutations report through +an `OnComplete>`: + +[source, java] +---- +StarWarsApi api = StarWarsApi.of("https://api.example.com/graphql"); +api.heroName(Episode.JEDI, "Bearer " + token, response -> { + if (response.isOk()) { + renderHero(response.getData().hero().name()); + } else { + showError(response.getResponseErrorMessage()); + } +}); +---- + +A subscription returns a `GraphQLSubscription` handle whose `cancel()` +ends the stream: + +[source, java] +---- +GraphQLSubscription sub = api.onReview(Episode.EMPIRE, "Bearer " + token, + new GraphQLSubscription.Handler() { + public void onNext(GraphQLResponse r) { + addReview(r.getData().reviewAdded().stars()); + } + public void onError(GraphQLResponse r) { + showError(r.getResponseErrorMessage()); + } + public void onComplete() { } + }); +// later: +sub.cancel(); +---- + +The `Impl` class that performs the request lives in +`target/generated-sources` -- the project source never references it +directly. The build server probes the project zip for +`cn1app.GraphQLClientBootstrap` and splices the registry wiring in, +mirroring the existing `cn1app.MapperBootstrap` pattern. + +==== Wire protocol + +Queries and mutations are sent as an HTTP `POST` to the endpoint with a +JSON body `{"query":...,"operationName":...,"variables":{...}}` and a +`Content-Type: application/json` header. The response envelope +(`{"data":...,"errors":[...]}`) is parsed into a typed +`GraphQLResponse`; because a GraphQL response can carry both data and +errors, `GraphQLResponse` exposes `getData()` and `getErrors()` +independently and `isOk()` reports whether the `errors` array was empty. + +Subscriptions run over a WebSocket using the `graphql-transport-ws` +protocol (offered via the `Sec-WebSocket-Protocol` handshake header): +the client sends `connection_init`, waits for `connection_ack`, sends a +`subscribe` message carrying the operation, and maps each `next` +payload's `data` to `T` before delivering it to the handler. The +WebSocket endpoint defaults to the query endpoint with its scheme +rewritten to `ws` / `wss`; pass a `ws://` / `wss://` URL to `of(...)` to +override it. All handler callbacks are dispatched on the EDT. + +==== Scope + +* Queries, mutations, and subscriptions. +* GraphQL enums map to generated Java enums in every position (response +fields, input fields, and method variables); unknown enum names decode +to `null` rather than throwing. +* Input object types map to generated `@Mapped` classes; variables of +scalar, enum, input-object, and list types are supported. +* Named and inline fragments are resolved against the schema and inlined +into the generated response types and the request document. +* Built-in scalars (`Int`, `Float`, `Boolean`, `String`, `ID`) map to +their boxed Java types. Custom scalars fall back to `String`. +* `union` / `interface` selections are emitted with their common fields +in the quick-start mode; use explicit operation documents with inline +fragments for full polymorphic typing. +* Authentication: a bearer token is exposed uniformly as a +`@Header("Authorization") String bearerToken` parameter on every method, +mirroring the OpenAPI and gRPC bearer-token slots. From 7d1c0308832b0fafdffe3d159e1fd416c0dcfc4c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:25:29 +0300 Subject: [PATCH 3/9] test(e2e): Spring Boot server + CN1 client exercising REST/GraphQL/gRPC end-to-end Adds scripts/protocol-e2e: a Spring Boot server that exposes the same greeting service over REST (OpenAPI), GraphQL (Spring for GraphQL), and gRPC-Web (binary framing implemented directly, no Envoy needed), plus a Codename One client whose @RestClient / @GraphQLClient / @GrpcClient sources (mirroring the cn1:generate-* output for the bundled specs) are wired by process-annotations and run on the JavaSE simulator via cn1:test. The client's AbstractTest classes perform real round-trips against the running server and assert the responses for all three protocols (GraphQL covers a query + an enum/int-variable mutation; gRPC covers a unary call; REST covers a typed GET). run-protocol-e2e.sh builds+starts the server and runs the client tests against it; .github/workflows/ protocol-e2e.yml runs it in CI alongside the hellocodenameone suites. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/protocol-e2e.yml | 63 ++++++++ scripts/protocol-e2e/README.md | 43 ++++++ .../common/codenameone_settings.properties | 11 ++ scripts/protocol-e2e/client/common/icon.png | Bin 0 -> 123269 bytes scripts/protocol-e2e/client/common/pom.xml | 115 +++++++++++++++ .../client/common/src/main/css/theme.css | 7 + .../java/com/codename1/e2e/E2eSupport.java | 20 +++ .../java/com/codename1/e2e/ProtocolE2e.java | 44 ++++++ .../codename1/e2e/graphql/AddReviewData.java | 13 ++ .../e2e/graphql/AddReviewData_AddReview.java | 15 ++ .../com/codename1/e2e/graphql/Episode.java | 8 + .../com/codename1/e2e/graphql/HeroData.java | 13 ++ .../codename1/e2e/graphql/HeroData_Hero.java | 15 ++ .../codename1/e2e/graphql/StarWarsApi.java | 29 ++++ .../com/codename1/e2e/grpc/GreeterGrpc.java | 22 +++ .../com/codename1/e2e/grpc/HelloReply.java | 13 ++ .../com/codename1/e2e/grpc/HelloRequest.java | 13 ++ .../java/com/codename1/e2e/rest/Greeting.java | 17 +++ .../com/codename1/e2e/rest/GreetingApi.java | 23 +++ .../codename1/e2e/GraphQlProtocolTest.java | 62 ++++++++ .../com/codename1/e2e/GrpcProtocolTest.java | 42 ++++++ .../com/codename1/e2e/RestProtocolTest.java | 39 +++++ scripts/protocol-e2e/client/javase/pom.xml | 86 +++++++++++ scripts/protocol-e2e/client/pom.xml | 111 ++++++++++++++ scripts/protocol-e2e/run-protocol-e2e.sh | 83 +++++++++++ scripts/protocol-e2e/server/pom.xml | 57 ++++++++ .../e2eserver/E2eServerApplication.java | 15 ++ .../e2eserver/GraphQlGreetingController.java | 31 ++++ .../e2eserver/GrpcWebGreetingController.java | 137 ++++++++++++++++++ .../e2eserver/RestGreetingController.java | 40 +++++ .../src/main/resources/application.properties | 6 + .../main/resources/graphql/schema.graphqls | 23 +++ scripts/protocol-e2e/specs/greeter.proto | 19 +++ scripts/protocol-e2e/specs/openapi.json | 37 +++++ scripts/protocol-e2e/specs/schema.graphqls | 23 +++ 35 files changed, 1295 insertions(+) create mode 100644 .github/workflows/protocol-e2e.yml create mode 100644 scripts/protocol-e2e/README.md create mode 100644 scripts/protocol-e2e/client/common/codenameone_settings.properties create mode 100644 scripts/protocol-e2e/client/common/icon.png create mode 100644 scripts/protocol-e2e/client/common/pom.xml create mode 100644 scripts/protocol-e2e/client/common/src/main/css/theme.css create mode 100644 scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/E2eSupport.java create mode 100644 scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/ProtocolE2e.java create mode 100644 scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/AddReviewData.java create mode 100644 scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/AddReviewData_AddReview.java create mode 100644 scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/Episode.java create mode 100644 scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/HeroData.java create mode 100644 scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/HeroData_Hero.java create mode 100644 scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/StarWarsApi.java create mode 100644 scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/grpc/GreeterGrpc.java create mode 100644 scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/grpc/HelloReply.java create mode 100644 scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/grpc/HelloRequest.java create mode 100644 scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/rest/Greeting.java create mode 100644 scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/rest/GreetingApi.java create mode 100644 scripts/protocol-e2e/client/common/src/test/java/com/codename1/e2e/GraphQlProtocolTest.java create mode 100644 scripts/protocol-e2e/client/common/src/test/java/com/codename1/e2e/GrpcProtocolTest.java create mode 100644 scripts/protocol-e2e/client/common/src/test/java/com/codename1/e2e/RestProtocolTest.java create mode 100644 scripts/protocol-e2e/client/javase/pom.xml create mode 100644 scripts/protocol-e2e/client/pom.xml create mode 100755 scripts/protocol-e2e/run-protocol-e2e.sh create mode 100644 scripts/protocol-e2e/server/pom.xml create mode 100644 scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/E2eServerApplication.java create mode 100644 scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/GraphQlGreetingController.java create mode 100644 scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/GrpcWebGreetingController.java create mode 100644 scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/RestGreetingController.java create mode 100644 scripts/protocol-e2e/server/src/main/resources/application.properties create mode 100644 scripts/protocol-e2e/server/src/main/resources/graphql/schema.graphqls create mode 100644 scripts/protocol-e2e/specs/greeter.proto create mode 100644 scripts/protocol-e2e/specs/openapi.json create mode 100644 scripts/protocol-e2e/specs/schema.graphqls diff --git a/.github/workflows/protocol-e2e.yml b/.github/workflows/protocol-e2e.yml new file mode 100644 index 0000000000..ed96392e65 --- /dev/null +++ b/.github/workflows/protocol-e2e.yml @@ -0,0 +1,63 @@ +name: Protocol E2E (REST / GraphQL / gRPC) + +# Stands up a Spring Boot server exposing the same logical service over REST +# (OpenAPI), GraphQL and gRPC-Web, then builds a Codename One client whose +# generated-style @RestClient / @GraphQLClient / @GrpcClient code is wired by +# process-annotations and run on the JavaSE simulator (cn1:test). The client +# performs real round-trips against all three endpoints and asserts the +# responses -- an end-to-end check of the full generated-client stack. + +on: + pull_request: + paths: + - 'scripts/protocol-e2e/**' + - 'CodenameOne/src/com/codename1/io/graphql/**' + - 'CodenameOne/src/com/codename1/annotations/graphql/**' + - 'CodenameOne/src/com/codename1/io/grpc/**' + - 'CodenameOne/src/com/codename1/io/rest/**' + - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/**' + - '.github/workflows/protocol-e2e.yml' + push: + branches: [ master ] + paths: + - 'scripts/protocol-e2e/**' + - 'CodenameOne/src/com/codename1/io/graphql/**' + - 'CodenameOne/src/com/codename1/annotations/graphql/**' + - '.github/workflows/protocol-e2e.yml' + workflow_dispatch: + +jobs: + protocol-e2e: + runs-on: ubuntu-latest + container: ghcr.io/codenameone/codenameone/pr-ci-container:latest + timeout-minutes: 40 + steps: + - uses: actions/checkout@v4 + + - name: Install Codename One artifacts (core / javase / css-compiler / plugin) + run: | + export JAVA_HOME="${JAVA_HOME_8}" + export PATH="${JAVA_HOME_8}/bin:$PATH" + cd maven + mvn -B -pl core,javase,css-compiler,codenameone-maven-plugin -am install \ + -Plocal-dev-javase -DskipTests -Dmaven.javadoc.skip=true + + - name: Ensure a virtual display (xvfb) for the simulator + run: | + if ! command -v xvfb-run >/dev/null 2>&1; then + (apt-get update && apt-get install -y xvfb) || true + fi + + - name: Run REST / GraphQL / gRPC end-to-end + run: | + export JAVA_HOME="${JAVA_HOME_17}" + export PATH="${JAVA_HOME_17}/bin:$PATH" + scripts/protocol-e2e/run-protocol-e2e.sh + + - name: Upload server log + if: always() + uses: actions/upload-artifact@v4 + with: + name: protocol-e2e-server-log + path: /tmp/protocol-e2e-server.log + if-no-files-found: ignore diff --git a/scripts/protocol-e2e/README.md b/scripts/protocol-e2e/README.md new file mode 100644 index 0000000000..800129b040 --- /dev/null +++ b/scripts/protocol-e2e/README.md @@ -0,0 +1,43 @@ +# Multi-protocol end-to-end test (REST / GraphQL / gRPC) + +This test exercises the full Codename One generated-client stack for all three +"spec → typed client" protocols against a single real server. + +## Layout + +- `server/` — a Spring Boot app exposing the same logical "greeting" service + three ways: + - **REST** (`GET /api/greeting`, `POST /api/echo`) — JSON, described by + [`specs/openapi.json`](specs/openapi.json). + - **GraphQL** (`POST /graphql`) — Spring for GraphQL, schema + [`specs/schema.graphqls`](specs/schema.graphqls). + - **gRPC-Web** (`POST /grpc/e2e.Greeter/SayHello`) — the gRPC-Web binary + framing the CN1 client speaks (`application/grpc-web+proto`) is implemented + directly, so no Envoy/proxy sidecar is needed. Contract: + [`specs/greeter.proto`](specs/greeter.proto). +- `client/` — a Codename One app (`common` + `javase`). Its + `@RestClient` / `@GraphQLClient` / `@GrpcClient` sources under + `client/common/src/main/java/com/codename1/e2e/` mirror what + `cn1:generate-openapi` / `cn1:generate-graphql` / `cn1:generate-grpc` produce + from the specs; `cn1:process-annotations` generates the impls and the + `cn1app.*Bootstrap` registrars at build time. The `AbstractTest` classes + under `client/common/src/test/java/com/codename1/e2e/` perform real + round-trips against the running server and are executed on the JavaSE + simulator via `cn1:test`. +- `run-protocol-e2e.sh` — builds and starts the server, then builds and runs + the client tests against it. + +## Running locally + +```bash +# Prerequisite: install this checkout's CN1 artifacts into your local repo +cd maven +mvn -pl core,javase,css-compiler,codenameone-maven-plugin -am install \ + -Plocal-dev-javase -DskipTests + +# Then run the end-to-end test (JDK 17 on PATH) +cd .. +scripts/protocol-e2e/run-protocol-e2e.sh +``` + +CI runs the same script in `.github/workflows/protocol-e2e.yml`. diff --git a/scripts/protocol-e2e/client/common/codenameone_settings.properties b/scripts/protocol-e2e/client/common/codenameone_settings.properties new file mode 100644 index 0000000000..23c125b528 --- /dev/null +++ b/scripts/protocol-e2e/client/common/codenameone_settings.properties @@ -0,0 +1,11 @@ +codename1.packageName=com.codename1.e2e +codename1.mainName=ProtocolE2e +codename1.displayName=ProtocolE2e +codename1.version=1.0 +codename1.vendor=CodenameOne +codename1.secondaryTitle=Protocol E2E +codename1.icon=icon.png +codename1.cssTheme=true +codename1.languageLevel=8 +codename1.kotlin=false +codename1.arg.java.version=17 diff --git a/scripts/protocol-e2e/client/common/icon.png b/scripts/protocol-e2e/client/common/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1f4fa5dd25258fbaa99b15a11d47c83ce4a9a8ac GIT binary patch literal 123269 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

g@MUJI7HpSpMR*={}r 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 + + 4.0.0 + + com.codename1.e2e + protocol-e2e-client + 1.0-SNAPSHOT + + com.codename1.e2e + protocol-e2e-client-common + 1.0-SNAPSHOT + jar + + + + com.codenameone + codenameone-core + provided + + + + + + + install-codenameone + ${user.home}/.codenameone/guibuilder.jar + + + + org.apache.maven.plugins + maven-antrun-plugin + + + validate + run + + + + + + + + + + + + + + + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${maven.compiler.source} + ${maven.compiler.target} + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + read-project-properties + + + ${basedir}/codenameone_settings.properties + + + + + + + com.codenameone + codenameone-maven-plugin + + + cn1-process-classes + process-classes + + bytecode-compliance + css + process-annotations + + + + attach-test-artifact + test + + attach-test-artifact + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + + diff --git a/scripts/protocol-e2e/client/common/src/main/css/theme.css b/scripts/protocol-e2e/client/common/src/main/css/theme.css new file mode 100644 index 0000000000..39f1aef131 --- /dev/null +++ b/scripts/protocol-e2e/client/common/src/main/css/theme.css @@ -0,0 +1,7 @@ +/* Minimal theme for the protocol e2e client (UI is incidental; tests do the work). */ +#Constants { +} + +Form { + background-color: #ffffff; +} diff --git a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/E2eSupport.java b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/E2eSupport.java new file mode 100644 index 0000000000..fbe9e539cf --- /dev/null +++ b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/E2eSupport.java @@ -0,0 +1,20 @@ +package com.codename1.e2e; + +/** Small shared helpers for the protocol e2e tests. */ +public final class E2eSupport { + private E2eSupport() {} + + /** Base URL of the running Spring Boot test server. */ + public static String baseUrl() { + String u = System.getProperty("e2e.server.url"); + return (u == null || u.length() == 0) ? "http://localhost:8080" : u; + } + + /** Blocks the (off-EDT) test thread until {@code done[0]} flips or the timeout elapses. */ + public static void await(boolean[] done) throws InterruptedException { + long deadline = System.currentTimeMillis() + 60000; + while (!done[0] && System.currentTimeMillis() < deadline) { + Thread.sleep(50); + } + } +} diff --git a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/ProtocolE2e.java b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/ProtocolE2e.java new file mode 100644 index 0000000000..3a78ce88c7 --- /dev/null +++ b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/ProtocolE2e.java @@ -0,0 +1,44 @@ +package com.codename1.e2e; + +import static com.codename1.ui.CN.*; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.util.Resources; + +/** + * Minimal Codename One lifecycle for the protocol end-to-end client. The UI is + * incidental -- the actual REST / GraphQL / gRPC round-trips against the + * Spring Boot test server are exercised by the AbstractTest classes under + * src/test/java and run via cn1:test. + */ +public class ProtocolE2e { + private Form current; + private Resources theme; + + public void init(Object context) { + theme = UIManager.initFirstTheme("/theme"); + } + + public void start() { + if (current != null) { + current.show(); + return; + } + Form hi = new Form("Protocol E2E", BoxLayout.y()); + hi.add(new Label("REST / GraphQL / gRPC client")); + hi.show(); + } + + public void stop() { + current = getCurrentForm(); + if (current instanceof com.codename1.ui.Dialog) { + ((com.codename1.ui.Dialog) current).dispose(); + current = getCurrentForm(); + } + } + + public void destroy() { + } +} diff --git a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/AddReviewData.java b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/AddReviewData.java new file mode 100644 index 0000000000..6f4e25eac4 --- /dev/null +++ b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/AddReviewData.java @@ -0,0 +1,13 @@ +// Mirrors the output of cn1:generate-graphql (mutation response root). +package com.codename1.e2e.graphql; + +import com.codename1.annotations.JsonProperty; +import com.codename1.annotations.Mapped; + +@Mapped +public class AddReviewData { + @JsonProperty("addReview") + public AddReviewData_AddReview addReview; + + public AddReviewData() {} +} diff --git a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/AddReviewData_AddReview.java b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/AddReviewData_AddReview.java new file mode 100644 index 0000000000..65214884ec --- /dev/null +++ b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/AddReviewData_AddReview.java @@ -0,0 +1,15 @@ +// Mirrors the output of cn1:generate-graphql (nested selection type). +package com.codename1.e2e.graphql; + +import com.codename1.annotations.JsonProperty; +import com.codename1.annotations.Mapped; + +@Mapped +public class AddReviewData_AddReview { + @JsonProperty("stars") + public Integer stars; + @JsonProperty("commentary") + public String commentary; + + public AddReviewData_AddReview() {} +} diff --git a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/Episode.java b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/Episode.java new file mode 100644 index 0000000000..8e11eaf2f9 --- /dev/null +++ b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/Episode.java @@ -0,0 +1,8 @@ +// Mirrors the output of cn1:generate-graphql for the e2e schema.graphqls. +package com.codename1.e2e.graphql; + +public enum Episode { + NEWHOPE, + EMPIRE, + JEDI +} diff --git a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/HeroData.java b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/HeroData.java new file mode 100644 index 0000000000..0a502735fe --- /dev/null +++ b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/HeroData.java @@ -0,0 +1,13 @@ +// Mirrors the output of cn1:generate-graphql (query response root). +package com.codename1.e2e.graphql; + +import com.codename1.annotations.JsonProperty; +import com.codename1.annotations.Mapped; + +@Mapped +public class HeroData { + @JsonProperty("hero") + public HeroData_Hero hero; + + public HeroData() {} +} diff --git a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/HeroData_Hero.java b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/HeroData_Hero.java new file mode 100644 index 0000000000..863a003a28 --- /dev/null +++ b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/HeroData_Hero.java @@ -0,0 +1,15 @@ +// Mirrors the output of cn1:generate-graphql (nested selection type). +package com.codename1.e2e.graphql; + +import com.codename1.annotations.JsonProperty; +import com.codename1.annotations.Mapped; + +@Mapped +public class HeroData_Hero { + @JsonProperty("name") + public String name; + @JsonProperty("greeting") + public String greeting; + + public HeroData_Hero() {} +} diff --git a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/StarWarsApi.java b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/StarWarsApi.java new file mode 100644 index 0000000000..bab5f7a70f --- /dev/null +++ b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/StarWarsApi.java @@ -0,0 +1,29 @@ +// Mirrors the output of cn1:generate-graphql for the e2e schema + operations. +package com.codename1.e2e.graphql; + +import com.codename1.annotations.graphql.GraphQLClient; +import com.codename1.annotations.graphql.Mutation; +import com.codename1.annotations.graphql.Query; +import com.codename1.annotations.graphql.Var; +import com.codename1.annotations.rest.Header; +import com.codename1.io.graphql.GraphQLClients; +import com.codename1.io.graphql.GraphQLResponse; +import com.codename1.util.OnComplete; + +@GraphQLClient("http://localhost:8080/graphql") +public interface StarWarsApi { + + @Query("query Hero($name: String) { hero(name: $name) { name greeting } }") + void hero(@Var("name") String name, + @Header("Authorization") String bearerToken, + OnComplete> callback); + + @Mutation("mutation AddReview($episode: Episode!, $stars: Int!) { addReview(episode: $episode, stars: $stars) { stars commentary } }") + void addReview(@Var("episode") Episode episode, @Var("stars") int stars, + @Header("Authorization") String bearerToken, + OnComplete> callback); + + static StarWarsApi of(String endpoint) { + return GraphQLClients.create(StarWarsApi.class, endpoint); + } +} diff --git a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/grpc/GreeterGrpc.java b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/grpc/GreeterGrpc.java new file mode 100644 index 0000000000..e187d6ac77 --- /dev/null +++ b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/grpc/GreeterGrpc.java @@ -0,0 +1,22 @@ +// Mirrors the output of cn1:generate-grpc for the e2e greeter.proto. +package com.codename1.e2e.grpc; + +import com.codename1.annotations.grpc.GrpcClient; +import com.codename1.annotations.grpc.Rpc; +import com.codename1.annotations.rest.Header; +import com.codename1.io.grpc.GrpcClients; +import com.codename1.io.grpc.GrpcResponse; +import com.codename1.util.OnComplete; + +@GrpcClient("e2e.Greeter") +public interface GreeterGrpc { + + @Rpc("SayHello") + void sayHello(HelloRequest request, + @Header("Authorization") String bearerToken, + OnComplete> callback); + + static GreeterGrpc of(String baseUrl) { + return GrpcClients.create(GreeterGrpc.class, baseUrl); + } +} diff --git a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/grpc/HelloReply.java b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/grpc/HelloReply.java new file mode 100644 index 0000000000..5d32f4b5e3 --- /dev/null +++ b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/grpc/HelloReply.java @@ -0,0 +1,13 @@ +// Mirrors the output of cn1:generate-grpc for the e2e greeter.proto. +package com.codename1.e2e.grpc; + +import com.codename1.annotations.grpc.ProtoField; +import com.codename1.annotations.grpc.ProtoMessage; + +@ProtoMessage +public class HelloReply { + @ProtoField(tag = 1) + public String message; + + public HelloReply() {} +} diff --git a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/grpc/HelloRequest.java b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/grpc/HelloRequest.java new file mode 100644 index 0000000000..215fceb874 --- /dev/null +++ b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/grpc/HelloRequest.java @@ -0,0 +1,13 @@ +// Mirrors the output of cn1:generate-grpc for the e2e greeter.proto. +package com.codename1.e2e.grpc; + +import com.codename1.annotations.grpc.ProtoField; +import com.codename1.annotations.grpc.ProtoMessage; + +@ProtoMessage +public class HelloRequest { + @ProtoField(tag = 1) + public String name; + + public HelloRequest() {} +} diff --git a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/rest/Greeting.java b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/rest/Greeting.java new file mode 100644 index 0000000000..9fb86fe2d3 --- /dev/null +++ b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/rest/Greeting.java @@ -0,0 +1,17 @@ +// Mirrors the output of cn1:generate-openapi for the e2e openapi.json. +package com.codename1.e2e.rest; + +import com.codename1.annotations.JsonProperty; +import com.codename1.annotations.Mapped; + +@Mapped +public class Greeting { + @JsonProperty("name") + public String name; + @JsonProperty("message") + public String message; + @JsonProperty("transport") + public String transport; + + public Greeting() {} +} diff --git a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/rest/GreetingApi.java b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/rest/GreetingApi.java new file mode 100644 index 0000000000..725bf3a65f --- /dev/null +++ b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/rest/GreetingApi.java @@ -0,0 +1,23 @@ +// Mirrors the output of cn1:generate-openapi for the e2e openapi.json. +package com.codename1.e2e.rest; + +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.Header; +import com.codename1.annotations.rest.Query; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.io.rest.RestClients; +import com.codename1.util.OnComplete; + +@RestClient +public interface GreetingApi { + + @GET("/api/greeting") + void greeting(@Query("name") String name, + @Header("Authorization") String bearerToken, + OnComplete> callback); + + static GreetingApi of(String baseUrl) { + return RestClients.create(GreetingApi.class, baseUrl); + } +} diff --git a/scripts/protocol-e2e/client/common/src/test/java/com/codename1/e2e/GraphQlProtocolTest.java b/scripts/protocol-e2e/client/common/src/test/java/com/codename1/e2e/GraphQlProtocolTest.java new file mode 100644 index 0000000000..5a3c80bb2a --- /dev/null +++ b/scripts/protocol-e2e/client/common/src/test/java/com/codename1/e2e/GraphQlProtocolTest.java @@ -0,0 +1,62 @@ +package com.codename1.e2e; + +import com.codename1.e2e.graphql.AddReviewData; +import com.codename1.e2e.graphql.Episode; +import com.codename1.e2e.graphql.HeroData; +import com.codename1.e2e.graphql.StarWarsApi; +import com.codename1.io.graphql.GraphQLResponse; +import com.codename1.testing.AbstractTest; +import com.codename1.util.OnComplete; + +/** End-to-end GraphQL query + mutation round-trips against the test server. */ +public class GraphQlProtocolTest extends AbstractTest { + + @Override + public boolean runTest() throws Exception { + StarWarsApi api = StarWarsApi.of(E2eSupport.baseUrl() + "/graphql"); + + // --- query --- + final HeroData[] hero = new HeroData[1]; + final boolean[] heroOk = { false }; + final boolean[] heroDone = { false }; + api.hero("Luke", null, new OnComplete>() { + public void completed(GraphQLResponse r) { + heroOk[0] = r.isOk(); + hero[0] = r.getData(); + heroDone[0] = true; + } + }); + E2eSupport.await(heroDone); + assertTrue(heroDone[0], "GraphQL query timed out"); + assertTrue(heroOk[0], "GraphQL query reported errors"); + assertNotNull(hero[0], "GraphQL data null"); + assertNotNull(hero[0].hero, "GraphQL hero null"); + assertEqual("Luke", hero[0].hero.name, "GraphQL hero.name"); + assertEqual("Hello, Luke!", hero[0].hero.greeting, "GraphQL hero.greeting"); + + // --- mutation (enum + int variables) --- + final AddReviewData[] review = new AddReviewData[1]; + final boolean[] reviewOk = { false }; + final boolean[] reviewDone = { false }; + api.addReview(Episode.JEDI, 5, null, new OnComplete>() { + public void completed(GraphQLResponse r) { + reviewOk[0] = r.isOk(); + review[0] = r.getData(); + reviewDone[0] = true; + } + }); + E2eSupport.await(reviewDone); + assertTrue(reviewDone[0], "GraphQL mutation timed out"); + assertTrue(reviewOk[0], "GraphQL mutation reported errors"); + assertNotNull(review[0], "GraphQL mutation data null"); + assertNotNull(review[0].addReview, "GraphQL addReview null"); + assertEqual(5, review[0].addReview.stars.intValue(), "GraphQL addReview.stars"); + assertEqual("JEDI rated 5", review[0].addReview.commentary, "GraphQL addReview.commentary"); + return true; + } + + @Override + public int getTimeoutMillis() { + return 90000; + } +} diff --git a/scripts/protocol-e2e/client/common/src/test/java/com/codename1/e2e/GrpcProtocolTest.java b/scripts/protocol-e2e/client/common/src/test/java/com/codename1/e2e/GrpcProtocolTest.java new file mode 100644 index 0000000000..5bb83286b3 --- /dev/null +++ b/scripts/protocol-e2e/client/common/src/test/java/com/codename1/e2e/GrpcProtocolTest.java @@ -0,0 +1,42 @@ +package com.codename1.e2e; + +import com.codename1.e2e.grpc.GreeterGrpc; +import com.codename1.e2e.grpc.HelloReply; +import com.codename1.e2e.grpc.HelloRequest; +import com.codename1.io.grpc.GrpcResponse; +import com.codename1.testing.AbstractTest; +import com.codename1.util.OnComplete; + +/** End-to-end gRPC-Web unary round-trip against the test server. */ +public class GrpcProtocolTest extends AbstractTest { + + @Override + public boolean runTest() throws Exception { + GreeterGrpc grpc = GreeterGrpc.of(E2eSupport.baseUrl() + "/grpc"); + HelloRequest req = new HelloRequest(); + req.name = "Leia"; + + final HelloReply[] data = new HelloReply[1]; + final int[] status = { -99 }; + final boolean[] done = { false }; + grpc.sayHello(req, null, new OnComplete>() { + public void completed(GrpcResponse r) { + status[0] = r.getResponseCode(); + data[0] = r.getResponseData(); + done[0] = true; + } + }); + E2eSupport.await(done); + + assertTrue(done[0], "gRPC call timed out"); + assertEqual(0, status[0], "gRPC status should be OK (0)"); + assertNotNull(data[0], "gRPC response data was null"); + assertEqual("Hello, Leia!", data[0].message, "gRPC reply message"); + return true; + } + + @Override + public int getTimeoutMillis() { + return 90000; + } +} diff --git a/scripts/protocol-e2e/client/common/src/test/java/com/codename1/e2e/RestProtocolTest.java b/scripts/protocol-e2e/client/common/src/test/java/com/codename1/e2e/RestProtocolTest.java new file mode 100644 index 0000000000..4dfbda17e2 --- /dev/null +++ b/scripts/protocol-e2e/client/common/src/test/java/com/codename1/e2e/RestProtocolTest.java @@ -0,0 +1,39 @@ +package com.codename1.e2e; + +import com.codename1.e2e.rest.Greeting; +import com.codename1.e2e.rest.GreetingApi; +import com.codename1.io.rest.Response; +import com.codename1.testing.AbstractTest; +import com.codename1.util.OnComplete; + +/** End-to-end REST (OpenAPI) round-trip against the Spring Boot test server. */ +public class RestProtocolTest extends AbstractTest { + + @Override + public boolean runTest() throws Exception { + GreetingApi api = GreetingApi.of(E2eSupport.baseUrl()); + final Greeting[] data = new Greeting[1]; + final int[] code = { -1 }; + final boolean[] done = { false }; + api.greeting("Shai", null, new OnComplete>() { + public void completed(Response r) { + code[0] = r.getResponseCode(); + data[0] = r.getResponseData(); + done[0] = true; + } + }); + E2eSupport.await(done); + + assertTrue(done[0], "REST call timed out"); + assertEqual(200, code[0], "REST HTTP status"); + assertNotNull(data[0], "REST response data was null"); + assertEqual("Hello, Shai!", data[0].message, "REST greeting message"); + assertEqual("rest", data[0].transport, "REST transport tag"); + return true; + } + + @Override + public int getTimeoutMillis() { + return 90000; + } +} diff --git a/scripts/protocol-e2e/client/javase/pom.xml b/scripts/protocol-e2e/client/javase/pom.xml new file mode 100644 index 0000000000..49e38f758f --- /dev/null +++ b/scripts/protocol-e2e/client/javase/pom.xml @@ -0,0 +1,86 @@ + + + 4.0.0 + + com.codename1.e2e + protocol-e2e-client + 1.0-SNAPSHOT + + com.codename1.e2e + protocol-e2e-client-javase + 1.0-SNAPSHOT + protocol-e2e-client-javase + + + UTF-8 + 17 + 17 + javase + javase + com.codename1.impl.javase.Simulator + + + + ${project.basedir}/../common/src/test/java + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + read-project-properties + + + ${basedir}/../common/codenameone_settings.properties + + + + + + + codenameone-maven-plugin + com.codenameone + ${cn1.plugin.version} + + + add-se-sources + generate-sources + generate-javase-sources + + + cn1-tests + test + test + + + + + + + + + ${project.groupId} + protocol-e2e-client-common + ${project.version} + + + ${project.groupId} + protocol-e2e-client-common + ${project.version} + tests + test + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + compile + + + diff --git a/scripts/protocol-e2e/client/pom.xml b/scripts/protocol-e2e/client/pom.xml new file mode 100644 index 0000000000..a41f15147e --- /dev/null +++ b/scripts/protocol-e2e/client/pom.xml @@ -0,0 +1,111 @@ + + + 4.0.0 + com.codename1.e2e + protocol-e2e-client + 1.0-SNAPSHOT + pom + protocol-e2e-client + Codename One client exercising REST / GraphQL / gRPC against the e2e server + + + common + + + + 8.0-SNAPSHOT + 8.0-SNAPSHOT + UTF-8 + 17 + 3.8.0 + 17 + 17 + 17 + 17 + protocol-e2e-client + + + + + + com.codenameone + java-runtime + ${cn1.version} + + + com.codenameone + codenameone-core + ${cn1.version} + + + com.codenameone + codenameone-javase + ${cn1.version} + + + + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + + org.codehaus.mojo + exec-maven-plugin + 3.0.0 + + + maven-antrun-plugin + org.apache.maven.plugins + 3.1.0 + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + + + + + + + javase + + + codename1.platform + javase + + true + + + javase + + + + diff --git a/scripts/protocol-e2e/run-protocol-e2e.sh b/scripts/protocol-e2e/run-protocol-e2e.sh new file mode 100755 index 0000000000..9afd9123b8 --- /dev/null +++ b/scripts/protocol-e2e/run-protocol-e2e.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# +# Multi-protocol end-to-end test runner. +# +# Builds and starts the Spring Boot test server, then builds the Codename One +# client (full stack: @RestClient / @GraphQLClient / @GrpcClient generated-style +# sources -> process-annotations -> runtime) and runs its cn1:test suite, which +# performs real REST / GraphQL / gRPC round-trips against the server. +# +# Prerequisites (handled by the CI workflow before calling this script): +# - codenameone-core / codenameone-javase / codenameone-maven-plugin built +# from this checkout and installed into the local Maven repo. +# - JAVA_HOME pointing at a JDK 17 (required by Spring Boot 3 and the client). +# +# The client runs the Codename One simulator, which needs a display; this +# script uses xvfb-run when available (Linux CI) and runs directly otherwise +# (e.g. a developer machine with a real display). +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +SERVER_DIR="$HERE/server" +CLIENT_DIR="$HERE/client" +MVN="${MVN:-mvn}" +SERVER_LOG="${SERVER_LOG:-/tmp/protocol-e2e-server.log}" +PORT="${E2E_PORT:-8080}" + +echo "[protocol-e2e] java: $(java -version 2>&1 | head -1)" + +echo "[protocol-e2e] Building server jar..." +"$MVN" -B -q -f "$SERVER_DIR/pom.xml" -DskipTests package + +echo "[protocol-e2e] Starting server on port $PORT..." +java -jar "$SERVER_DIR/target/protocol-e2e-server.jar" --server.port="$PORT" >"$SERVER_LOG" 2>&1 & +SERVER_PID=$! +cleanup() { + echo "[protocol-e2e] Stopping server (pid $SERVER_PID)..." + kill "$SERVER_PID" >/dev/null 2>&1 || true + wait "$SERVER_PID" 2>/dev/null || true +} +trap cleanup EXIT + +echo "[protocol-e2e] Waiting for server readiness..." +ready=0 +for _ in $(seq 1 90); do + if curl -fs -o /dev/null "http://localhost:$PORT/api/greeting?name=ping" 2>/dev/null; then + ready=1 + break + fi + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "[protocol-e2e] Server process exited early; log:" >&2 + cat "$SERVER_LOG" >&2 || true + exit 1 + fi + sleep 1 +done +if [ "$ready" != "1" ]; then + echo "[protocol-e2e] Server did not become ready in time; log:" >&2 + cat "$SERVER_LOG" >&2 || true + exit 1 +fi +echo "[protocol-e2e] Server is up." + +RUNNER="" +if command -v xvfb-run >/dev/null 2>&1; then + RUNNER="xvfb-run -a" + echo "[protocol-e2e] Using xvfb-run for the simulator." +fi + +echo "[protocol-e2e] Building + running the Codename One client (cn1:test)..." +set +e +$RUNNER "$MVN" -B -f "$CLIENT_DIR/pom.xml" install \ + -Dcodename1.platform=javase \ + -De2e.server.url="http://localhost:$PORT" +STATUS=$? +set -e + +if [ "$STATUS" -ne 0 ]; then + echo "[protocol-e2e] Client tests FAILED (status $STATUS). Server log tail:" >&2 + tail -n 50 "$SERVER_LOG" >&2 || true + exit "$STATUS" +fi + +echo "[protocol-e2e] All three protocols (REST / GraphQL / gRPC) verified end-to-end." diff --git a/scripts/protocol-e2e/server/pom.xml b/scripts/protocol-e2e/server/pom.xml new file mode 100644 index 0000000000..1b2488dec4 --- /dev/null +++ b/scripts/protocol-e2e/server/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + + + com.codename1.e2e + protocol-e2e-server + 1.0-SNAPSHOT + jar + + + org.springframework.boot + spring-boot-starter-parent + 3.2.10 + + + + + 17 + 17 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-graphql + + + + + protocol-e2e-server + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/E2eServerApplication.java b/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/E2eServerApplication.java new file mode 100644 index 0000000000..78ed065e87 --- /dev/null +++ b/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/E2eServerApplication.java @@ -0,0 +1,15 @@ +package com.example.e2eserver; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Multi-protocol end-to-end test server. Started by run-protocol-e2e.sh before + * the Codename One client app runs its protocol tests against it. + */ +@SpringBootApplication +public class E2eServerApplication { + public static void main(String[] args) { + SpringApplication.run(E2eServerApplication.class, args); + } +} diff --git a/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/GraphQlGreetingController.java b/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/GraphQlGreetingController.java new file mode 100644 index 0000000000..581571a6b1 --- /dev/null +++ b/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/GraphQlGreetingController.java @@ -0,0 +1,31 @@ +package com.example.e2eserver; + +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.MutationMapping; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.stereotype.Controller; + +/** + * GraphQL transport, backed by Spring for GraphQL. The CN1 @GraphQLClient + * generated from schema.graphqls posts queries/mutations to /graphql. + */ +@Controller +public class GraphQlGreetingController { + + public record Hero(String name, String greeting) { } + + public record Review(int stars, String commentary) { } + + public enum Episode { NEWHOPE, EMPIRE, JEDI } + + @QueryMapping + public Hero hero(@Argument String name) { + String n = (name == null || name.isEmpty()) ? "world" : name; + return new Hero(n, "Hello, " + n + "!"); + } + + @MutationMapping + public Review addReview(@Argument Episode episode, @Argument int stars) { + return new Review(stars, episode + " rated " + stars); + } +} diff --git a/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/GrpcWebGreetingController.java b/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/GrpcWebGreetingController.java new file mode 100644 index 0000000000..7a99cfce3d --- /dev/null +++ b/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/GrpcWebGreetingController.java @@ -0,0 +1,137 @@ +package com.example.e2eserver; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; + +/** + * Minimal gRPC-Web endpoint for the e2e test. The CN1 gRPC client + * (com.codename1.io.grpc.GrpcWeb) speaks application/grpc-web+proto, so rather + * than run a real gRPC server behind an Envoy bridge we decode the gRPC-Web + * framing and the (single-field) protobuf messages by hand. + * + * Request framing : [0x00][len BE32][HelloRequest protobuf] + * HelloRequest : field 1 (string name) + * Response framing : [0x00][len BE32][HelloReply protobuf] + * [0x80][len BE32]["grpc-status:0\r\n..."] (trailer frame) + * HelloReply : field 1 (string message) + * + * Mapped at /grpc/{service}/{method}; the CN1 client posts to + * /e2e.Greeter/SayHello with baseUrl = http://host:8080/grpc. + */ +@RestController +public class GrpcWebGreetingController { + + private static final String CONTENT_TYPE = "application/grpc-web+proto"; + + @PostMapping("/grpc/e2e.Greeter/SayHello") + public void sayHello(HttpServletRequest request, HttpServletResponse response) throws IOException { + byte[] body = readAll(request.getInputStream()); + String name = decodeName(body); + if (name == null || name.isEmpty()) { + name = "world"; + } + + byte[] reply = encodeStringField(1, "Hello, " + name + "!"); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + writeFrame(out, 0x00, reply); + writeFrame(out, 0x80, "grpc-status:0\r\ngrpc-message:\r\n".getBytes(StandardCharsets.UTF_8)); + + response.setStatus(200); + response.setContentType(CONTENT_TYPE); + byte[] payload = out.toByteArray(); + response.setContentLength(payload.length); + OutputStream os = response.getOutputStream(); + os.write(payload); + os.flush(); + } + + /** Reads the gRPC-Web data frame and extracts protobuf field 1 (string). */ + private static String decodeName(byte[] body) { + if (body == null || body.length < 5) { + return null; + } + int len = ((body[1] & 0xFF) << 24) | ((body[2] & 0xFF) << 16) + | ((body[3] & 0xFF) << 8) | (body[4] & 0xFF); + int pos = 5; + int end = Math.min(body.length, pos + len); + while (pos < end) { + int tag = body[pos++] & 0xFF; + int field = tag >>> 3; + int wire = tag & 0x7; + if (wire == 2) { // length-delimited + int[] r = readVarint(body, pos); + int fieldLen = r[0]; + pos = r[1]; + if (field == 1) { + return new String(body, pos, fieldLen, StandardCharsets.UTF_8); + } + pos += fieldLen; + } else if (wire == 0) { // varint + pos = readVarint(body, pos)[1]; + } else { + break; + } + } + return null; + } + + private static int[] readVarint(byte[] b, int pos) { + int value = 0; + int shift = 0; + while (pos < b.length) { + int x = b[pos++] & 0xFF; + value |= (x & 0x7F) << shift; + if ((x & 0x80) == 0) { + break; + } + shift += 7; + } + return new int[] { value, pos }; + } + + /** Encodes a protobuf message with one length-delimited string field. */ + private static byte[] encodeStringField(int field, String value) { + byte[] utf8 = value.getBytes(StandardCharsets.UTF_8); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write((field << 3) | 2); // tag: field, wire type LEN + writeVarint(out, utf8.length); + out.write(utf8, 0, utf8.length); + return out.toByteArray(); + } + + private static void writeVarint(ByteArrayOutputStream out, int value) { + int v = value; + while ((v & ~0x7F) != 0) { + out.write((v & 0x7F) | 0x80); + v >>>= 7; + } + out.write(v); + } + + private static void writeFrame(ByteArrayOutputStream out, int flag, byte[] payload) { + out.write(flag); + out.write((payload.length >>> 24) & 0xFF); + out.write((payload.length >>> 16) & 0xFF); + out.write((payload.length >>> 8) & 0xFF); + out.write(payload.length & 0xFF); + out.write(payload, 0, payload.length); + } + + private static byte[] readAll(InputStream in) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = in.read(buf)) >= 0) { + out.write(buf, 0, n); + } + return out.toByteArray(); + } +} diff --git a/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/RestGreetingController.java b/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/RestGreetingController.java new file mode 100644 index 0000000000..fc9d7566cd --- /dev/null +++ b/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/RestGreetingController.java @@ -0,0 +1,40 @@ +package com.example.e2eserver; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * REST transport, described by openapi.json on the client side. The CN1 + * @RestClient generated from that spec calls these endpoints. + */ +@RestController +public class RestGreetingController { + + @GetMapping("/api/greeting") + public Map greeting(@RequestParam(name = "name", defaultValue = "world") String name) { + Map m = new LinkedHashMap<>(); + m.put("name", name); + m.put("message", "Hello, " + name + "!"); + m.put("transport", "rest"); + return m; + } + + /** Echoes the Authorization header back so the client can assert bearer-token plumbing. */ + @PostMapping("/api/echo") + public Map echo(@RequestBody Map body, + @RequestHeader(name = "Authorization", required = false) String auth) { + Map m = new LinkedHashMap<>(); + m.put("name", String.valueOf(body.get("name"))); + m.put("message", "echo:" + body.get("name")); + m.put("transport", "rest"); + m.put("authorization", auth); + return m; + } +} diff --git a/scripts/protocol-e2e/server/src/main/resources/application.properties b/scripts/protocol-e2e/server/src/main/resources/application.properties new file mode 100644 index 0000000000..e562addb0e --- /dev/null +++ b/scripts/protocol-e2e/server/src/main/resources/application.properties @@ -0,0 +1,6 @@ +# Multi-protocol e2e test server. +server.port=8080 +# Expose the GraphQL HTTP endpoint at /graphql (default, listed for clarity). +spring.graphql.path=/graphql +# Fail fast if the schema and resolvers disagree. +spring.graphql.schema.printer.enabled=false diff --git a/scripts/protocol-e2e/server/src/main/resources/graphql/schema.graphqls b/scripts/protocol-e2e/server/src/main/resources/graphql/schema.graphqls new file mode 100644 index 0000000000..7271ee81b2 --- /dev/null +++ b/scripts/protocol-e2e/server/src/main/resources/graphql/schema.graphqls @@ -0,0 +1,23 @@ +type Query { + hero(name: String): Hero +} + +type Mutation { + addReview(episode: Episode!, stars: Int!): Review +} + +type Hero { + name: String! + greeting: String! +} + +type Review { + stars: Int! + commentary: String +} + +enum Episode { + NEWHOPE + EMPIRE + JEDI +} diff --git a/scripts/protocol-e2e/specs/greeter.proto b/scripts/protocol-e2e/specs/greeter.proto new file mode 100644 index 0000000000..35b54e0f24 --- /dev/null +++ b/scripts/protocol-e2e/specs/greeter.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +package e2e; + +// Contract for the gRPC-Web transport. The Codename One client classes under +// client/common/src/main/java/com/codename1/e2e/grpc mirror what +// `cn1:generate-grpc -Dcn1.grpc.proto=greeter.proto +// -Dcn1.grpc.basePackage=com.codename1.e2e.grpc` produces from this file. +message HelloRequest { + string name = 1; +} + +message HelloReply { + string message = 1; +} + +service Greeter { + rpc SayHello (HelloRequest) returns (HelloReply); +} diff --git a/scripts/protocol-e2e/specs/openapi.json b/scripts/protocol-e2e/specs/openapi.json new file mode 100644 index 0000000000..f8505cd0ca --- /dev/null +++ b/scripts/protocol-e2e/specs/openapi.json @@ -0,0 +1,37 @@ +{ + "openapi": "3.0.0", + "info": { "title": "Protocol E2E Greeting API", "version": "1.0.0" }, + "paths": { + "/api/greeting": { + "get": { + "tags": ["Greeting"], + "operationId": "greeting", + "parameters": [ + { "name": "name", "in": "query", "required": false, "schema": { "type": "string" } } + ], + "responses": { + "200": { + "description": "A greeting", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Greeting" } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Greeting": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "message": { "type": "string" }, + "transport": { "type": "string" } + } + } + } + } +} diff --git a/scripts/protocol-e2e/specs/schema.graphqls b/scripts/protocol-e2e/specs/schema.graphqls new file mode 100644 index 0000000000..7271ee81b2 --- /dev/null +++ b/scripts/protocol-e2e/specs/schema.graphqls @@ -0,0 +1,23 @@ +type Query { + hero(name: String): Hero +} + +type Mutation { + addReview(episode: Episode!, stars: Int!): Review +} + +type Hero { + name: String! + greeting: String! +} + +type Review { + stars: Int! + commentary: String +} + +enum Episode { + NEWHOPE + EMPIRE + JEDI +} From a83b1546b42cac704de8be9536e42db37fbdd2a9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:46:58 +0300 Subject: [PATCH 4/9] docs(developer-guide): satisfy Vale style gate (contraction + auto- hyphen) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/developer-guide/appendix_goal_generate_graphql.adoc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/developer-guide/appendix_goal_generate_graphql.adoc b/docs/developer-guide/appendix_goal_generate_graphql.adoc index 3d8a8575ea..e4591a2368 100644 --- a/docs/developer-guide/appendix_goal_generate_graphql.adoc +++ b/docs/developer-guide/appendix_goal_generate_graphql.adoc @@ -73,7 +73,7 @@ files (only missing files are written). ==== Generation modes -Unlike OpenAPI paths or gRPC service methods, a GraphQL schema does not +Unlike OpenAPI paths or gRPC service methods, a GraphQL schema doesn't enumerate the operations a client wants -- the client chooses which fields to select. The goal therefore supports two modes: @@ -85,8 +85,8 @@ nested generated types). Referenced fragment definitions are inlined into the request document automatically. * **Schema-only quick-start mode**: omit `cn1.graphql.operations`. For each field of the root `Query` / `Mutation` / `Subscription` type the -generator emits one method whose selection set is auto-expanded to -`cn1.graphql.maxDepth` levels, stopping at recursive types. This is a +generator emits one method whose selection set is automatically expanded +to `cn1.graphql.maxDepth` levels, stopping at recursive types. This is a convenience for getting started; it may over- or under-fetch, so prefer operations mode for production code. From 125b80918d9d27c309194e938b6bde3def73e15a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:41:35 +0300 Subject: [PATCH 5/9] docs(developer-guide): replace 'inlined' to satisfy LanguageTool spelling gate Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/developer-guide/appendix_goal_generate_graphql.adoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/developer-guide/appendix_goal_generate_graphql.adoc b/docs/developer-guide/appendix_goal_generate_graphql.adoc index e4591a2368..450ea174ac 100644 --- a/docs/developer-guide/appendix_goal_generate_graphql.adoc +++ b/docs/developer-guide/appendix_goal_generate_graphql.adoc @@ -81,7 +81,7 @@ fields to select. The goal therefore supports two modes: For each named `query` / `mutation` / `subscription` the generator emits one interface method plus a precise `@Mapped` response-type tree that mirrors exactly that operation's selection set (nested selections become -nested generated types). Referenced fragment definitions are inlined +nested generated types). Referenced fragment definitions are merged into the request document automatically. * **Schema-only quick-start mode**: omit `cn1.graphql.operations`. For each field of the root `Query` / `Mutation` / `Subscription` type the @@ -209,7 +209,7 @@ fields, input fields, and method variables); unknown enum names decode to `null` rather than throwing. * Input object types map to generated `@Mapped` classes; variables of scalar, enum, input-object, and list types are supported. -* Named and inline fragments are resolved against the schema and inlined +* Named and inline fragments are resolved against the schema and merged into the generated response types and the request document. * Built-in scalars (`Int`, `Float`, `Boolean`, `String`, `ID`) map to their boxed Java types. Custom scalars fall back to `String`. From 35dd2c68d8e93ff01d338c62610457cd7372d45e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 2 Jun 2026 05:08:53 +0300 Subject: [PATCH 6/9] feat(openapi): generate Java enums + expand e2e to build-time codegen & richer API OpenAPI enum support: cn1:generate-openapi now emits a Java enum per string `enum` schema (bound via the JSON mapper's name()), so model properties typed by an enum $ref get the generated enum; enums whose values are not valid Java identifiers degrade to String. gRPC already supported enums (@ProtoEnum); documented OpenAPI enums in the developer guide. GraphQL codegen fix: emit @Query/@Mutation/@Subscription with a NAMED value (`value = "..."`) when operationName is also present -- a positional value alongside another element does not compile. Caught by the expanded e2e (which, unlike the mojo unit tests, compiles the generated sources); also asserted now in GenerateGraphQLMojoTest. Expanded the protocol-e2e test: the CN1 client is now GENERATED at build time (cn1:generate-openapi/-grpc/-graphql from the bundled specs into target/generated-sources/cn1), so codegen regressions break the test. The catalog API exercises enums (in response fields, path params, variables, and request fields), nested objects, list/repeated fields, multiple methods, and varied scalars across all three protocols. Validated end-to-end locally. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../appendix_goal_generate_openapi.adoc | 7 + .../codename1/maven/GenerateGraphQLMojo.java | 13 +- .../codename1/maven/GenerateOpenApiMojo.java | 103 +++++++++- .../maven/GenerateGraphQLMojoTest.java | 9 +- .../maven/GenerateOpenApiMojoTest.java | 45 ++++ scripts/protocol-e2e/README.md | 61 ++++-- .../client/common/cn1specs/catalog.proto | 39 ++++ .../client/common/cn1specs/openapi.json | 84 ++++++++ .../client/common/cn1specs/operations.graphql | 31 +++ .../client/common/cn1specs/schema.graphqls | 29 +++ scripts/protocol-e2e/client/common/pom.xml | 51 +++++ .../codename1/e2e/graphql/AddReviewData.java | 13 -- .../e2e/graphql/AddReviewData_AddReview.java | 15 -- .../com/codename1/e2e/graphql/Episode.java | 8 - .../com/codename1/e2e/graphql/HeroData.java | 13 -- .../codename1/e2e/graphql/HeroData_Hero.java | 15 -- .../codename1/e2e/graphql/StarWarsApi.java | 29 --- .../com/codename1/e2e/grpc/GreeterGrpc.java | 22 -- .../com/codename1/e2e/grpc/HelloReply.java | 13 -- .../com/codename1/e2e/grpc/HelloRequest.java | 13 -- .../java/com/codename1/e2e/rest/Greeting.java | 17 -- .../com/codename1/e2e/rest/GreetingApi.java | 23 --- .../codename1/e2e/GraphQlProtocolTest.java | 107 ++++++---- .../com/codename1/e2e/GrpcProtocolTest.java | 62 ++++-- .../com/codename1/e2e/RestProtocolTest.java | 80 ++++++-- scripts/protocol-e2e/run-protocol-e2e.sh | 2 +- .../java/com/example/e2eserver/Catalog.java | 55 +++++ .../e2eserver/GraphQlCatalogController.java | 27 +++ .../e2eserver/GraphQlGreetingController.java | 31 --- .../e2eserver/GrpcWebCatalogController.java | 194 ++++++++++++++++++ .../e2eserver/GrpcWebGreetingController.java | 137 ------------- .../e2eserver/RestCatalogController.java | 27 +++ .../e2eserver/RestGreetingController.java | 40 ---- .../main/resources/graphql/schema.graphqls | 32 +-- scripts/protocol-e2e/specs/catalog.proto | 39 ++++ scripts/protocol-e2e/specs/greeter.proto | 19 -- scripts/protocol-e2e/specs/openapi.json | 67 +++++- scripts/protocol-e2e/specs/operations.graphql | 31 +++ scripts/protocol-e2e/specs/schema.graphqls | 32 +-- 39 files changed, 1076 insertions(+), 559 deletions(-) create mode 100644 scripts/protocol-e2e/client/common/cn1specs/catalog.proto create mode 100644 scripts/protocol-e2e/client/common/cn1specs/openapi.json create mode 100644 scripts/protocol-e2e/client/common/cn1specs/operations.graphql create mode 100644 scripts/protocol-e2e/client/common/cn1specs/schema.graphqls delete mode 100644 scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/AddReviewData.java delete mode 100644 scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/AddReviewData_AddReview.java delete mode 100644 scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/Episode.java delete mode 100644 scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/HeroData.java delete mode 100644 scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/HeroData_Hero.java delete mode 100644 scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/StarWarsApi.java delete mode 100644 scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/grpc/GreeterGrpc.java delete mode 100644 scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/grpc/HelloReply.java delete mode 100644 scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/grpc/HelloRequest.java delete mode 100644 scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/rest/Greeting.java delete mode 100644 scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/rest/GreetingApi.java create mode 100644 scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/Catalog.java create mode 100644 scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/GraphQlCatalogController.java delete mode 100644 scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/GraphQlGreetingController.java create mode 100644 scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/GrpcWebCatalogController.java delete mode 100644 scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/GrpcWebGreetingController.java create mode 100644 scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/RestCatalogController.java delete mode 100644 scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/RestGreetingController.java create mode 100644 scripts/protocol-e2e/specs/catalog.proto delete mode 100644 scripts/protocol-e2e/specs/greeter.proto create mode 100644 scripts/protocol-e2e/specs/operations.graphql diff --git a/docs/developer-guide/appendix_goal_generate_openapi.adoc b/docs/developer-guide/appendix_goal_generate_openapi.adoc index c210cf9bec..fc4f7e2e61 100644 --- a/docs/developer-guide/appendix_goal_generate_openapi.adoc +++ b/docs/developer-guide/appendix_goal_generate_openapi.adoc @@ -130,6 +130,13 @@ wiring in, mirroring the existing `cn1app.MapperBootstrap` pattern. * Response schemas: `$ref` resolution, primitives (`string` / `number` / `integer` / `boolean`), arrays, object schemas. `oneOf` / `anyOf` / `allOf` collapse to `Object` -- callers cast. +* String `enum` schemas are emitted as Java enums and bound by the JSON +mapper through each constant's `name()`, so a model property typed by an +enum `$ref` gets the generated enum type. This requires every enum value +to be a valid (non-reserved) Java identifier; an enum whose values are +not -- for example `"two-day"` -- degrades to `String` rather than +generating an enum that could not round-trip. Integer/number enums keep +their numeric type. * Schema unification: two `components.schemas` entries with identical property shapes collapse to a single record/class to avoid an explosion of duplicates. diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateGraphQLMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateGraphQLMojo.java index 90488dc073..60e2049bfb 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateGraphQLMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateGraphQLMojo.java @@ -624,9 +624,16 @@ private String buildMethod(String kind, String methodName, String document, StringBuilder sb = new StringBuilder(512); String anno = subscription ? "Subscription" : (GraphQLOperationModel.OP_MUTATION.equals(kind) ? "Mutation" : "Query"); - sb.append(" @").append(anno).append("(\"").append(escapeJava(document)).append('"'); - if (operationName != null && operationName.length() > 0) { - sb.append(", operationName = \"").append(escapeJava(operationName)).append('"'); + // When operationName is also present the document must be named + // (`value = "..."`) -- a positional value is only legal when it is + // the sole annotation element. + boolean hasOpName = operationName != null && operationName.length() > 0; + sb.append(" @").append(anno).append('('); + if (hasOpName) { + sb.append("value = \"").append(escapeJava(document)) + .append("\", operationName = \"").append(escapeJava(operationName)).append('"'); + } else { + sb.append('"').append(escapeJava(document)).append('"'); } sb.append(")\n"); String ret = subscription ? "GraphQLSubscription" : "void"; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateOpenApiMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateOpenApiMojo.java index c8ccd836aa..f8c7096f18 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateOpenApiMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateOpenApiMojo.java @@ -267,12 +267,22 @@ static final class Generator { } void run() throws IOException { - // Build per-schema info up front so the unification map is ready - // before any operation references a schema by name. + // Pass 1: classify `enum` schemas first so property/parameter type + // resolution can tell an enum `$ref` from an object `$ref`. for (Map.Entry e : schemas.entrySet()) { if (!(e.getValue() instanceof Map)) continue; @SuppressWarnings("unchecked") Map schema = (Map) e.getValue(); + if (isEnumSchema(schema)) { + schemaByName.put(e.getKey(), buildEnumInfo(e.getKey(), schema)); + } + } + // Pass 2: object schemas, now able to resolve enum references. + for (Map.Entry e : schemas.entrySet()) { + if (!(e.getValue() instanceof Map)) continue; + @SuppressWarnings("unchecked") + Map schema = (Map) e.getValue(); + if (isEnumSchema(schema)) continue; SchemaInfo info = buildSchemaInfo(e.getKey(), schema); if (info == null) continue; schemaByName.put(e.getKey(), info); @@ -313,10 +323,19 @@ void run() throws IOException { ensureDir(modelDir); // Iterate canonical schemas only (unification dropped duplicates). Set emittedCanonical = new HashSet(); + int enumCount = 0; for (SchemaInfo info : schemaByName.values()) { if (!info.isCanonical) continue; if (!emittedCanonical.add(info.javaName)) continue; - emitModel(modelDir, info); + if (info.isEnum) { + if (info.enumGeneratable) { + emitEnum(modelDir, info); + enumCount++; + } + // Non-generatable enums degrade to String -- nothing to emit. + } else { + emitModel(modelDir, info); + } } // Emit @RestClient interfaces -- one per tag. @@ -326,7 +345,8 @@ void run() throws IOException { emitApi(apiDir, e.getKey(), e.getValue()); } - log.info("Generated " + emittedCanonical.size() + " model(s) and " + log.info("Generated " + (emittedCanonical.size() - enumCount) + " model(s), " + + enumCount + " enum(s), and " + opsByTag.size() + " @RestClient interface(s) under " + outputDir); } @@ -375,6 +395,9 @@ private void unifyShapes() { } private static String shapeOf(SchemaInfo s) { + if (s.isEnum) { + return "enum:" + s.enumGeneratable + ":" + s.enumValues; + } StringBuilder sb = new StringBuilder(); for (PropInfo p : s.props) { sb.append(p.specName).append(':').append(p.javaType).append(';'); @@ -382,6 +405,46 @@ private static String shapeOf(SchemaInfo s) { return sb.toString(); } + /// `true` when the schema is a string enumeration (an `enum` array on a + /// `string`-typed -- or untyped -- schema). Integer/number enums keep + /// their numeric Java type and are not turned into Java enums. + private static boolean isEnumSchema(Map schema) { + Object e = schema.get("enum"); + if (!(e instanceof List) || ((List) e).isEmpty()) return false; + Object type = schema.get("type"); + return type == null || "string".equals(type); + } + + private SchemaInfo buildEnumInfo(String name, Map schema) { + SchemaInfo s = new SchemaInfo(); + s.specName = name; + s.javaName = sanitizeClassName(name); + s.isCanonical = true; + s.isEnum = true; + for (Object v : (List) schema.get("enum")) { + if (v != null) s.enumValues.add(String.valueOf(v)); + } + s.enumGeneratable = enumValuesAreJavaConstants(s.enumValues); + return s; + } + + /// An enum can be emitted as a Java enum only when each value is already + /// a valid (non-reserved) Java identifier, because the JSON mapper binds + /// enum constants by their `name()` -- the constant must equal the wire + /// value verbatim. Otherwise the field degrades to `String`. + private static boolean enumValuesAreJavaConstants(List values) { + if (values.isEmpty()) return false; + for (String v : values) { + if (v.length() == 0) return false; + if (!Character.isJavaIdentifierStart(v.charAt(0))) return false; + for (int i = 1; i < v.length(); i++) { + if (!Character.isJavaIdentifierPart(v.charAt(i))) return false; + } + if (isJavaReservedWord(v)) return false; + } + return true; + } + // ---------------------------------------------------------------- // Operation parsing // ---------------------------------------------------------------- @@ -524,8 +587,13 @@ String schemaToJavaType(Object schemaObj) { int slash = r.lastIndexOf('/'); if (slash >= 0 && r.startsWith("#/components/schemas/")) { String specName = r.substring(slash + 1); + SchemaInfo target = schemaByName.get(specName); + if (target != null && target.isEnum && !target.enumGeneratable) { + return "String"; + } String alias = nameAliases.get(specName); - String name = alias != null ? alias : sanitizeClassName(specName); + String name = alias != null ? alias + : (target != null ? target.javaName : sanitizeClassName(specName)); return modelPackage + "." + name; } return "Object"; @@ -569,6 +637,24 @@ private String primaryTag(Map op) { // Source emit // ---------------------------------------------------------------- + private void emitEnum(File dir, SchemaInfo info) throws IOException { + File f = new File(dir, info.javaName + ".java"); + if (f.exists() && !overwrite) { + log.debug("skip existing " + f); + return; + } + StringBuilder sb = new StringBuilder(512); + sb.append("// Generated by cn1:generate-openapi.\n"); + sb.append("package ").append(modelPackage).append(";\n\n"); + sb.append("public enum ").append(info.javaName).append(" {\n"); + for (int i = 0; i < info.enumValues.size(); i++) { + sb.append(" ").append(info.enumValues.get(i)); + sb.append(i == info.enumValues.size() - 1 ? "\n" : ",\n"); + } + sb.append("}\n"); + writeFile(f, sb.toString()); + } + private void emitModel(File dir, SchemaInfo info) throws IOException { File f = new File(dir, info.javaName + ".java"); if (f.exists() && !overwrite) { @@ -827,6 +913,13 @@ static final class SchemaInfo { String javaName; // sanitized Java identifier (post-unification) boolean isCanonical; // false when this entry is aliased to another final List props = new ArrayList(); + /// `true` when this schema is a string `enum`. + boolean isEnum; + /// `true` when every enum value is a valid Java constant name, so it + /// can be emitted as a Java enum and bound via `name()`. Otherwise the + /// enum degrades to `String`. + boolean enumGeneratable; + final List enumValues = new ArrayList(); } static final class PropInfo { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/GenerateGraphQLMojoTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/GenerateGraphQLMojoTest.java index d8972806a0..026ba4847b 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/GenerateGraphQLMojoTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/GenerateGraphQLMojoTest.java @@ -95,9 +95,14 @@ void operationsModeEmitsRecordsAndInterface(@TempDir Path tmp) throws Exception String api = read(out, "com/example/sw/StarWarsApi.java"); assertTrue(api.contains("@GraphQLClient(\"https://api/graphql\")"), "client anno; was:\n" + api); - assertTrue(api.contains("query HeroName($episode: Episode) { hero(episode: $episode) " + // With an operationName present, the document must be a NAMED value + // (`value = "..."`); a positional value alongside operationName does + // not compile. + assertTrue(api.contains("@Query(value = \"query HeroName($episode: Episode) { hero(episode: $episode) " + "{ ...HeroFields friends { name } } }"), - "minified operation document embedded; was:\n" + api); + "minified operation document embedded as a named value; was:\n" + api); + assertTrue(api.contains("operationName = \"HeroName\""), + "operationName emitted; was:\n" + api); assertTrue(api.contains("fragment HeroFields on Character { name }"), "referenced fragment appended to document; was:\n" + api); assertTrue(api.contains("@Var(\"episode\") Episode episode"), "typed enum variable; was:\n" + api); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/GenerateOpenApiMojoTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/GenerateOpenApiMojoTest.java index f1a92cba6c..9213073b6f 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/GenerateOpenApiMojoTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/GenerateOpenApiMojoTest.java @@ -117,6 +117,51 @@ void emitsRecordsAndRestClientInterface(@TempDir Path tmp) throws Exception { "of(...) factory should delegate to RestClients.create"); } + private static final String ENUM_SPEC = + "{" + + "\"openapi\":\"3.0.0\"," + + "\"info\":{\"title\":\"Enums\",\"version\":\"1.0\"}," + + "\"paths\":{}," + + "\"components\":{\"schemas\":{" + + " \"Status\":{\"type\":\"string\",\"enum\":[\"available\",\"pending\",\"sold\"]}," + + " \"Shipping\":{\"type\":\"string\",\"enum\":[\"next-day\",\"two-day\"]}," + + " \"Pet\":{\"type\":\"object\",\"properties\":{" + + " \"id\":{\"type\":\"integer\",\"format\":\"int64\"}," + + " \"status\":{\"$ref\":\"#/components/schemas/Status\"}," + + " \"shipping\":{\"$ref\":\"#/components/schemas/Shipping\"}" + + " }}" + + "}}}"; + + @Test + void emitsJavaEnumsForStringEnumSchemas(@TempDir Path tmp) throws Exception { + Map doc = parse(ENUM_SPEC); + File out = tmp.toFile(); + new GenerateOpenApiMojo.Generator( + doc, "com.example.petstore", out, true, /*emitRecords*/ true, + new SystemStreamLog()).run(); + + // Generatable enum -> Java enum whose constants equal the wire values. + File statusJava = new File(out, "com/example/petstore/model/Status.java"); + assertTrue(statusJava.exists(), "expected Status.java enum"); + String statusSrc = readString(statusJava); + assertTrue(statusSrc.contains("public enum Status"), "Status should be a Java enum; was:\n" + statusSrc); + assertTrue(statusSrc.contains("available") && statusSrc.contains("pending") + && statusSrc.contains("sold"), "Status values; was:\n" + statusSrc); + assertFalse(statusSrc.contains("@Mapped"), "a plain enum is not @Mapped"); + + // The owning model references the enum type for the status property... + String petSrc = readString(new File(out, "com/example/petstore/model/Pet.java")); + assertTrue(petSrc.contains("com.example.petstore.model.Status status"), + "Pet.status should be typed as the generated enum; was:\n" + petSrc); + + // ...but the hyphenated enum can't be a Java constant, so it degrades + // to String and no enum file is emitted. + assertFalse(new File(out, "com/example/petstore/model/Shipping.java").exists(), + "non-identifier enum values should degrade to String (no Shipping.java)"); + assertTrue(petSrc.contains("String shipping"), + "Pet.shipping should degrade to String; was:\n" + petSrc); + } + @Test void emitsClassesOnJava8Target(@TempDir Path tmp) throws Exception { Map doc = parse(SAMPLE_SPEC); diff --git a/scripts/protocol-e2e/README.md b/scripts/protocol-e2e/README.md index 800129b040..1fb34b39f5 100644 --- a/scripts/protocol-e2e/README.md +++ b/scripts/protocol-e2e/README.md @@ -1,32 +1,49 @@ # Multi-protocol end-to-end test (REST / GraphQL / gRPC) -This test exercises the full Codename One generated-client stack for all three -"spec → typed client" protocols against a single real server. +This test exercises the full Codename One generated-client stack -- including +the build-time **code generation** -- for all three "spec to typed client" +protocols against a single real server, over a non-trivial catalog API +(enums, nested objects, repeated/list fields, and multiple methods). ## Layout -- `server/` — a Spring Boot app exposing the same logical "greeting" service - three ways: - - **REST** (`GET /api/greeting`, `POST /api/echo`) — JSON, described by - [`specs/openapi.json`](specs/openapi.json). - - **GraphQL** (`POST /graphql`) — Spring for GraphQL, schema - [`specs/schema.graphqls`](specs/schema.graphqls). - - **gRPC-Web** (`POST /grpc/e2e.Greeter/SayHello`) — the gRPC-Web binary - framing the CN1 client speaks (`application/grpc-web+proto`) is implemented - directly, so no Envoy/proxy sidecar is needed. Contract: - [`specs/greeter.proto`](specs/greeter.proto). -- `client/` — a Codename One app (`common` + `javase`). Its - `@RestClient` / `@GraphQLClient` / `@GrpcClient` sources under - `client/common/src/main/java/com/codename1/e2e/` mirror what - `cn1:generate-openapi` / `cn1:generate-graphql` / `cn1:generate-grpc` produce - from the specs; `cn1:process-annotations` generates the impls and the - `cn1app.*Bootstrap` registrars at build time. The `AbstractTest` classes - under `client/common/src/test/java/com/codename1/e2e/` perform real - round-trips against the running server and are executed on the JavaSE - simulator via `cn1:test`. -- `run-protocol-e2e.sh` — builds and starts the server, then builds and runs +- `specs/` -- the canonical contracts: [`openapi.json`](specs/openapi.json), + [`schema.graphqls`](specs/schema.graphqls) + + [`operations.graphql`](specs/operations.graphql), and + [`catalog.proto`](specs/catalog.proto). +- `server/` -- a Spring Boot app exposing the same catalog three ways: + - **REST** (`GET /api/products`, `/api/products/{id}`, + `/api/products/category/{category}` with an enum path parameter). + - **GraphQL** (`POST /graphql`, Spring for GraphQL) -- list/single queries + and an enum-typed variable. + - **gRPC-Web** (`POST /grpc/e2e.Catalog/{GetProduct,ListProducts}`) -- the + gRPC-Web binary framing and protobuf wire format are implemented directly, + so no Envoy/proxy sidecar is required. +- `client/` -- a Codename One app (`common` + `javase`). The `common` module + runs `cn1:generate-openapi`, `cn1:generate-grpc`, and `cn1:generate-graphql` + at **build time** (from the specs under `client/common/cn1specs/`) into + `target/generated-sources/cn1`; `cn1:process-annotations` then generates the + client impls and the `cn1app.*Bootstrap` registrars. The `AbstractTest` + classes under `client/common/src/test/java/com/codename1/e2e/` perform real + round-trips against the running server and run on the JavaSE simulator via + `cn1:test`. +- `run-protocol-e2e.sh` -- builds and starts the server, then builds and runs the client tests against it. +## What it covers + +| Nuance | REST | GraphQL | gRPC | +|--------|------|---------|------| +| Enum field in a response | yes | yes | yes | +| Enum as a parameter/variable | path param | variable | request field | +| Nested object | `Dimensions` | nested selection | -- | +| List / repeated field | `tags` | `tags` | `tags`, `repeated Product` | +| Multiple methods | 3 | 3 | 2 | +| Varied scalars | long, double, string | ID, Float, String | int64, double, string | + +Because the clients are generated during the build, a regression in +`cn1:generate-openapi` / `-grpc` / `-graphql` breaks this test. + ## Running locally ```bash diff --git a/scripts/protocol-e2e/client/common/cn1specs/catalog.proto b/scripts/protocol-e2e/client/common/cn1specs/catalog.proto new file mode 100644 index 0000000000..c612dd753a --- /dev/null +++ b/scripts/protocol-e2e/client/common/cn1specs/catalog.proto @@ -0,0 +1,39 @@ +syntax = "proto3"; + +package e2e; + +// Contract for the gRPC-Web transport. Covers an enum field, a repeated +// scalar field, nested/repeated messages, and multiple rpc methods so the +// generated CN1 protobuf codecs are genuinely exercised. + +enum Category { + CATEGORY_UNSPECIFIED = 0; + BOOKS = 1; + ELECTRONICS = 2; + TOYS = 3; +} + +message Product { + int64 id = 1; + string name = 2; + Category category = 3; + repeated string tags = 4; + double rating = 5; +} + +message GetProductRequest { + int64 id = 1; +} + +message ListProductsRequest { + Category category = 1; +} + +message ProductList { + repeated Product products = 1; +} + +service Catalog { + rpc GetProduct (GetProductRequest) returns (Product); + rpc ListProducts (ListProductsRequest) returns (ProductList); +} diff --git a/scripts/protocol-e2e/client/common/cn1specs/openapi.json b/scripts/protocol-e2e/client/common/cn1specs/openapi.json new file mode 100644 index 0000000000..dcc4f06e3d --- /dev/null +++ b/scripts/protocol-e2e/client/common/cn1specs/openapi.json @@ -0,0 +1,84 @@ +{ + "openapi": "3.0.0", + "info": { "title": "Protocol E2E Catalog API", "version": "1.0.0" }, + "paths": { + "/api/products": { + "get": { + "tags": ["Catalog"], + "operationId": "listProducts", + "responses": { + "200": { + "description": "All products", + "content": { + "application/json": { + "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Product" } } + } + } + } + } + } + }, + "/api/products/{id}": { + "get": { + "tags": ["Catalog"], + "operationId": "getProduct", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "integer", "format": "int64" } } + ], + "responses": { + "200": { + "description": "A product", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/Product" } } + } + } + } + } + }, + "/api/products/category/{category}": { + "get": { + "tags": ["Catalog"], + "operationId": "getProductsByCategory", + "parameters": [ + { "name": "category", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/Category" } } + ], + "responses": { + "200": { + "description": "Products in a category", + "content": { + "application/json": { + "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Product" } } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Category": { + "type": "string", + "enum": ["BOOKS", "ELECTRONICS", "TOYS"] + }, + "Dimensions": { + "type": "object", + "properties": { + "width": { "type": "number", "format": "double" }, + "height": { "type": "number", "format": "double" } + } + }, + "Product": { + "type": "object", + "properties": { + "id": { "type": "integer", "format": "int64" }, + "name": { "type": "string" }, + "category": { "$ref": "#/components/schemas/Category" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "rating": { "type": "number", "format": "double" }, + "dimensions": { "$ref": "#/components/schemas/Dimensions" } + } + } + } + } +} diff --git a/scripts/protocol-e2e/client/common/cn1specs/operations.graphql b/scripts/protocol-e2e/client/common/cn1specs/operations.graphql new file mode 100644 index 0000000000..dcfbcc119c --- /dev/null +++ b/scripts/protocol-e2e/client/common/cn1specs/operations.graphql @@ -0,0 +1,31 @@ +# Operations the CN1 GraphQL client is generated from (operations mode). +# Exercises: a list query with nested object selection, a single-object query +# with an enum field, and an enum-typed variable. + +query Products { + products { + id + name + category + tags + rating + dimensions { width height } + } +} + +query Product($id: ID!) { + product(id: $id) { + id + name + category + rating + } +} + +query ProductsByCategory($category: Category!) { + productsByCategory(category: $category) { + id + name + category + } +} diff --git a/scripts/protocol-e2e/client/common/cn1specs/schema.graphqls b/scripts/protocol-e2e/client/common/cn1specs/schema.graphqls new file mode 100644 index 0000000000..a17fea1e4a --- /dev/null +++ b/scripts/protocol-e2e/client/common/cn1specs/schema.graphqls @@ -0,0 +1,29 @@ +schema { + query: Query +} + +type Query { + products: [Product!]! + product(id: ID!): Product + productsByCategory(category: Category!): [Product!]! +} + +type Product { + id: ID! + name: String! + category: Category! + tags: [String!]! + rating: Float! + dimensions: Dimensions +} + +type Dimensions { + width: Float! + height: Float! +} + +enum Category { + BOOKS + ELECTRONICS + TOYS +} diff --git a/scripts/protocol-e2e/client/common/pom.xml b/scripts/protocol-e2e/client/common/pom.xml index 1c29ae5375..0d6a5fdf71 100644 --- a/scripts/protocol-e2e/client/common/pom.xml +++ b/scripts/protocol-e2e/client/common/pom.xml @@ -85,6 +85,40 @@ com.codenameone codenameone-maven-plugin + + + generate-rest-client + generate-sources + generate-openapi + + ${project.basedir}/cn1specs/openapi.json + com.codename1.e2e.rest + ${project.build.directory}/generated-sources/cn1 + + + + generate-grpc-client + generate-sources + generate-grpc + + ${project.basedir}/cn1specs/catalog.proto + com.codename1.e2e.grpc + ${project.build.directory}/generated-sources/cn1 + + + + generate-graphql-client + generate-sources + generate-graphql + + ${project.basedir}/cn1specs/schema.graphqls + ${project.basedir}/cn1specs/operations.graphql + com.codename1.e2e.graphql + CatalogGraphApi + ${project.build.directory}/generated-sources/cn1 + + cn1-process-classes process-classes @@ -103,6 +137,23 @@ + + org.codehaus.mojo + build-helper-maven-plugin + 3.4.0 + + + add-generated-cn1-sources + generate-sources + add-source + + + ${project.build.directory}/generated-sources/cn1 + + + + + org.apache.maven.plugins maven-surefire-plugin diff --git a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/AddReviewData.java b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/AddReviewData.java deleted file mode 100644 index 6f4e25eac4..0000000000 --- a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/AddReviewData.java +++ /dev/null @@ -1,13 +0,0 @@ -// Mirrors the output of cn1:generate-graphql (mutation response root). -package com.codename1.e2e.graphql; - -import com.codename1.annotations.JsonProperty; -import com.codename1.annotations.Mapped; - -@Mapped -public class AddReviewData { - @JsonProperty("addReview") - public AddReviewData_AddReview addReview; - - public AddReviewData() {} -} diff --git a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/AddReviewData_AddReview.java b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/AddReviewData_AddReview.java deleted file mode 100644 index 65214884ec..0000000000 --- a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/AddReviewData_AddReview.java +++ /dev/null @@ -1,15 +0,0 @@ -// Mirrors the output of cn1:generate-graphql (nested selection type). -package com.codename1.e2e.graphql; - -import com.codename1.annotations.JsonProperty; -import com.codename1.annotations.Mapped; - -@Mapped -public class AddReviewData_AddReview { - @JsonProperty("stars") - public Integer stars; - @JsonProperty("commentary") - public String commentary; - - public AddReviewData_AddReview() {} -} diff --git a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/Episode.java b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/Episode.java deleted file mode 100644 index 8e11eaf2f9..0000000000 --- a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/Episode.java +++ /dev/null @@ -1,8 +0,0 @@ -// Mirrors the output of cn1:generate-graphql for the e2e schema.graphqls. -package com.codename1.e2e.graphql; - -public enum Episode { - NEWHOPE, - EMPIRE, - JEDI -} diff --git a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/HeroData.java b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/HeroData.java deleted file mode 100644 index 0a502735fe..0000000000 --- a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/HeroData.java +++ /dev/null @@ -1,13 +0,0 @@ -// Mirrors the output of cn1:generate-graphql (query response root). -package com.codename1.e2e.graphql; - -import com.codename1.annotations.JsonProperty; -import com.codename1.annotations.Mapped; - -@Mapped -public class HeroData { - @JsonProperty("hero") - public HeroData_Hero hero; - - public HeroData() {} -} diff --git a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/HeroData_Hero.java b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/HeroData_Hero.java deleted file mode 100644 index 863a003a28..0000000000 --- a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/HeroData_Hero.java +++ /dev/null @@ -1,15 +0,0 @@ -// Mirrors the output of cn1:generate-graphql (nested selection type). -package com.codename1.e2e.graphql; - -import com.codename1.annotations.JsonProperty; -import com.codename1.annotations.Mapped; - -@Mapped -public class HeroData_Hero { - @JsonProperty("name") - public String name; - @JsonProperty("greeting") - public String greeting; - - public HeroData_Hero() {} -} diff --git a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/StarWarsApi.java b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/StarWarsApi.java deleted file mode 100644 index bab5f7a70f..0000000000 --- a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/graphql/StarWarsApi.java +++ /dev/null @@ -1,29 +0,0 @@ -// Mirrors the output of cn1:generate-graphql for the e2e schema + operations. -package com.codename1.e2e.graphql; - -import com.codename1.annotations.graphql.GraphQLClient; -import com.codename1.annotations.graphql.Mutation; -import com.codename1.annotations.graphql.Query; -import com.codename1.annotations.graphql.Var; -import com.codename1.annotations.rest.Header; -import com.codename1.io.graphql.GraphQLClients; -import com.codename1.io.graphql.GraphQLResponse; -import com.codename1.util.OnComplete; - -@GraphQLClient("http://localhost:8080/graphql") -public interface StarWarsApi { - - @Query("query Hero($name: String) { hero(name: $name) { name greeting } }") - void hero(@Var("name") String name, - @Header("Authorization") String bearerToken, - OnComplete> callback); - - @Mutation("mutation AddReview($episode: Episode!, $stars: Int!) { addReview(episode: $episode, stars: $stars) { stars commentary } }") - void addReview(@Var("episode") Episode episode, @Var("stars") int stars, - @Header("Authorization") String bearerToken, - OnComplete> callback); - - static StarWarsApi of(String endpoint) { - return GraphQLClients.create(StarWarsApi.class, endpoint); - } -} diff --git a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/grpc/GreeterGrpc.java b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/grpc/GreeterGrpc.java deleted file mode 100644 index e187d6ac77..0000000000 --- a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/grpc/GreeterGrpc.java +++ /dev/null @@ -1,22 +0,0 @@ -// Mirrors the output of cn1:generate-grpc for the e2e greeter.proto. -package com.codename1.e2e.grpc; - -import com.codename1.annotations.grpc.GrpcClient; -import com.codename1.annotations.grpc.Rpc; -import com.codename1.annotations.rest.Header; -import com.codename1.io.grpc.GrpcClients; -import com.codename1.io.grpc.GrpcResponse; -import com.codename1.util.OnComplete; - -@GrpcClient("e2e.Greeter") -public interface GreeterGrpc { - - @Rpc("SayHello") - void sayHello(HelloRequest request, - @Header("Authorization") String bearerToken, - OnComplete> callback); - - static GreeterGrpc of(String baseUrl) { - return GrpcClients.create(GreeterGrpc.class, baseUrl); - } -} diff --git a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/grpc/HelloReply.java b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/grpc/HelloReply.java deleted file mode 100644 index 5d32f4b5e3..0000000000 --- a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/grpc/HelloReply.java +++ /dev/null @@ -1,13 +0,0 @@ -// Mirrors the output of cn1:generate-grpc for the e2e greeter.proto. -package com.codename1.e2e.grpc; - -import com.codename1.annotations.grpc.ProtoField; -import com.codename1.annotations.grpc.ProtoMessage; - -@ProtoMessage -public class HelloReply { - @ProtoField(tag = 1) - public String message; - - public HelloReply() {} -} diff --git a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/grpc/HelloRequest.java b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/grpc/HelloRequest.java deleted file mode 100644 index 215fceb874..0000000000 --- a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/grpc/HelloRequest.java +++ /dev/null @@ -1,13 +0,0 @@ -// Mirrors the output of cn1:generate-grpc for the e2e greeter.proto. -package com.codename1.e2e.grpc; - -import com.codename1.annotations.grpc.ProtoField; -import com.codename1.annotations.grpc.ProtoMessage; - -@ProtoMessage -public class HelloRequest { - @ProtoField(tag = 1) - public String name; - - public HelloRequest() {} -} diff --git a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/rest/Greeting.java b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/rest/Greeting.java deleted file mode 100644 index 9fb86fe2d3..0000000000 --- a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/rest/Greeting.java +++ /dev/null @@ -1,17 +0,0 @@ -// Mirrors the output of cn1:generate-openapi for the e2e openapi.json. -package com.codename1.e2e.rest; - -import com.codename1.annotations.JsonProperty; -import com.codename1.annotations.Mapped; - -@Mapped -public class Greeting { - @JsonProperty("name") - public String name; - @JsonProperty("message") - public String message; - @JsonProperty("transport") - public String transport; - - public Greeting() {} -} diff --git a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/rest/GreetingApi.java b/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/rest/GreetingApi.java deleted file mode 100644 index 725bf3a65f..0000000000 --- a/scripts/protocol-e2e/client/common/src/main/java/com/codename1/e2e/rest/GreetingApi.java +++ /dev/null @@ -1,23 +0,0 @@ -// Mirrors the output of cn1:generate-openapi for the e2e openapi.json. -package com.codename1.e2e.rest; - -import com.codename1.annotations.rest.GET; -import com.codename1.annotations.rest.Header; -import com.codename1.annotations.rest.Query; -import com.codename1.annotations.rest.RestClient; -import com.codename1.io.rest.Response; -import com.codename1.io.rest.RestClients; -import com.codename1.util.OnComplete; - -@RestClient -public interface GreetingApi { - - @GET("/api/greeting") - void greeting(@Query("name") String name, - @Header("Authorization") String bearerToken, - OnComplete> callback); - - static GreetingApi of(String baseUrl) { - return RestClients.create(GreetingApi.class, baseUrl); - } -} diff --git a/scripts/protocol-e2e/client/common/src/test/java/com/codename1/e2e/GraphQlProtocolTest.java b/scripts/protocol-e2e/client/common/src/test/java/com/codename1/e2e/GraphQlProtocolTest.java index 5a3c80bb2a..6c8e99da37 100644 --- a/scripts/protocol-e2e/client/common/src/test/java/com/codename1/e2e/GraphQlProtocolTest.java +++ b/scripts/protocol-e2e/client/common/src/test/java/com/codename1/e2e/GraphQlProtocolTest.java @@ -1,60 +1,83 @@ package com.codename1.e2e; -import com.codename1.e2e.graphql.AddReviewData; -import com.codename1.e2e.graphql.Episode; -import com.codename1.e2e.graphql.HeroData; -import com.codename1.e2e.graphql.StarWarsApi; +import com.codename1.e2e.graphql.Category; +import com.codename1.e2e.graphql.CatalogGraphApi; +import com.codename1.e2e.graphql.ProductData; +import com.codename1.e2e.graphql.ProductsByCategoryData; +import com.codename1.e2e.graphql.ProductsData; +import com.codename1.e2e.graphql.ProductsData_Products; import com.codename1.io.graphql.GraphQLResponse; import com.codename1.testing.AbstractTest; import com.codename1.util.OnComplete; -/** End-to-end GraphQL query + mutation round-trips against the test server. */ +/** + * End-to-end GraphQL round-trips against the catalog server: a list query with + * a nested object selection + enum + string list, a single-object query, and a + * query with an enum-typed variable. + */ public class GraphQlProtocolTest extends AbstractTest { @Override public boolean runTest() throws Exception { - StarWarsApi api = StarWarsApi.of(E2eSupport.baseUrl() + "/graphql"); - - // --- query --- - final HeroData[] hero = new HeroData[1]; - final boolean[] heroOk = { false }; - final boolean[] heroDone = { false }; - api.hero("Luke", null, new OnComplete>() { - public void completed(GraphQLResponse r) { - heroOk[0] = r.isOk(); - hero[0] = r.getData(); - heroDone[0] = true; - } + CatalogGraphApi api = CatalogGraphApi.of(E2eSupport.baseUrl() + "/graphql"); + + // --- list query: nested object + enum + string list --- + final ProductsData[] data = new ProductsData[1]; + final boolean[] ok1 = { false }; + final boolean[] d1 = { false }; + api.products(null, new OnComplete>() { + public void completed(GraphQLResponse r) { ok1[0] = r.isOk(); data[0] = r.getData(); d1[0] = true; } }); - E2eSupport.await(heroDone); - assertTrue(heroDone[0], "GraphQL query timed out"); - assertTrue(heroOk[0], "GraphQL query reported errors"); - assertNotNull(hero[0], "GraphQL data null"); - assertNotNull(hero[0].hero, "GraphQL hero null"); - assertEqual("Luke", hero[0].hero.name, "GraphQL hero.name"); - assertEqual("Hello, Luke!", hero[0].hero.greeting, "GraphQL hero.greeting"); - - // --- mutation (enum + int variables) --- - final AddReviewData[] review = new AddReviewData[1]; - final boolean[] reviewOk = { false }; - final boolean[] reviewDone = { false }; - api.addReview(Episode.JEDI, 5, null, new OnComplete>() { - public void completed(GraphQLResponse r) { - reviewOk[0] = r.isOk(); - review[0] = r.getData(); - reviewDone[0] = true; - } + E2eSupport.await(d1); + assertTrue(d1[0], "GraphQL products timed out"); + assertTrue(ok1[0], "GraphQL products reported errors"); + assertNotNull(data[0], "GraphQL products data null"); + assertEqual(4, data[0].products().size(), "GraphQL product count"); + ProductsData_Products first = byId(data[0], "1"); + assertNotNull(first, "GraphQL product id=1 missing"); + assertEqual("The Hobbit", first.name(), "GraphQL name"); + assertEqual(Category.BOOKS, first.category(), "GraphQL enum field"); + assertTrue(first.tags().contains("fantasy"), "GraphQL string-list field"); + assertNotNull(first.dimensions(), "GraphQL nested selection"); + assertRange(20.0, first.dimensions().height(), 0.001, "GraphQL nested field"); + + // --- single object query --- + final ProductData[] pd = new ProductData[1]; + final boolean[] d2 = { false }; + api.product("3", null, new OnComplete>() { + public void completed(GraphQLResponse r) { pd[0] = r.getData(); d2[0] = true; } }); - E2eSupport.await(reviewDone); - assertTrue(reviewDone[0], "GraphQL mutation timed out"); - assertTrue(reviewOk[0], "GraphQL mutation reported errors"); - assertNotNull(review[0], "GraphQL mutation data null"); - assertNotNull(review[0].addReview, "GraphQL addReview null"); - assertEqual(5, review[0].addReview.stars.intValue(), "GraphQL addReview.stars"); - assertEqual("JEDI rated 5", review[0].addReview.commentary, "GraphQL addReview.commentary"); + E2eSupport.await(d2); + assertTrue(d2[0], "GraphQL product timed out"); + assertNotNull(pd[0], "GraphQL product null"); + assertNotNull(pd[0].product(), "GraphQL product.product null"); + assertEqual(Category.TOYS, pd[0].product().category(), "GraphQL single enum"); + + // --- enum-typed variable --- + final ProductsByCategoryData[] pc = new ProductsByCategoryData[1]; + final boolean[] d3 = { false }; + api.productsByCategory(Category.ELECTRONICS, null, + new OnComplete>() { + public void completed(GraphQLResponse r) { pc[0] = r.getData(); d3[0] = true; } + }); + E2eSupport.await(d3); + assertTrue(d3[0], "GraphQL byCategory timed out"); + assertNotNull(pc[0], "GraphQL byCategory null"); + assertEqual(1, pc[0].productsByCategory().size(), "GraphQL byCategory count"); + assertEqual(Category.ELECTRONICS, pc[0].productsByCategory().get(0).category(), + "GraphQL enum-variable filter"); return true; } + private static ProductsData_Products byId(ProductsData data, String id) { + for (ProductsData_Products p : data.products()) { + if (id.equals(p.id())) { + return p; + } + } + return null; + } + @Override public int getTimeoutMillis() { return 90000; diff --git a/scripts/protocol-e2e/client/common/src/test/java/com/codename1/e2e/GrpcProtocolTest.java b/scripts/protocol-e2e/client/common/src/test/java/com/codename1/e2e/GrpcProtocolTest.java index 5bb83286b3..97b32589bb 100644 --- a/scripts/protocol-e2e/client/common/src/test/java/com/codename1/e2e/GrpcProtocolTest.java +++ b/scripts/protocol-e2e/client/common/src/test/java/com/codename1/e2e/GrpcProtocolTest.java @@ -1,37 +1,57 @@ package com.codename1.e2e; -import com.codename1.e2e.grpc.GreeterGrpc; -import com.codename1.e2e.grpc.HelloReply; -import com.codename1.e2e.grpc.HelloRequest; +import com.codename1.e2e.grpc.CatalogGrpc; +import com.codename1.e2e.grpc.Category; +import com.codename1.e2e.grpc.GetProductRequest; +import com.codename1.e2e.grpc.ListProductsRequest; +import com.codename1.e2e.grpc.Product; +import com.codename1.e2e.grpc.ProductList; import com.codename1.io.grpc.GrpcResponse; import com.codename1.testing.AbstractTest; import com.codename1.util.OnComplete; -/** End-to-end gRPC-Web unary round-trip against the test server. */ +/** + * End-to-end gRPC-Web round-trips against the catalog server, exercising the + * generated protobuf codecs for an enum field, a repeated scalar, a double, + * and nested/repeated messages. + */ public class GrpcProtocolTest extends AbstractTest { @Override public boolean runTest() throws Exception { - GreeterGrpc grpc = GreeterGrpc.of(E2eSupport.baseUrl() + "/grpc"); - HelloRequest req = new HelloRequest(); - req.name = "Leia"; + CatalogGrpc grpc = CatalogGrpc.of(E2eSupport.baseUrl() + "/grpc"); - final HelloReply[] data = new HelloReply[1]; - final int[] status = { -99 }; - final boolean[] done = { false }; - grpc.sayHello(req, null, new OnComplete>() { - public void completed(GrpcResponse r) { - status[0] = r.getResponseCode(); - data[0] = r.getResponseData(); - done[0] = true; - } + // --- unary: message with enum + repeated string + double --- + final Product[] product = new Product[1]; + final int[] s1 = { -99 }; + final boolean[] d1 = { false }; + grpc.getProduct(new GetProductRequest(1L), null, new OnComplete>() { + public void completed(GrpcResponse r) { s1[0] = r.getResponseCode(); product[0] = r.getResponseData(); d1[0] = true; } }); - E2eSupport.await(done); + E2eSupport.await(d1); + assertTrue(d1[0], "gRPC getProduct timed out"); + assertEqual(0, s1[0], "gRPC status OK"); + assertNotNull(product[0], "gRPC product null"); + assertEqual(1L, product[0].id(), "gRPC int64 field"); + assertEqual("The Hobbit", product[0].name(), "gRPC string field"); + assertEqual(Category.BOOKS, product[0].category(), "gRPC enum field"); + assertTrue(product[0].tags().contains("classic"), "gRPC repeated string field"); + assertRange(4.8, product[0].rating(), 0.001, "gRPC double field"); - assertTrue(done[0], "gRPC call timed out"); - assertEqual(0, status[0], "gRPC status should be OK (0)"); - assertNotNull(data[0], "gRPC response data was null"); - assertEqual("Hello, Leia!", data[0].message, "gRPC reply message"); + // --- nested/repeated message + enum request field --- + final ProductList[] list = new ProductList[1]; + final boolean[] d2 = { false }; + grpc.listProducts(new ListProductsRequest(Category.BOOKS), null, new OnComplete>() { + public void completed(GrpcResponse r) { list[0] = r.getResponseData(); d2[0] = true; } + }); + E2eSupport.await(d2); + assertTrue(d2[0], "gRPC listProducts timed out"); + assertNotNull(list[0], "gRPC list null"); + assertNotNull(list[0].products(), "gRPC repeated message null"); + assertEqual(2, list[0].products().size(), "gRPC repeated message count"); + for (Product p : list[0].products()) { + assertEqual(Category.BOOKS, p.category(), "gRPC nested enum"); + } return true; } diff --git a/scripts/protocol-e2e/client/common/src/test/java/com/codename1/e2e/RestProtocolTest.java b/scripts/protocol-e2e/client/common/src/test/java/com/codename1/e2e/RestProtocolTest.java index 4dfbda17e2..73a9336527 100644 --- a/scripts/protocol-e2e/client/common/src/test/java/com/codename1/e2e/RestProtocolTest.java +++ b/scripts/protocol-e2e/client/common/src/test/java/com/codename1/e2e/RestProtocolTest.java @@ -1,37 +1,79 @@ package com.codename1.e2e; -import com.codename1.e2e.rest.Greeting; -import com.codename1.e2e.rest.GreetingApi; +import com.codename1.e2e.rest.CatalogApi; +import com.codename1.e2e.rest.model.Category; +import com.codename1.e2e.rest.model.Product; import com.codename1.io.rest.Response; import com.codename1.testing.AbstractTest; import com.codename1.util.OnComplete; -/** End-to-end REST (OpenAPI) round-trip against the Spring Boot test server. */ +import java.util.List; + +/** + * End-to-end REST (OpenAPI) round-trips against the catalog server: a list + * response with nested objects + enums + string lists, a single object by id, + * and an enum-typed path parameter. + */ public class RestProtocolTest extends AbstractTest { @Override public boolean runTest() throws Exception { - GreetingApi api = GreetingApi.of(E2eSupport.baseUrl()); - final Greeting[] data = new Greeting[1]; - final int[] code = { -1 }; - final boolean[] done = { false }; - api.greeting("Shai", null, new OnComplete>() { - public void completed(Response r) { - code[0] = r.getResponseCode(); - data[0] = r.getResponseData(); - done[0] = true; - } + CatalogApi api = CatalogApi.of(E2eSupport.baseUrl()); + + // --- list: nested object + enum + string list --- + final List[] all = new List[1]; + final boolean[] d1 = { false }; + api.listProducts(null, new OnComplete>>() { + public void completed(Response> r) { all[0] = r.getResponseData(); d1[0] = true; } + }); + E2eSupport.await(d1); + assertTrue(d1[0], "REST list timed out"); + assertNotNull(all[0], "REST list null"); + assertEqual(4, all[0].size(), "REST product count"); + Product hobbit = byId(all[0], 1L); + assertNotNull(hobbit, "product id=1 missing"); + assertEqual("The Hobbit", hobbit.name(), "REST product name"); + assertEqual(Category.BOOKS, hobbit.category(), "REST enum field"); + assertTrue(hobbit.tags().contains("fantasy"), "REST string-list field"); + assertNotNull(hobbit.dimensions(), "REST nested object"); + assertRange(12.0, hobbit.dimensions().width(), 0.001, "REST nested object field"); + + // --- single object by id --- + final Product[] one = new Product[1]; + final boolean[] d2 = { false }; + api.getProduct(2L, null, new OnComplete>() { + public void completed(Response r) { one[0] = r.getResponseData(); d2[0] = true; } }); - E2eSupport.await(done); + E2eSupport.await(d2); + assertTrue(d2[0], "REST getProduct timed out"); + assertNotNull(one[0], "REST getProduct null"); + assertEqual(Category.ELECTRONICS, one[0].category(), "REST getProduct enum"); - assertTrue(done[0], "REST call timed out"); - assertEqual(200, code[0], "REST HTTP status"); - assertNotNull(data[0], "REST response data was null"); - assertEqual("Hello, Shai!", data[0].message, "REST greeting message"); - assertEqual("rest", data[0].transport, "REST transport tag"); + // --- enum-typed path parameter --- + final List[] books = new List[1]; + final boolean[] d3 = { false }; + api.getProductsByCategory(Category.BOOKS, null, new OnComplete>>() { + public void completed(Response> r) { books[0] = r.getResponseData(); d3[0] = true; } + }); + E2eSupport.await(d3); + assertTrue(d3[0], "REST byCategory timed out"); + assertNotNull(books[0], "REST byCategory null"); + assertEqual(2, books[0].size(), "REST byCategory count"); + for (Product p : books[0]) { + assertEqual(Category.BOOKS, p.category(), "REST byCategory filtered enum"); + } return true; } + private static Product byId(List list, long id) { + for (Product p : list) { + if (p.id() != null && p.id().longValue() == id) { + return p; + } + } + return null; + } + @Override public int getTimeoutMillis() { return 90000; diff --git a/scripts/protocol-e2e/run-protocol-e2e.sh b/scripts/protocol-e2e/run-protocol-e2e.sh index 9afd9123b8..ed25c03a91 100755 --- a/scripts/protocol-e2e/run-protocol-e2e.sh +++ b/scripts/protocol-e2e/run-protocol-e2e.sh @@ -42,7 +42,7 @@ trap cleanup EXIT echo "[protocol-e2e] Waiting for server readiness..." ready=0 for _ in $(seq 1 90); do - if curl -fs -o /dev/null "http://localhost:$PORT/api/greeting?name=ping" 2>/dev/null; then + if curl -fs -o /dev/null "http://localhost:$PORT/api/products" 2>/dev/null; then ready=1 break fi diff --git a/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/Catalog.java b/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/Catalog.java new file mode 100644 index 0000000000..919aa8f201 --- /dev/null +++ b/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/Catalog.java @@ -0,0 +1,55 @@ +package com.example.e2eserver; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Shared catalog domain + sample data, used by all three transports so the + * REST / GraphQL / gRPC clients see consistent responses. + */ +public final class Catalog { + + public enum Category { BOOKS, ELECTRONICS, TOYS } + + public record Dimensions(double width, double height) { } + + public record Product(long id, String name, Category category, + List tags, double rating, Dimensions dimensions) { } + + private static final List PRODUCTS = Arrays.asList( + new Product(1, "The Hobbit", Category.BOOKS, + Arrays.asList("fantasy", "classic"), 4.8, new Dimensions(12.0, 20.0)), + new Product(2, "Wireless Headphones", Category.ELECTRONICS, + Arrays.asList("audio", "bluetooth"), 4.2, new Dimensions(18.0, 18.0)), + new Product(3, "Building Blocks", Category.TOYS, + Arrays.asList("kids"), 4.5, new Dimensions(30.0, 25.0)), + new Product(4, "Clean Code", Category.BOOKS, + Arrays.asList("software", "classic"), 4.6, new Dimensions(15.0, 23.0)) + ); + + private Catalog() { } + + public static List all() { + return PRODUCTS; + } + + public static Product byId(long id) { + for (Product p : PRODUCTS) { + if (p.id() == id) { + return p; + } + } + return null; + } + + public static List byCategory(Category category) { + List out = new ArrayList<>(); + for (Product p : PRODUCTS) { + if (p.category() == category) { + out.add(p); + } + } + return out; + } +} diff --git a/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/GraphQlCatalogController.java b/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/GraphQlCatalogController.java new file mode 100644 index 0000000000..24a9e47e5f --- /dev/null +++ b/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/GraphQlCatalogController.java @@ -0,0 +1,27 @@ +package com.example.e2eserver; + +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.stereotype.Controller; + +import java.util.List; + +/** GraphQL transport, backed by Spring for GraphQL (schema.graphqls). */ +@Controller +public class GraphQlCatalogController { + + @QueryMapping + public List products() { + return Catalog.all(); + } + + @QueryMapping + public Catalog.Product product(@Argument String id) { + return Catalog.byId(Long.parseLong(id)); + } + + @QueryMapping + public List productsByCategory(@Argument Catalog.Category category) { + return Catalog.byCategory(category); + } +} diff --git a/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/GraphQlGreetingController.java b/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/GraphQlGreetingController.java deleted file mode 100644 index 581571a6b1..0000000000 --- a/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/GraphQlGreetingController.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.example.e2eserver; - -import org.springframework.graphql.data.method.annotation.Argument; -import org.springframework.graphql.data.method.annotation.MutationMapping; -import org.springframework.graphql.data.method.annotation.QueryMapping; -import org.springframework.stereotype.Controller; - -/** - * GraphQL transport, backed by Spring for GraphQL. The CN1 @GraphQLClient - * generated from schema.graphqls posts queries/mutations to /graphql. - */ -@Controller -public class GraphQlGreetingController { - - public record Hero(String name, String greeting) { } - - public record Review(int stars, String commentary) { } - - public enum Episode { NEWHOPE, EMPIRE, JEDI } - - @QueryMapping - public Hero hero(@Argument String name) { - String n = (name == null || name.isEmpty()) ? "world" : name; - return new Hero(n, "Hello, " + n + "!"); - } - - @MutationMapping - public Review addReview(@Argument Episode episode, @Argument int stars) { - return new Review(stars, episode + " rated " + stars); - } -} diff --git a/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/GrpcWebCatalogController.java b/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/GrpcWebCatalogController.java new file mode 100644 index 0000000000..646a1e6aff --- /dev/null +++ b/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/GrpcWebCatalogController.java @@ -0,0 +1,194 @@ +package com.example.e2eserver; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * gRPC-Web endpoint for the e2e test (no Envoy). The CN1 gRPC client speaks + * application/grpc-web+proto, so the gRPC-Web framing and the (standard) + * protobuf wire format are handled directly. Exercises an enum field, a + * repeated scalar, nested/repeated messages, and a double. + * + * Frame: [0x00][len BE32][message] then [0x80][len BE32][trailer]. + */ +@RestController +public class GrpcWebCatalogController { + + private static final String CONTENT_TYPE = "application/grpc-web+proto"; + + @PostMapping("/grpc/e2e.Catalog/GetProduct") + public void getProduct(HttpServletRequest request, HttpServletResponse response) throws IOException { + long id = readVarintField1(readAll(request.getInputStream())); + Catalog.Product p = Catalog.byId(id); + byte[] msg = p == null ? new byte[0] : encodeProduct(p); + respond(response, msg); + } + + @PostMapping("/grpc/e2e.Catalog/ListProducts") + public void listProducts(HttpServletRequest request, HttpServletResponse response) throws IOException { + long cat = readVarintField1(readAll(request.getInputStream())); + List products = cat == 0 + ? Catalog.all() + : Catalog.byCategory(categoryForNumber((int) cat)); + ByteArrayOutputStream list = new ByteArrayOutputStream(); + for (Catalog.Product p : products) { + writeMessageField(list, 1, encodeProduct(p)); + } + respond(response, list.toByteArray()); + } + + // -- protobuf encoding ------------------------------------------- + + private static byte[] encodeProduct(Catalog.Product p) { + ByteArrayOutputStream o = new ByteArrayOutputStream(); + writeVarintField(o, 1, p.id()); + writeStringField(o, 2, p.name()); + writeVarintField(o, 3, categoryNumber(p.category())); + for (String tag : p.tags()) { + writeStringField(o, 4, tag); + } + writeDoubleField(o, 5, p.rating()); + return o.toByteArray(); + } + + private static int categoryNumber(Catalog.Category c) { + switch (c) { + case BOOKS: return 1; + case ELECTRONICS: return 2; + case TOYS: return 3; + default: return 0; + } + } + + private static Catalog.Category categoryForNumber(int n) { + switch (n) { + case 1: return Catalog.Category.BOOKS; + case 2: return Catalog.Category.ELECTRONICS; + case 3: return Catalog.Category.TOYS; + default: return Catalog.Category.BOOKS; + } + } + + private static void writeVarintField(ByteArrayOutputStream out, int field, long value) { + out.write((field << 3) | 0); + writeVarint(out, value); + } + + private static void writeStringField(ByteArrayOutputStream out, int field, String value) { + byte[] b = value.getBytes(StandardCharsets.UTF_8); + out.write((field << 3) | 2); + writeVarint(out, b.length); + out.write(b, 0, b.length); + } + + private static void writeMessageField(ByteArrayOutputStream out, int field, byte[] msg) { + out.write((field << 3) | 2); + writeVarint(out, msg.length); + out.write(msg, 0, msg.length); + } + + private static void writeDoubleField(ByteArrayOutputStream out, int field, double value) { + out.write((field << 3) | 1); // wire type I64 + long bits = Double.doubleToLongBits(value); + for (int i = 0; i < 8; i++) { + out.write((int) ((bits >>> (8 * i)) & 0xFF)); // little-endian + } + } + + private static void writeVarint(ByteArrayOutputStream out, long value) { + long v = value; + while ((v & ~0x7FL) != 0) { + out.write((int) ((v & 0x7F) | 0x80)); + v >>>= 7; + } + out.write((int) v); + } + + /** Reads protobuf field 1 (a varint) from the gRPC-Web data frame body. */ + private static long readVarintField1(byte[] body) { + if (body == null || body.length < 5) { + return 0; + } + int len = ((body[1] & 0xFF) << 24) | ((body[2] & 0xFF) << 16) + | ((body[3] & 0xFF) << 8) | (body[4] & 0xFF); + int pos = 5; + int end = Math.min(body.length, pos + len); + while (pos < end) { + int tag = body[pos++] & 0xFF; + int field = tag >>> 3; + int wire = tag & 0x7; + if (wire == 0) { + long[] r = readVarint(body, pos); + pos = (int) r[1]; + if (field == 1) { + return r[0]; + } + } else if (wire == 2) { + long[] r = readVarint(body, pos); + pos = (int) r[1] + (int) r[0]; + } else if (wire == 1) { + pos += 8; + } else { + break; + } + } + return 0; + } + + private static long[] readVarint(byte[] b, int pos) { + long value = 0; + int shift = 0; + while (pos < b.length) { + int x = b[pos++] & 0xFF; + value |= (long) (x & 0x7F) << shift; + if ((x & 0x80) == 0) { + break; + } + shift += 7; + } + return new long[] { value, pos }; + } + + // -- gRPC-Web framing -------------------------------------------- + + private void respond(HttpServletResponse response, byte[] message) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + writeFrame(out, 0x00, message); + writeFrame(out, 0x80, "grpc-status:0\r\ngrpc-message:\r\n".getBytes(StandardCharsets.UTF_8)); + byte[] payload = out.toByteArray(); + response.setStatus(200); + response.setContentType(CONTENT_TYPE); + response.setContentLength(payload.length); + OutputStream os = response.getOutputStream(); + os.write(payload); + os.flush(); + } + + private static void writeFrame(ByteArrayOutputStream out, int flag, byte[] payload) { + out.write(flag); + out.write((payload.length >>> 24) & 0xFF); + out.write((payload.length >>> 16) & 0xFF); + out.write((payload.length >>> 8) & 0xFF); + out.write(payload.length & 0xFF); + out.write(payload, 0, payload.length); + } + + private static byte[] readAll(InputStream in) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = in.read(buf)) >= 0) { + out.write(buf, 0, n); + } + return out.toByteArray(); + } +} diff --git a/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/GrpcWebGreetingController.java b/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/GrpcWebGreetingController.java deleted file mode 100644 index 7a99cfce3d..0000000000 --- a/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/GrpcWebGreetingController.java +++ /dev/null @@ -1,137 +0,0 @@ -package com.example.e2eserver; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RestController; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.nio.charset.StandardCharsets; - -/** - * Minimal gRPC-Web endpoint for the e2e test. The CN1 gRPC client - * (com.codename1.io.grpc.GrpcWeb) speaks application/grpc-web+proto, so rather - * than run a real gRPC server behind an Envoy bridge we decode the gRPC-Web - * framing and the (single-field) protobuf messages by hand. - * - * Request framing : [0x00][len BE32][HelloRequest protobuf] - * HelloRequest : field 1 (string name) - * Response framing : [0x00][len BE32][HelloReply protobuf] - * [0x80][len BE32]["grpc-status:0\r\n..."] (trailer frame) - * HelloReply : field 1 (string message) - * - * Mapped at /grpc/{service}/{method}; the CN1 client posts to - * /e2e.Greeter/SayHello with baseUrl = http://host:8080/grpc. - */ -@RestController -public class GrpcWebGreetingController { - - private static final String CONTENT_TYPE = "application/grpc-web+proto"; - - @PostMapping("/grpc/e2e.Greeter/SayHello") - public void sayHello(HttpServletRequest request, HttpServletResponse response) throws IOException { - byte[] body = readAll(request.getInputStream()); - String name = decodeName(body); - if (name == null || name.isEmpty()) { - name = "world"; - } - - byte[] reply = encodeStringField(1, "Hello, " + name + "!"); - ByteArrayOutputStream out = new ByteArrayOutputStream(); - writeFrame(out, 0x00, reply); - writeFrame(out, 0x80, "grpc-status:0\r\ngrpc-message:\r\n".getBytes(StandardCharsets.UTF_8)); - - response.setStatus(200); - response.setContentType(CONTENT_TYPE); - byte[] payload = out.toByteArray(); - response.setContentLength(payload.length); - OutputStream os = response.getOutputStream(); - os.write(payload); - os.flush(); - } - - /** Reads the gRPC-Web data frame and extracts protobuf field 1 (string). */ - private static String decodeName(byte[] body) { - if (body == null || body.length < 5) { - return null; - } - int len = ((body[1] & 0xFF) << 24) | ((body[2] & 0xFF) << 16) - | ((body[3] & 0xFF) << 8) | (body[4] & 0xFF); - int pos = 5; - int end = Math.min(body.length, pos + len); - while (pos < end) { - int tag = body[pos++] & 0xFF; - int field = tag >>> 3; - int wire = tag & 0x7; - if (wire == 2) { // length-delimited - int[] r = readVarint(body, pos); - int fieldLen = r[0]; - pos = r[1]; - if (field == 1) { - return new String(body, pos, fieldLen, StandardCharsets.UTF_8); - } - pos += fieldLen; - } else if (wire == 0) { // varint - pos = readVarint(body, pos)[1]; - } else { - break; - } - } - return null; - } - - private static int[] readVarint(byte[] b, int pos) { - int value = 0; - int shift = 0; - while (pos < b.length) { - int x = b[pos++] & 0xFF; - value |= (x & 0x7F) << shift; - if ((x & 0x80) == 0) { - break; - } - shift += 7; - } - return new int[] { value, pos }; - } - - /** Encodes a protobuf message with one length-delimited string field. */ - private static byte[] encodeStringField(int field, String value) { - byte[] utf8 = value.getBytes(StandardCharsets.UTF_8); - ByteArrayOutputStream out = new ByteArrayOutputStream(); - out.write((field << 3) | 2); // tag: field, wire type LEN - writeVarint(out, utf8.length); - out.write(utf8, 0, utf8.length); - return out.toByteArray(); - } - - private static void writeVarint(ByteArrayOutputStream out, int value) { - int v = value; - while ((v & ~0x7F) != 0) { - out.write((v & 0x7F) | 0x80); - v >>>= 7; - } - out.write(v); - } - - private static void writeFrame(ByteArrayOutputStream out, int flag, byte[] payload) { - out.write(flag); - out.write((payload.length >>> 24) & 0xFF); - out.write((payload.length >>> 16) & 0xFF); - out.write((payload.length >>> 8) & 0xFF); - out.write(payload.length & 0xFF); - out.write(payload, 0, payload.length); - } - - private static byte[] readAll(InputStream in) throws IOException { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - byte[] buf = new byte[4096]; - int n; - while ((n = in.read(buf)) >= 0) { - out.write(buf, 0, n); - } - return out.toByteArray(); - } -} diff --git a/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/RestCatalogController.java b/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/RestCatalogController.java new file mode 100644 index 0000000000..295ba276ad --- /dev/null +++ b/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/RestCatalogController.java @@ -0,0 +1,27 @@ +package com.example.e2eserver; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** REST transport (described by openapi.json). */ +@RestController +public class RestCatalogController { + + @GetMapping("/api/products") + public List listProducts() { + return Catalog.all(); + } + + @GetMapping("/api/products/{id}") + public Catalog.Product getProduct(@PathVariable long id) { + return Catalog.byId(id); + } + + @GetMapping("/api/products/category/{category}") + public List getProductsByCategory(@PathVariable Catalog.Category category) { + return Catalog.byCategory(category); + } +} diff --git a/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/RestGreetingController.java b/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/RestGreetingController.java deleted file mode 100644 index fc9d7566cd..0000000000 --- a/scripts/protocol-e2e/server/src/main/java/com/example/e2eserver/RestGreetingController.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.example.e2eserver; - -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import java.util.LinkedHashMap; -import java.util.Map; - -/** - * REST transport, described by openapi.json on the client side. The CN1 - * @RestClient generated from that spec calls these endpoints. - */ -@RestController -public class RestGreetingController { - - @GetMapping("/api/greeting") - public Map greeting(@RequestParam(name = "name", defaultValue = "world") String name) { - Map m = new LinkedHashMap<>(); - m.put("name", name); - m.put("message", "Hello, " + name + "!"); - m.put("transport", "rest"); - return m; - } - - /** Echoes the Authorization header back so the client can assert bearer-token plumbing. */ - @PostMapping("/api/echo") - public Map echo(@RequestBody Map body, - @RequestHeader(name = "Authorization", required = false) String auth) { - Map m = new LinkedHashMap<>(); - m.put("name", String.valueOf(body.get("name"))); - m.put("message", "echo:" + body.get("name")); - m.put("transport", "rest"); - m.put("authorization", auth); - return m; - } -} diff --git a/scripts/protocol-e2e/server/src/main/resources/graphql/schema.graphqls b/scripts/protocol-e2e/server/src/main/resources/graphql/schema.graphqls index 7271ee81b2..a17fea1e4a 100644 --- a/scripts/protocol-e2e/server/src/main/resources/graphql/schema.graphqls +++ b/scripts/protocol-e2e/server/src/main/resources/graphql/schema.graphqls @@ -1,23 +1,29 @@ -type Query { - hero(name: String): Hero +schema { + query: Query } -type Mutation { - addReview(episode: Episode!, stars: Int!): Review +type Query { + products: [Product!]! + product(id: ID!): Product + productsByCategory(category: Category!): [Product!]! } -type Hero { +type Product { + id: ID! name: String! - greeting: String! + category: Category! + tags: [String!]! + rating: Float! + dimensions: Dimensions } -type Review { - stars: Int! - commentary: String +type Dimensions { + width: Float! + height: Float! } -enum Episode { - NEWHOPE - EMPIRE - JEDI +enum Category { + BOOKS + ELECTRONICS + TOYS } diff --git a/scripts/protocol-e2e/specs/catalog.proto b/scripts/protocol-e2e/specs/catalog.proto new file mode 100644 index 0000000000..c612dd753a --- /dev/null +++ b/scripts/protocol-e2e/specs/catalog.proto @@ -0,0 +1,39 @@ +syntax = "proto3"; + +package e2e; + +// Contract for the gRPC-Web transport. Covers an enum field, a repeated +// scalar field, nested/repeated messages, and multiple rpc methods so the +// generated CN1 protobuf codecs are genuinely exercised. + +enum Category { + CATEGORY_UNSPECIFIED = 0; + BOOKS = 1; + ELECTRONICS = 2; + TOYS = 3; +} + +message Product { + int64 id = 1; + string name = 2; + Category category = 3; + repeated string tags = 4; + double rating = 5; +} + +message GetProductRequest { + int64 id = 1; +} + +message ListProductsRequest { + Category category = 1; +} + +message ProductList { + repeated Product products = 1; +} + +service Catalog { + rpc GetProduct (GetProductRequest) returns (Product); + rpc ListProducts (ListProductsRequest) returns (ProductList); +} diff --git a/scripts/protocol-e2e/specs/greeter.proto b/scripts/protocol-e2e/specs/greeter.proto deleted file mode 100644 index 35b54e0f24..0000000000 --- a/scripts/protocol-e2e/specs/greeter.proto +++ /dev/null @@ -1,19 +0,0 @@ -syntax = "proto3"; - -package e2e; - -// Contract for the gRPC-Web transport. The Codename One client classes under -// client/common/src/main/java/com/codename1/e2e/grpc mirror what -// `cn1:generate-grpc -Dcn1.grpc.proto=greeter.proto -// -Dcn1.grpc.basePackage=com.codename1.e2e.grpc` produces from this file. -message HelloRequest { - string name = 1; -} - -message HelloReply { - string message = 1; -} - -service Greeter { - rpc SayHello (HelloRequest) returns (HelloReply); -} diff --git a/scripts/protocol-e2e/specs/openapi.json b/scripts/protocol-e2e/specs/openapi.json index f8505cd0ca..dcc4f06e3d 100644 --- a/scripts/protocol-e2e/specs/openapi.json +++ b/scripts/protocol-e2e/specs/openapi.json @@ -1,20 +1,53 @@ { "openapi": "3.0.0", - "info": { "title": "Protocol E2E Greeting API", "version": "1.0.0" }, + "info": { "title": "Protocol E2E Catalog API", "version": "1.0.0" }, "paths": { - "/api/greeting": { + "/api/products": { "get": { - "tags": ["Greeting"], - "operationId": "greeting", + "tags": ["Catalog"], + "operationId": "listProducts", + "responses": { + "200": { + "description": "All products", + "content": { + "application/json": { + "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Product" } } + } + } + } + } + } + }, + "/api/products/{id}": { + "get": { + "tags": ["Catalog"], + "operationId": "getProduct", "parameters": [ - { "name": "name", "in": "query", "required": false, "schema": { "type": "string" } } + { "name": "id", "in": "path", "required": true, "schema": { "type": "integer", "format": "int64" } } ], "responses": { "200": { - "description": "A greeting", + "description": "A product", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/Product" } } + } + } + } + } + }, + "/api/products/category/{category}": { + "get": { + "tags": ["Catalog"], + "operationId": "getProductsByCategory", + "parameters": [ + { "name": "category", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/Category" } } + ], + "responses": { + "200": { + "description": "Products in a category", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Greeting" } + "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Product" } } } } } @@ -24,12 +57,26 @@ }, "components": { "schemas": { - "Greeting": { + "Category": { + "type": "string", + "enum": ["BOOKS", "ELECTRONICS", "TOYS"] + }, + "Dimensions": { + "type": "object", + "properties": { + "width": { "type": "number", "format": "double" }, + "height": { "type": "number", "format": "double" } + } + }, + "Product": { "type": "object", "properties": { + "id": { "type": "integer", "format": "int64" }, "name": { "type": "string" }, - "message": { "type": "string" }, - "transport": { "type": "string" } + "category": { "$ref": "#/components/schemas/Category" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "rating": { "type": "number", "format": "double" }, + "dimensions": { "$ref": "#/components/schemas/Dimensions" } } } } diff --git a/scripts/protocol-e2e/specs/operations.graphql b/scripts/protocol-e2e/specs/operations.graphql new file mode 100644 index 0000000000..dcfbcc119c --- /dev/null +++ b/scripts/protocol-e2e/specs/operations.graphql @@ -0,0 +1,31 @@ +# Operations the CN1 GraphQL client is generated from (operations mode). +# Exercises: a list query with nested object selection, a single-object query +# with an enum field, and an enum-typed variable. + +query Products { + products { + id + name + category + tags + rating + dimensions { width height } + } +} + +query Product($id: ID!) { + product(id: $id) { + id + name + category + rating + } +} + +query ProductsByCategory($category: Category!) { + productsByCategory(category: $category) { + id + name + category + } +} diff --git a/scripts/protocol-e2e/specs/schema.graphqls b/scripts/protocol-e2e/specs/schema.graphqls index 7271ee81b2..a17fea1e4a 100644 --- a/scripts/protocol-e2e/specs/schema.graphqls +++ b/scripts/protocol-e2e/specs/schema.graphqls @@ -1,23 +1,29 @@ -type Query { - hero(name: String): Hero +schema { + query: Query } -type Mutation { - addReview(episode: Episode!, stars: Int!): Review +type Query { + products: [Product!]! + product(id: ID!): Product + productsByCategory(category: Category!): [Product!]! } -type Hero { +type Product { + id: ID! name: String! - greeting: String! + category: Category! + tags: [String!]! + rating: Float! + dimensions: Dimensions } -type Review { - stars: Int! - commentary: String +type Dimensions { + width: Float! + height: Float! } -enum Episode { - NEWHOPE - EMPIRE - JEDI +enum Category { + BOOKS + ELECTRONICS + TOYS } From fb7b524568fc0c15eb29f6e595f8341e51e90d81 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 2 Jun 2026 05:25:13 +0300 Subject: [PATCH 7/9] docs(developer-guide): satisfy Vale contraction gate in OpenAPI enum note Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/developer-guide/appendix_goal_generate_openapi.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developer-guide/appendix_goal_generate_openapi.adoc b/docs/developer-guide/appendix_goal_generate_openapi.adoc index fc4f7e2e61..fc9acf9d78 100644 --- a/docs/developer-guide/appendix_goal_generate_openapi.adoc +++ b/docs/developer-guide/appendix_goal_generate_openapi.adoc @@ -135,7 +135,7 @@ mapper through each constant's `name()`, so a model property typed by an enum `$ref` gets the generated enum type. This requires every enum value to be a valid (non-reserved) Java identifier; an enum whose values are not -- for example `"two-day"` -- degrades to `String` rather than -generating an enum that could not round-trip. Integer/number enums keep +generating an enum that couldn't round-trip. Integer/number enums keep their numeric type. * Schema unification: two `components.schemas` entries with identical property shapes collapse to a single record/class to avoid an From 4e1578623e2f36ccc4e6ed82343f526c1e66924a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 2 Jun 2026 06:19:01 +0300 Subject: [PATCH 8/9] test(e2e): single source of truth for protocol specs (no duplication) The proto / OpenAPI / GraphQL specs were committed in three places (specs/, client/common/cn1specs/, server/.../resources/graphql/), inviting drift. Now specs/ is the sole copy: the client generate-* executions read it via ../../specs, and the server copies schema.graphqls from ../specs onto the classpath at build time (process-resources) instead of committing a second copy. Validated end-to-end locally. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/protocol-e2e/README.md | 2 +- .../client/common/cn1specs/catalog.proto | 39 --------- .../client/common/cn1specs/openapi.json | 84 ------------------- .../client/common/cn1specs/operations.graphql | 31 ------- .../client/common/cn1specs/schema.graphqls | 29 ------- scripts/protocol-e2e/client/common/pom.xml | 8 +- scripts/protocol-e2e/server/pom.xml | 26 ++++++ .../main/resources/graphql/schema.graphqls | 29 ------- 8 files changed, 31 insertions(+), 217 deletions(-) delete mode 100644 scripts/protocol-e2e/client/common/cn1specs/catalog.proto delete mode 100644 scripts/protocol-e2e/client/common/cn1specs/openapi.json delete mode 100644 scripts/protocol-e2e/client/common/cn1specs/operations.graphql delete mode 100644 scripts/protocol-e2e/client/common/cn1specs/schema.graphqls delete mode 100644 scripts/protocol-e2e/server/src/main/resources/graphql/schema.graphqls diff --git a/scripts/protocol-e2e/README.md b/scripts/protocol-e2e/README.md index 1fb34b39f5..fa81945dc8 100644 --- a/scripts/protocol-e2e/README.md +++ b/scripts/protocol-e2e/README.md @@ -21,7 +21,7 @@ protocols against a single real server, over a non-trivial catalog API so no Envoy/proxy sidecar is required. - `client/` -- a Codename One app (`common` + `javase`). The `common` module runs `cn1:generate-openapi`, `cn1:generate-grpc`, and `cn1:generate-graphql` - at **build time** (from the specs under `client/common/cn1specs/`) into + at **build time** (from the shared `specs/` directory) into `target/generated-sources/cn1`; `cn1:process-annotations` then generates the client impls and the `cn1app.*Bootstrap` registrars. The `AbstractTest` classes under `client/common/src/test/java/com/codename1/e2e/` perform real diff --git a/scripts/protocol-e2e/client/common/cn1specs/catalog.proto b/scripts/protocol-e2e/client/common/cn1specs/catalog.proto deleted file mode 100644 index c612dd753a..0000000000 --- a/scripts/protocol-e2e/client/common/cn1specs/catalog.proto +++ /dev/null @@ -1,39 +0,0 @@ -syntax = "proto3"; - -package e2e; - -// Contract for the gRPC-Web transport. Covers an enum field, a repeated -// scalar field, nested/repeated messages, and multiple rpc methods so the -// generated CN1 protobuf codecs are genuinely exercised. - -enum Category { - CATEGORY_UNSPECIFIED = 0; - BOOKS = 1; - ELECTRONICS = 2; - TOYS = 3; -} - -message Product { - int64 id = 1; - string name = 2; - Category category = 3; - repeated string tags = 4; - double rating = 5; -} - -message GetProductRequest { - int64 id = 1; -} - -message ListProductsRequest { - Category category = 1; -} - -message ProductList { - repeated Product products = 1; -} - -service Catalog { - rpc GetProduct (GetProductRequest) returns (Product); - rpc ListProducts (ListProductsRequest) returns (ProductList); -} diff --git a/scripts/protocol-e2e/client/common/cn1specs/openapi.json b/scripts/protocol-e2e/client/common/cn1specs/openapi.json deleted file mode 100644 index dcc4f06e3d..0000000000 --- a/scripts/protocol-e2e/client/common/cn1specs/openapi.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { "title": "Protocol E2E Catalog API", "version": "1.0.0" }, - "paths": { - "/api/products": { - "get": { - "tags": ["Catalog"], - "operationId": "listProducts", - "responses": { - "200": { - "description": "All products", - "content": { - "application/json": { - "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Product" } } - } - } - } - } - } - }, - "/api/products/{id}": { - "get": { - "tags": ["Catalog"], - "operationId": "getProduct", - "parameters": [ - { "name": "id", "in": "path", "required": true, "schema": { "type": "integer", "format": "int64" } } - ], - "responses": { - "200": { - "description": "A product", - "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/Product" } } - } - } - } - } - }, - "/api/products/category/{category}": { - "get": { - "tags": ["Catalog"], - "operationId": "getProductsByCategory", - "parameters": [ - { "name": "category", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/Category" } } - ], - "responses": { - "200": { - "description": "Products in a category", - "content": { - "application/json": { - "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Product" } } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "Category": { - "type": "string", - "enum": ["BOOKS", "ELECTRONICS", "TOYS"] - }, - "Dimensions": { - "type": "object", - "properties": { - "width": { "type": "number", "format": "double" }, - "height": { "type": "number", "format": "double" } - } - }, - "Product": { - "type": "object", - "properties": { - "id": { "type": "integer", "format": "int64" }, - "name": { "type": "string" }, - "category": { "$ref": "#/components/schemas/Category" }, - "tags": { "type": "array", "items": { "type": "string" } }, - "rating": { "type": "number", "format": "double" }, - "dimensions": { "$ref": "#/components/schemas/Dimensions" } - } - } - } - } -} diff --git a/scripts/protocol-e2e/client/common/cn1specs/operations.graphql b/scripts/protocol-e2e/client/common/cn1specs/operations.graphql deleted file mode 100644 index dcfbcc119c..0000000000 --- a/scripts/protocol-e2e/client/common/cn1specs/operations.graphql +++ /dev/null @@ -1,31 +0,0 @@ -# Operations the CN1 GraphQL client is generated from (operations mode). -# Exercises: a list query with nested object selection, a single-object query -# with an enum field, and an enum-typed variable. - -query Products { - products { - id - name - category - tags - rating - dimensions { width height } - } -} - -query Product($id: ID!) { - product(id: $id) { - id - name - category - rating - } -} - -query ProductsByCategory($category: Category!) { - productsByCategory(category: $category) { - id - name - category - } -} diff --git a/scripts/protocol-e2e/client/common/cn1specs/schema.graphqls b/scripts/protocol-e2e/client/common/cn1specs/schema.graphqls deleted file mode 100644 index a17fea1e4a..0000000000 --- a/scripts/protocol-e2e/client/common/cn1specs/schema.graphqls +++ /dev/null @@ -1,29 +0,0 @@ -schema { - query: Query -} - -type Query { - products: [Product!]! - product(id: ID!): Product - productsByCategory(category: Category!): [Product!]! -} - -type Product { - id: ID! - name: String! - category: Category! - tags: [String!]! - rating: Float! - dimensions: Dimensions -} - -type Dimensions { - width: Float! - height: Float! -} - -enum Category { - BOOKS - ELECTRONICS - TOYS -} diff --git a/scripts/protocol-e2e/client/common/pom.xml b/scripts/protocol-e2e/client/common/pom.xml index 0d6a5fdf71..27cd37e7ea 100644 --- a/scripts/protocol-e2e/client/common/pom.xml +++ b/scripts/protocol-e2e/client/common/pom.xml @@ -92,7 +92,7 @@ generate-sources generate-openapi - ${project.basedir}/cn1specs/openapi.json + ${project.basedir}/../../specs/openapi.json com.codename1.e2e.rest ${project.build.directory}/generated-sources/cn1 @@ -102,7 +102,7 @@ generate-sources generate-grpc - ${project.basedir}/cn1specs/catalog.proto + ${project.basedir}/../../specs/catalog.proto com.codename1.e2e.grpc ${project.build.directory}/generated-sources/cn1 @@ -112,8 +112,8 @@ generate-sources generate-graphql - ${project.basedir}/cn1specs/schema.graphqls - ${project.basedir}/cn1specs/operations.graphql + ${project.basedir}/../../specs/schema.graphqls + ${project.basedir}/../../specs/operations.graphql com.codename1.e2e.graphql CatalogGraphApi ${project.build.directory}/generated-sources/cn1 diff --git a/scripts/protocol-e2e/server/pom.xml b/scripts/protocol-e2e/server/pom.xml index 1b2488dec4..88c5aef559 100644 --- a/scripts/protocol-e2e/server/pom.xml +++ b/scripts/protocol-e2e/server/pom.xml @@ -52,6 +52,32 @@ org.springframework.boot spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-graphql-schema + process-resources + copy-resources + + ${project.build.outputDirectory}/graphql + + + ${project.basedir}/../specs + + schema.graphqls + + + + + + + diff --git a/scripts/protocol-e2e/server/src/main/resources/graphql/schema.graphqls b/scripts/protocol-e2e/server/src/main/resources/graphql/schema.graphqls deleted file mode 100644 index a17fea1e4a..0000000000 --- a/scripts/protocol-e2e/server/src/main/resources/graphql/schema.graphqls +++ /dev/null @@ -1,29 +0,0 @@ -schema { - query: Query -} - -type Query { - products: [Product!]! - product(id: ID!): Product - productsByCategory(category: Category!): [Product!]! -} - -type Product { - id: ID! - name: String! - category: Category! - tags: [String!]! - rating: Float! - dimensions: Dimensions -} - -type Dimensions { - width: Float! - height: Float! -} - -enum Category { - BOOKS - ELECTRONICS - TOYS -} From 0b6b615323bd18f3afb8de3136888c2835365b8f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 2 Jun 2026 07:08:55 +0300 Subject: [PATCH 9/9] docs(skill): document the three typed API-client backends in the initializr skill The codename-one authoring skill that ships into every generated project only covered REST/OpenAPI (and only in passing). Add a dedicated references/api-clients.md covering all three "spec to typed client" generators that share one architecture -- REST/OpenAPI (@RestClient), gRPC (@GrpcClient, gRPC-Web) and GraphQL (@GraphQLClient, subscriptions over WebSocket): the generate-* goals, annotations, response envelopes (Response/GrpcResponse/ GraphQLResponse), enum support, the EDT callback convention, and the scripts/protocol-e2e end-to-end reference. Wire it into SKILL.md (references index + task-routing table), cross-link from references/java-api-subset.md's networking section, and add the generate-grpc / generate-graphql goals alongside generate-openapi in references/build-and-run.md. Also fix stale drift in the protocol-e2e server pom comment (still described the old "greeting" service instead of the current "catalog" API). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../common/src/main/resources/skill/SKILL.md | 2 + .../resources/skill/references/api-clients.md | 214 ++++++++++++++++++ .../skill/references/build-and-run.md | 18 ++ .../skill/references/java-api-subset.md | 2 + scripts/protocol-e2e/server/pom.xml | 8 +- 5 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 scripts/initializr/common/src/main/resources/skill/references/api-clients.md diff --git a/scripts/initializr/common/src/main/resources/skill/SKILL.md b/scripts/initializr/common/src/main/resources/skill/SKILL.md index 55d1a9f76f..292dbdb8ff 100644 --- a/scripts/initializr/common/src/main/resources/skill/SKILL.md +++ b/scripts/initializr/common/src/main/resources/skill/SKILL.md @@ -23,6 +23,7 @@ This skill teaches you how to write code for a Codename One (CN1) cross-platform - `references/build-and-run.md` — Local vs cloud builds, JDK matrix, Maven goals, `codenameone_settings.properties`, running the simulator, building for iOS/Android/Web, automated (Enterprise) cloud builds in CI. - `references/build-hints.md` — Curated index of `codename1.arg.*` build hints (iOS, Android, push, web). - `references/java-api-subset.md` — How to inspect the supported Java API subset, IO (`Storage`, `FileSystemStorage`), networking (`ConnectionRequest`, `Rest`), OAuth/OpenID Connect (`OidcClient`), WebSockets (cn1lib), concurrency, dates, SQLite. **Read this whenever the compliance check fails or when you reach for a `java.*` API.** +- `references/api-clients.md` — The three "spec to typed client" code generators that share one architecture: REST/OpenAPI (`cn1:generate-openapi` + `@RestClient`), gRPC (`cn1:generate-grpc` + `@GrpcClient`), and GraphQL (`cn1:generate-graphql` + `@GraphQLClient`). Read this when the backend has an OpenAPI spec, a `.proto`, or a GraphQL schema and you want a generated, annotated client instead of hand-rolling calls. - `references/ui-components.md` — Form, Toolbar, Container layouts (Border/Box/Flow/Grid/Layered), common components, navigation, dialogs. - `references/binding-and-validation.md` — `@Bindable` / `@Bind` annotation binding **and** annotation-driven validation (`@Required`, `@Length`, `@Regex`, `@Email`, `@Url`, `@Numeric`, `@ExistIn`, `@Validate`). Read this whenever you see one of those annotations, wire a model to a form, or need to gate a submit button on validation. - `references/css.md` — CSS capabilities and (important) **limitations**. Selectors, supported properties, 9-patch borders, theme constants, and the build-time vector transcoder that compiles SVG and Lottie / Bodymovin JSON referenced via `url(...)` into `GeneratedSVGImage` subclasses. @@ -288,6 +289,7 @@ If you cannot run the simulator (e.g. headless environment), **say so explicitly | "Port this from Swing" / Swing idioms | `references/swing-comparison.md` | | "I have HTML/CSS, convert it" | `references/html-css-cheatsheet.md` | | "I have Android XML/Kotlin/Java, convert it" | `references/android-to-cn1.md` | +| "Generate a client for this OpenAPI spec / `.proto` / GraphQL schema" / `@RestClient`, `@GrpcClient`, `@GraphQLClient` | `references/api-clients.md` | | "Write a test for this screen" / "Compare to a baseline" | `references/testing-and-screenshots.md` | | "Make it look right on tablet/landscape" | `references/mobile-adaptability.md` | | "How do I run/build/deploy" | `references/build-and-run.md` | diff --git a/scripts/initializr/common/src/main/resources/skill/references/api-clients.md b/scripts/initializr/common/src/main/resources/skill/references/api-clients.md new file mode 100644 index 0000000000..4e4bb02929 --- /dev/null +++ b/scripts/initializr/common/src/main/resources/skill/references/api-clients.md @@ -0,0 +1,214 @@ +# Generated typed API clients (REST / gRPC / GraphQL) + +Codename One ships three "spec to typed client" code generators that share one +architecture. You point a Maven goal at a contract file (an OpenAPI spec, a +`.proto`, or a GraphQL schema), it writes **editable** model classes plus one +annotated **client interface** into `common/src/main/java`, and a build-time +annotation processor turns that interface into a working wire implementation on +the next compile. You never hand-write HTTP/marshalling code, and the generated +interface is small enough to read and commit. + +All three follow the same shape, so once you know one you know all three: + +| | REST / OpenAPI | gRPC | GraphQL | +|---|---|---|---| +| Generate goal | `cn1:generate-openapi` | `cn1:generate-grpc` | `cn1:generate-graphql` | +| Contract | OpenAPI 3.x JSON/YAML | `.proto` | `.graphqls` (+ optional `.graphql` operations) | +| Client annotation | `@RestClient` | `@GrpcClient` | `@GraphQLClient` | +| Method annotations | `@GET`/`@POST`/`@PUT`/`@PATCH`/`@DELETE` | `@Rpc` | `@Query`/`@Mutation`/`@Subscription` | +| Param annotations | `@Path`/`@Query`/`@Header`/`@Body`/`@Cookie` | (positional message) | `@Var`/`@Header` | +| Model annotation | `@Mapped` + `@JsonProperty` | `@ProtoMessage`/`@ProtoField`/`@ProtoEnum` | `@Mapped` + `@JsonProperty` | +| Runtime package | `com.codename1.io.rest` | `com.codename1.io.grpc` | `com.codename1.io.graphql` | +| Response envelope | `Response` | `GrpcResponse` | `GraphQLResponse` | +| Transport | HTTPS (JSON) | gRPC-Web (`application/grpc-web+proto`) | HTTPS POST (JSON); subscriptions over WebSocket | + +**Pick by what the backend already speaks** — you don't choose the protocol, the +server does. Use OpenAPI for a normal JSON REST API, gRPC for a protobuf service +exposed via gRPC-Web, GraphQL for a GraphQL endpoint. (For a *handful* of ad-hoc +REST calls, the fluent `Rest` builder in `references/java-api-subset.md` is +lighter than generating a client — reach for codegen when there's a real spec +with many endpoints/messages.) + +## How the pipeline works (identical for all three) + +1. Run the `generate-*` goal once (or wire it into `generate-sources` so it + re-runs on every build — that's what the end-to-end test under + `scripts/protocol-e2e/` does). It writes model classes + the client interface + under your `basePackage` into `common/src/main/java`. **These are yours** — + edit them, commit them, regenerate when the contract changes. +2. On the next `compile`, the annotation processor scans the annotated interface + and emits `Impl` + a `cn1app.*Bootstrap` registrar into + `common/target/generated-sources` (not project source). +3. The bootstrap is auto-installed before `Display.init` (the simulator + `Class.forName`s it; the iOS/Android build server detects it in the app zip), + so no manual registration is needed. +4. Call `.of(baseUrl)` to get an instance and call its methods. + +If a client returns `null` data with an otherwise-normal response, the usual +cause is that `process-annotations` did not run (stale `target/`) or a model +class is not `@Mapped` — rebuild with a clean `compile`. + +## Calling convention + +Every operation is **asynchronous and non-blocking**: the last parameter is a +callback that fires **on the EDT** (queries/mutations via +`com.codename1.util.OnComplete<...>`; subscriptions via a streaming `Handler`). +There is no synchronous variant — kick off the call, return, and update the UI +from the callback. + +## REST / OpenAPI + +```bash +mvn -pl common cn1:generate-openapi \ + -Dcn1.openapi.spec=petstore.json \ + -Dcn1.openapi.basePackage=com.example.petstore +``` + +Emits one `@Mapped` model per schema and one `@RestClient` interface per OpenAPI +**tag** (so `PetApi`, `StoreApi`, ...). String `enum` schemas become real Java +`enum` types and bind through `@Mapped` (a value that is not a legal Java +identifier degrades to `String`). + +```java +@RestClient("https://petstore.example.com/v2") +public interface PetApi { + @GET("/pet/{petId}") + void getPetById(@Path("petId") long petId, + @Header("Authorization") String bearer, + OnComplete> callback); + + @POST("/pet") + void addPet(@Body Pet pet, OnComplete> callback); + + static PetApi of(String baseUrl) { return RestClients.create(PetApi.class, baseUrl); } +} + +// usage +PetApi.of(baseUrl).getPetById(42, "Bearer " + token, response -> { + if (response.getResponseCode() == 200) { + Pet pet = response.getResponseData(); // already typed + renderPet(pet); + } +}); +``` + +`Response` exposes `getResponseData()`, `getResponseCode()`, and the raw bytes. + +## gRPC + +```bash +mvn -pl common cn1:generate-grpc \ + -Dcn1.grpc.proto=catalog.proto \ + -Dcn1.grpc.basePackage=com.example.catalog +``` + +Emits `@ProtoMessage` classes (`@ProtoField` per field, `@ProtoEnum` for proto +enums) and one `@GrpcClient` interface per `service`. The transport is +**gRPC-Web** (`com.codename1.io.grpc.GrpcWeb`) so it works over plain +HTTPS — the server must expose a gRPC-Web endpoint (Envoy, grpc-web filter, or a +direct implementation); raw HTTP/2 gRPC is not reachable from a mobile client. + +```java +@GrpcClient("e2e.Catalog") // package-qualified proto service name +public interface CatalogClient { + @Rpc("GetProduct") + void getProduct(GetProductRequest req, OnComplete> callback); + + @Rpc("ListProducts") + void listProducts(ListProductsRequest req, OnComplete> callback); + + static CatalogClient of(String baseUrl) { return GrpcClients.create(CatalogClient.class, baseUrl); } +} + +// usage +CatalogClient.of(baseUrl).getProduct(req, response -> { + if (response.isOk()) { + Product p = response.getResponseData(); + } else { + Log.p("grpc-status " + response.getStatus() + ": " + response.getStatusMessage()); + } +}); +``` + +`GrpcResponse` carries the decoded message plus the gRPC `status`/`statusMessage` +trailer and `isOk()`. + +## GraphQL + +```bash +# Operations mode (precise) — generate exactly the named operations you author: +mvn -pl common cn1:generate-graphql \ + -Dcn1.graphql.schema=schema.graphqls \ + -Dcn1.graphql.operations=operations.graphql \ + -Dcn1.graphql.basePackage=com.example.catalog \ + -Dcn1.graphql.clientName=CatalogGraphApi \ + -Dcn1.graphql.endpoint=https://api.example.com/graphql + +# Schema-only quick-start — no operations file; one method per root field with an +# auto-generated selection set expanded to cn1.graphql.maxDepth (default 2): +mvn -pl common cn1:generate-graphql \ + -Dcn1.graphql.schema=schema.graphqls \ + -Dcn1.graphql.basePackage=com.example.catalog +``` + +The schema's `enum` types become Java enums, `input`/object types become +`@Mapped` classes. In **operations mode** each named `query`/`mutation`/ +`subscription` becomes one interface method, its `$variables` become `@Var` +parameters, and a `Data` response model is synthesised from the +selection set. Schema-only mode is a convenience that may over- or under-fetch; +operations mode is the precise path. + +```java +@GraphQLClient("https://api.example.com/graphql") +public interface CatalogGraphApi { + @Query(value = "query Products($cat: Category) { products(category: $cat) { id name category } }", + operationName = "Products") + void products(@Var("cat") Category category, + @Header("Authorization") String bearer, + OnComplete> callback); + + @Mutation(value = "mutation Rate($id: ID!, $stars: Int!) { rate(id: $id, stars: $stars) { id stars } }", + operationName = "Rate") + void rate(@Var("id") String id, @Var("stars") int stars, + OnComplete> callback); + + @Subscription(value = "subscription OnRated($id: ID!) { rated(id: $id) { id stars } }", + operationName = "OnRated") + GraphQLSubscription onRated(@Var("id") String id, + GraphQLSubscription.Handler handler); + + static CatalogGraphApi of(String endpoint) { return GraphQLClients.create(CatalogGraphApi.class, endpoint); } +} + +// query/mutation — callback on the EDT +CatalogGraphApi.of(endpoint).products(Category.TOOLS, "Bearer " + token, response -> { + if (response.isOk()) { // HTTP 2xx AND no GraphQL errors + ProductsData data = response.getData(); + } else if (response.hasErrors()) { + Log.p(response.getResponseErrorMessage()); // first error message + } +}); + +// subscription — streamed over WebSocket (graphql-transport-ws). cancel() to stop. +GraphQLSubscription sub = CatalogGraphApi.of(endpoint).onRated("p-1", new GraphQLSubscription.Handler() { + public void onNext(OnRatedData data) { updateStars(data); } + public void onError(GraphQLResponse err) { Log.p(err.getResponseErrorMessage()); } + public void onComplete() { } +}); +// later: sub.cancel(); +``` + +`GraphQLResponse` is unusual in that GraphQL can return **data and errors at +once**: `getData()`, `getErrors()` (list of `GraphQLError`), `hasErrors()`, +`getHttpCode()`, `getResponseErrorMessage()` (first error), and `isOk()` (HTTP +2xx **and** no errors). Subscriptions need the WebSocket support in the runtime; +queries and mutations are plain HTTPS POST. + +## End-to-end reference + +`scripts/protocol-e2e/` in the framework repo is a runnable example of all three: +one Spring Boot server exposing the same catalog over REST, gRPC-Web, and +GraphQL, with a Codename One client that generates all three typed clients at +build time from a single shared `specs/` directory and round-trips against the +server via `cn1:test`. Read it when you need a concrete, working setup that +exercises enums, nested objects, list/repeated fields, and multiple methods. diff --git a/scripts/initializr/common/src/main/resources/skill/references/build-and-run.md b/scripts/initializr/common/src/main/resources/skill/references/build-and-run.md index 4e306b3791..71141093d4 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/build-and-run.md +++ b/scripts/initializr/common/src/main/resources/skill/references/build-and-run.md @@ -64,6 +64,24 @@ mvn -pl common cn1:generate-openapi \ -Dcn1.openapi.spec=petstore.json \ -Dcn1.openapi.basePackage=com.example.petstore +# Generate a typed gRPC-Web client from a .proto. Writes @ProtoMessage models +# (+ @ProtoEnum) and one @GrpcClient interface per service. Same processor +# pipeline as above. See references/api-clients.md. +mvn -pl common cn1:generate-grpc \ + -Dcn1.grpc.proto=catalog.proto \ + -Dcn1.grpc.basePackage=com.example.catalog + +# Generate a typed GraphQL client from a schema (+ optional named operations). +# Writes @Mapped models, schema enums, and a @GraphQLClient interface with +# @Query/@Mutation/@Subscription methods. With an operations file the generated +# methods/response models are precise; without one it falls back to a +# bounded-depth (cn1.graphql.maxDepth, default 2) selection set per root field. +mvn -pl common cn1:generate-graphql \ + -Dcn1.graphql.schema=schema.graphqls \ + -Dcn1.graphql.operations=operations.graphql \ + -Dcn1.graphql.basePackage=com.example.catalog \ + -Dcn1.graphql.clientName=CatalogGraphApi + # --- Cloud builds (need a Codename One account; some need Enterprise tier) --- # Native iOS app (.ipa). Cloud-built. diff --git a/scripts/initializr/common/src/main/resources/skill/references/java-api-subset.md b/scripts/initializr/common/src/main/resources/skill/references/java-api-subset.md index 96c0d63c91..d7421a25f0 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/java-api-subset.md +++ b/scripts/initializr/common/src/main/resources/skill/references/java-api-subset.md @@ -363,6 +363,8 @@ If no `Mapper` is registered for `Class` (typical cause: the class isn't ` For **bulk REST clients** (an existing OpenAPI 3.x spec, dozens of endpoints), use the `cn1:generate-openapi` Maven goal — it emits `@Mapped` records / classes per schema and one `@RestClient`-annotated interface per OpenAPI tag into `common/src/main/java`. The annotation processors run on the next compile and write the wire impls into generated-sources, so the implementation isn't part of the project source. Call sites instantiate via the generated `Api.of(baseUrl)` static factory. +This is one of **three** "spec to typed client" generators that share the same architecture — the others are **gRPC** (`cn1:generate-grpc` + `@GrpcClient`, over gRPC-Web) and **GraphQL** (`cn1:generate-graphql` + `@GraphQLClient`, with subscriptions over WebSocket). When the backend has a `.proto` or a GraphQL schema instead of (or alongside) an OpenAPI spec, read `references/api-clients.md` for the goal, the annotations, the response envelopes (`Response` / `GrpcResponse` / `GraphQLResponse`), and enum support. + ### Writing JSON — `JSONWriter` For ad-hoc request bodies use `com.codename1.io.JSONWriter`. Two access modes: diff --git a/scripts/protocol-e2e/server/pom.xml b/scripts/protocol-e2e/server/pom.xml index 88c5aef559..7bacf9d3a7 100644 --- a/scripts/protocol-e2e/server/pom.xml +++ b/scripts/protocol-e2e/server/pom.xml @@ -6,13 +6,13 @@