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/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/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..450ea174ac --- /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 doesn't +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 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 +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. + +==== 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 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`. +* `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. diff --git a/docs/developer-guide/appendix_goal_generate_openapi.adoc b/docs/developer-guide/appendix_goal_generate_openapi.adoc index c210cf9bec..fc9acf9d78 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 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 explosion of duplicates. 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..60e2049bfb --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateGraphQLMojo.java @@ -0,0 +1,970 @@ +/* + * 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"); + // 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"; + 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/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/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..026ba4847b --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/GenerateGraphQLMojoTest.java @@ -0,0 +1,180 @@ +/* + * 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); + // 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 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); + 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/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/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(); 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/README.md b/scripts/protocol-e2e/README.md new file mode 100644 index 0000000000..fa81945dc8 --- /dev/null +++ b/scripts/protocol-e2e/README.md @@ -0,0 +1,60 @@ +# Multi-protocol end-to-end test (REST / GraphQL / gRPC) + +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 + +- `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 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 + 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 +# 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 0000000000..1f4fa5dd25 Binary files /dev/null and b/scripts/protocol-e2e/client/common/icon.png differ diff --git a/scripts/protocol-e2e/client/common/pom.xml b/scripts/protocol-e2e/client/common/pom.xml new file mode 100644 index 0000000000..27cd37e7ea --- /dev/null +++ b/scripts/protocol-e2e/client/common/pom.xml @@ -0,0 +1,166 @@ + + + 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 + + + + generate-rest-client + generate-sources + generate-openapi + + ${project.basedir}/../../specs/openapi.json + com.codename1.e2e.rest + ${project.build.directory}/generated-sources/cn1 + + + + generate-grpc-client + generate-sources + generate-grpc + + ${project.basedir}/../../specs/catalog.proto + com.codename1.e2e.grpc + ${project.build.directory}/generated-sources/cn1 + + + + generate-graphql-client + generate-sources + generate-graphql + + ${project.basedir}/../../specs/schema.graphqls + ${project.basedir}/../../specs/operations.graphql + com.codename1.e2e.graphql + CatalogGraphApi + ${project.build.directory}/generated-sources/cn1 + + + + cn1-process-classes + process-classes + + bytecode-compliance + css + process-annotations + + + + attach-test-artifact + test + + attach-test-artifact + + + + + + 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 + + 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/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..6c8e99da37 --- /dev/null +++ b/scripts/protocol-e2e/client/common/src/test/java/com/codename1/e2e/GraphQlProtocolTest.java @@ -0,0 +1,85 @@ +package com.codename1.e2e; + +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 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 { + 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(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(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 new file mode 100644 index 0000000000..97b32589bb --- /dev/null +++ b/scripts/protocol-e2e/client/common/src/test/java/com/codename1/e2e/GrpcProtocolTest.java @@ -0,0 +1,62 @@ +package com.codename1.e2e; + +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 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 { + CatalogGrpc grpc = CatalogGrpc.of(E2eSupport.baseUrl() + "/grpc"); + + // --- 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(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"); + + // --- 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; + } + + @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..73a9336527 --- /dev/null +++ b/scripts/protocol-e2e/client/common/src/test/java/com/codename1/e2e/RestProtocolTest.java @@ -0,0 +1,81 @@ +package com.codename1.e2e; + +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; + +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 { + 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(d2); + assertTrue(d2[0], "REST getProduct timed out"); + assertNotNull(one[0], "REST getProduct null"); + assertEqual(Category.ELECTRONICS, one[0].category(), "REST getProduct enum"); + + // --- 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/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..ed25c03a91 --- /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/products" 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..7bacf9d3a7 --- /dev/null +++ b/scripts/protocol-e2e/server/pom.xml @@ -0,0 +1,83 @@ + + + 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 + + + + 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/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/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/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/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/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/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/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/openapi.json b/scripts/protocol-e2e/specs/openapi.json new file mode 100644 index 0000000000..dcc4f06e3d --- /dev/null +++ b/scripts/protocol-e2e/specs/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/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 new file mode 100644 index 0000000000..a17fea1e4a --- /dev/null +++ b/scripts/protocol-e2e/specs/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 +}