From 2d6823a38ca1233bba3b5d1d47139fd9526f0ffe Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 29 May 2026 21:16:15 +0300 Subject: [PATCH 1/5] Add gRPC-Web client codegen mirroring OpenAPI pattern Generate typed gRPC clients from proto3 .proto files the same way cn1:generate-openapi turns OpenAPI specs into REST clients: a Maven goal emits user-edited @ProtoMessage / @ProtoEnum / @GrpcClient sources into src/main/java, two build-time annotation processors emit the protobuf codecs + gRPC-Web call sites into target/generated-sources, and a pair of bootstrap classes plug everything into the runtime registries before Display.init. Wire protocol is gRPC-Web binary (application/grpc-web+proto) -- plain HTTP/2 gRPC requires trailers that ConnectionRequest does not expose, but gRPC-Web is the standard mobile/browser variant that works with Envoy, the official grpcweb Go proxy, and the gRPC-Web filter shipped with modern gRPC server implementations. Scope of v1: unary RPCs, proto3, all scalar types, nested messages, enums, repeated fields. Streaming, map, well-known types, and `import` are out -- the parser errors cleanly on each. Also closes a pre-existing gap where cn1app.RestClientBootstrap was generated by the OpenAPI processor but never spliced into the Executor stub / JavaSEPort class-forname loop. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../annotations/grpc/GrpcClient.java | 44 + .../codename1/annotations/grpc/ProtoEnum.java | 31 + .../annotations/grpc/ProtoField.java | 51 + .../annotations/grpc/ProtoMessage.java | 38 + .../com/codename1/annotations/grpc/Rpc.java | 36 + .../annotations/grpc/package-info.java | 54 ++ .../com/codename1/io/grpc/GrpcClients.java | 57 ++ .../com/codename1/io/grpc/GrpcException.java | 34 + .../com/codename1/io/grpc/GrpcResponse.java | 94 ++ .../src/com/codename1/io/grpc/GrpcWeb.java | 317 ++++++ .../src/com/codename1/io/grpc/ProtoCodec.java | 35 + .../com/codename1/io/grpc/ProtoCodecs.java | 55 ++ .../com/codename1/io/grpc/ProtoReader.java | 234 +++++ .../com/codename1/io/grpc/ProtoWriter.java | 263 +++++ .../com/codename1/io/grpc/package-info.java | 22 + .../com/codename1/impl/javase/JavaSEPort.java | 5 +- .../developer-guide/Maven-Appendix-Goals.adoc | 2 + .../appendix_goal_generate_grpc.adoc | 168 ++++ .../java/com/codename1/builders/Executor.java | 9 + .../com/codename1/maven/GenerateGrpcMojo.java | 905 ++++++++++++++++++ .../GrpcClientAnnotationProcessor.java | 445 +++++++++ .../ProtoMessageAnnotationProcessor.java | 733 ++++++++++++++ ...ame1.maven.annotations.AnnotationProcessor | 2 + .../com/codename1/io/grpc/GrpcWebTest.java | 131 +++ .../com/codename1/io/grpc/ProtoCodecTest.java | 180 ++++ .../codename1/maven/GenerateGrpcMojoTest.java | 157 +++ .../GrpcClientAnnotationProcessorTest.java | 220 +++++ .../ProtoMessageAnnotationProcessorTest.java | 206 ++++ 28 files changed, 4527 insertions(+), 1 deletion(-) create mode 100644 CodenameOne/src/com/codename1/annotations/grpc/GrpcClient.java create mode 100644 CodenameOne/src/com/codename1/annotations/grpc/ProtoEnum.java create mode 100644 CodenameOne/src/com/codename1/annotations/grpc/ProtoField.java create mode 100644 CodenameOne/src/com/codename1/annotations/grpc/ProtoMessage.java create mode 100644 CodenameOne/src/com/codename1/annotations/grpc/Rpc.java create mode 100644 CodenameOne/src/com/codename1/annotations/grpc/package-info.java create mode 100644 CodenameOne/src/com/codename1/io/grpc/GrpcClients.java create mode 100644 CodenameOne/src/com/codename1/io/grpc/GrpcException.java create mode 100644 CodenameOne/src/com/codename1/io/grpc/GrpcResponse.java create mode 100644 CodenameOne/src/com/codename1/io/grpc/GrpcWeb.java create mode 100644 CodenameOne/src/com/codename1/io/grpc/ProtoCodec.java create mode 100644 CodenameOne/src/com/codename1/io/grpc/ProtoCodecs.java create mode 100644 CodenameOne/src/com/codename1/io/grpc/ProtoReader.java create mode 100644 CodenameOne/src/com/codename1/io/grpc/ProtoWriter.java create mode 100644 CodenameOne/src/com/codename1/io/grpc/package-info.java create mode 100644 docs/developer-guide/appendix_goal_generate_grpc.adoc create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateGrpcMojo.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/GrpcClientAnnotationProcessor.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/ProtoMessageAnnotationProcessor.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/io/grpc/GrpcWebTest.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/io/grpc/ProtoCodecTest.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/GenerateGrpcMojoTest.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/GrpcClientAnnotationProcessorTest.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/ProtoMessageAnnotationProcessorTest.java diff --git a/CodenameOne/src/com/codename1/annotations/grpc/GrpcClient.java b/CodenameOne/src/com/codename1/annotations/grpc/GrpcClient.java new file mode 100644 index 0000000000..08c847e0d0 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/grpc/GrpcClient.java @@ -0,0 +1,44 @@ +/* + * 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.grpc; + +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 gRPC client that the build-time annotation +/// processor wires up to a generated gRPC-Web implementation. Each +/// abstract method must carry an [Rpc] annotation naming the gRPC +/// method, and at most one non-callback parameter -- the request +/// message, a `@ProtoMessage`-annotated POJO. The final parameter is +/// an `OnComplete>` callback. +/// +/// The fully qualified service path defaults to +/// `/` -- for example +/// `helloworld.Greeter/SayHello`. Override per-method via +/// [Rpc#service()] when the service path needs to differ from the +/// interface-level default. +/// +/// The processor emits a `Impl` class in generated-sources +/// and registers it with [com.codename1.io.grpc.GrpcClients] so the +/// interface's `static T of(String baseUrl)` factory can return an +/// instance without the project source referencing the impl directly. +/// Mirrors [com.codename1.annotations.rest.RestClient]. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface GrpcClient { + /// Fully qualified gRPC service path (without leading slash), e.g. + /// `helloworld.Greeter`. Combined with each method's [Rpc#value()] + /// to form the request URI segment `//` appended + /// to the `baseUrl`. Empty string means each method must specify + /// the full service path via [Rpc#service()]. + String value() default ""; +} diff --git a/CodenameOne/src/com/codename1/annotations/grpc/ProtoEnum.java b/CodenameOne/src/com/codename1/annotations/grpc/ProtoEnum.java new file mode 100644 index 0000000000..ff451e0689 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/grpc/ProtoEnum.java @@ -0,0 +1,31 @@ +/* + * 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.grpc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Marks a Java `enum` as a Protocol Buffers enum. The generator +/// emits the enum with a `public final int number` field and a +/// `static Xxx forNumber(int n)` lookup. On the wire enums are +/// encoded as varint -- the field's tag from [ProtoField] is read / +/// written like an `int32`, and the integer value is mapped back to +/// the enum constant via `forNumber`. +/// +/// Unknown numbers map to `null` so callers can distinguish "no +/// such constant" from "constant with number 0". For proto3 the +/// zero constant is the default and is what the wire produces when +/// the field is absent. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface ProtoEnum { +} diff --git a/CodenameOne/src/com/codename1/annotations/grpc/ProtoField.java b/CodenameOne/src/com/codename1/annotations/grpc/ProtoField.java new file mode 100644 index 0000000000..a62728dc6f --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/grpc/ProtoField.java @@ -0,0 +1,51 @@ +/* + * 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.grpc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Binds a [ProtoMessage] field to a protobuf field tag. Tags must +/// be positive, unique within the message, and correspond to the +/// tag declared in the upstream `.proto` file. +/// +/// Optional [#wireType()] forces a non-default scalar encoding for +/// integer fields. Defaults to [WireKind#DEFAULT], which selects +/// varint for `int32` / `int64` / `bool`, fixed32 / fixed64 for +/// `float` / `double`, and length-delimited for strings, byte arrays, +/// nested messages, and `repeated` packed scalars. Specify +/// [WireKind#SINT] for ZigZag-encoded signed integers, or +/// [WireKind#FIXED] for fixed-width unsigned integers (matches +/// `fixed32` / `fixed64` in proto3). +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface ProtoField { + /// Protobuf field tag (positive integer, unique per message). + int tag(); + + /// Optional override of the Java field name when the generator + /// had to rename it to a valid identifier. Carries the original + /// `.proto` name so introspection tooling can recover it. + String name() default ""; + + /// Non-default integer encoding selector. Has no effect for + /// non-integer fields. + WireKind wireType() default WireKind.DEFAULT; + + /// Integer encoding selectors. `DEFAULT` matches `int32` / + /// `int64` / `uint32` / `uint64` (varint). `SINT` matches + /// `sint32` / `sint64` (ZigZag-encoded varint). `FIXED` matches + /// `fixed32` / `fixed64` / `sfixed32` / `sfixed64` (fixed-width). + public enum WireKind { + DEFAULT, SINT, FIXED + } +} diff --git a/CodenameOne/src/com/codename1/annotations/grpc/ProtoMessage.java b/CodenameOne/src/com/codename1/annotations/grpc/ProtoMessage.java new file mode 100644 index 0000000000..0acdeb048d --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/grpc/ProtoMessage.java @@ -0,0 +1,38 @@ +/* + * 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.grpc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Marks a POJO (or Java 17+ record) as a Protocol Buffers message. +/// The Codename One Maven plugin scans every `@ProtoMessage` class at +/// build time and emits a reflection-free `ProtoCodec` next to it +/// that serializes / deserializes the class to / from the binary +/// protobuf wire format. The generated `cn1app.ProtoBootstrap` +/// registers every codec with [com.codename1.io.grpc.ProtoCodecs] so +/// generated gRPC clients can resolve nested message types by class +/// without reflection. +/// +/// Each persistable field on the class must carry [ProtoField] with a +/// unique tag. Field types may be: scalar (int / long / float / +/// double / boolean / String / byte[]), other `@ProtoMessage` types, +/// `@ProtoEnum`-marked enums, or `java.util.List` of any of the +/// above (interpreted as `repeated` in proto3). +/// +/// Mirrors the design of [com.codename1.annotations.Mapped] for JSON +/// projection; you can carry both annotations on the same class to +/// support JSON and protobuf wire formats off the same POJO. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface ProtoMessage { +} diff --git a/CodenameOne/src/com/codename1/annotations/grpc/Rpc.java b/CodenameOne/src/com/codename1/annotations/grpc/Rpc.java new file mode 100644 index 0000000000..aa4f585225 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/grpc/Rpc.java @@ -0,0 +1,36 @@ +/* + * 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.grpc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Binds a [GrpcClient] interface method to a gRPC unary RPC. The +/// `value` is the gRPC method name (e.g. `SayHello`) and is combined +/// with the interface-level [GrpcClient#value()] to form +/// `//`. The optional [#service()] overrides the +/// interface-level service path for a single method (useful when an +/// interface aggregates calls into multiple services). +/// +/// Streaming RPCs are not supported in this release -- only unary +/// (single request, single response). +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface Rpc { + /// The gRPC method name as declared in the `.proto` `rpc` line, + /// e.g. `SayHello`. Joined to the service path via `/`. + String value(); + + /// Optional override of the interface-level service path. Empty + /// string means inherit from [GrpcClient#value()]. + String service() default ""; +} diff --git a/CodenameOne/src/com/codename1/annotations/grpc/package-info.java b/CodenameOne/src/com/codename1/annotations/grpc/package-info.java new file mode 100644 index 0000000000..c4e2af4d4c --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/grpc/package-info.java @@ -0,0 +1,54 @@ +/* + * 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. + */ +/// gRPC-Web client annotations. [GrpcClient] marks an interface as a +/// gRPC client; [Rpc] binds each method to a unary RPC. +/// [ProtoMessage] marks a POJO as a protobuf message; +/// [ProtoField] tags each field; [ProtoEnum] marks an enum as +/// protobuf-encoded. +/// +/// The Codename One Maven plugin's `cn1:generate-grpc` mojo emits +/// these annotations from a user-supplied `.proto` file; the +/// `RestClient` / `ProtoMessage` annotation processors then emit +/// the implementation code at build time. End-to-end usage: +/// +/// ```java +/// // generated by cn1:generate-grpc +/// @ProtoMessage +/// public final class HelloRequest { +/// @ProtoField(tag = 1) public String name; +/// } +/// +/// @ProtoMessage +/// public final class HelloReply { +/// @ProtoField(tag = 1) public String message; +/// } +/// +/// @GrpcClient("helloworld.Greeter") +/// public interface GreeterGrpc { +/// @Rpc("SayHello") +/// void sayHello(HelloRequest req, +/// OnComplete> callback); +/// +/// static GreeterGrpc of(String baseUrl) { +/// return GrpcClients.create(GreeterGrpc.class, baseUrl); +/// } +/// } +/// +/// // call site +/// GreeterGrpc g = GreeterGrpc.of("https://api.example.com"); +/// HelloRequest req = new HelloRequest(); +/// req.name = "world"; +/// g.sayHello(req, resp -> { +/// if (resp.getResponseCode() == 200) { +/// System.out.println(resp.getResponseData().message); +/// } +/// }); +/// ``` +package com.codename1.annotations.grpc; diff --git a/CodenameOne/src/com/codename1/io/grpc/GrpcClients.java b/CodenameOne/src/com/codename1/io/grpc/GrpcClients.java new file mode 100644 index 0000000000..03f51d43b9 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/grpc/GrpcClients.java @@ -0,0 +1,57 @@ +/* + * 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.io.grpc; + +import java.util.HashMap; +import java.util.Map; + +/// Runtime registry that wires `@GrpcClient`-annotated interfaces +/// to the build-time-generated implementations. The generated +/// `cn1app.GrpcClientBootstrap` calls [#register(Class, Factory)] +/// for every gRPC interface in the project; user code reaches them +/// via the `static of(String baseUrl)` factory that +/// `cn1:generate-grpc` puts on each interface, and that factory in +/// turn calls [#create(Class, String)] here. +/// +/// Mirrors [com.codename1.io.rest.RestClients]. +public final class GrpcClients { + + private static final Map, Factory> REGISTRY = new HashMap, Factory>(); + + private GrpcClients() { + } + + /// Registers a factory for a `@GrpcClient`-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. + @SuppressWarnings("unchecked") + public static T create(Class apiType, String baseUrl) { + Factory factory = (Factory) REGISTRY.get(apiType); + if (factory == null) { + throw new IllegalStateException( + "No GrpcClient impl registered for " + apiType.getName() + + " -- did cn1:process-annotations run?"); + } + return factory.create(baseUrl); + } + + /// 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 baseUrl); + } +} diff --git a/CodenameOne/src/com/codename1/io/grpc/GrpcException.java b/CodenameOne/src/com/codename1/io/grpc/GrpcException.java new file mode 100644 index 0000000000..c0d0fd599f --- /dev/null +++ b/CodenameOne/src/com/codename1/io/grpc/GrpcException.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.io.grpc; + +/// Thrown when a synchronous helper observes a non-OK gRPC status +/// or a transport-level failure. The async callback path uses +/// [GrpcResponse] instead -- this is only for code paths that +/// prefer exceptions over inspecting `responseCode`. +public class GrpcException extends RuntimeException { + + private final int status; + private final int httpCode; + + public GrpcException(int status, int httpCode, String message) { + super(message == null ? ("gRPC status " + status) : message); + this.status = status; + this.httpCode = httpCode; + } + + public int getStatus() { + return status; + } + + public int getHttpCode() { + return httpCode; + } +} diff --git a/CodenameOne/src/com/codename1/io/grpc/GrpcResponse.java b/CodenameOne/src/com/codename1/io/grpc/GrpcResponse.java new file mode 100644 index 0000000000..e97a8ef16f --- /dev/null +++ b/CodenameOne/src/com/codename1/io/grpc/GrpcResponse.java @@ -0,0 +1,94 @@ +/* + * 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.io.grpc; + +/// Result of a unary gRPC call. Mirrors the shape of +/// [com.codename1.io.rest.Response] so call sites that already use +/// the REST helpers feel at home, but the integer status code +/// returned by [#getResponseCode()] is the **gRPC** status, not +/// the HTTP one: +/// +/// - `0` -- `OK`. Wire-level success. +/// - `1..16` -- standard gRPC statuses (CANCELLED, UNKNOWN, ...). +/// - `-1` -- the transport itself failed (network error, HTTP +/// non-200, missing status trailer). [#getHttpCode()] still +/// returns the underlying HTTP code in that case. +/// +/// Use [#isOk()] for the common "request succeeded" check. +public final class GrpcResponse { + + public static final int STATUS_OK = 0; + public static final int STATUS_CANCELLED = 1; + public static final int STATUS_UNKNOWN = 2; + public static final int STATUS_INVALID_ARGUMENT = 3; + public static final int STATUS_DEADLINE_EXCEEDED = 4; + public static final int STATUS_NOT_FOUND = 5; + public static final int STATUS_ALREADY_EXISTS = 6; + public static final int STATUS_PERMISSION_DENIED = 7; + public static final int STATUS_RESOURCE_EXHAUSTED = 8; + public static final int STATUS_FAILED_PRECONDITION = 9; + public static final int STATUS_ABORTED = 10; + public static final int STATUS_OUT_OF_RANGE = 11; + public static final int STATUS_UNIMPLEMENTED = 12; + public static final int STATUS_INTERNAL = 13; + public static final int STATUS_UNAVAILABLE = 14; + public static final int STATUS_DATA_LOSS = 15; + public static final int STATUS_UNAUTHENTICATED = 16; + + /// Sentinel status indicating the transport itself failed (no + /// `grpc-status` trailer was parsed). Inspect [#getHttpCode()] + /// for the underlying HTTP error. + public static final int STATUS_TRANSPORT_FAILURE = -1; + + private final int grpcStatus; + private final int httpCode; + private final T responseData; + private final String responseMessage; + + public GrpcResponse(int grpcStatus, int httpCode, T responseData, String responseMessage) { + this.grpcStatus = grpcStatus; + this.httpCode = httpCode; + this.responseData = responseData; + this.responseMessage = responseMessage; + } + + /// The deserialised response message, or `null` when the call + /// failed or the server returned an empty body. + public T getResponseData() { + return responseData; + } + + /// The gRPC status code. `0` is success; non-zero codes follow + /// the standard gRPC status enumeration. `-1` ([#STATUS_TRANSPORT_FAILURE]) + /// signals a transport-level error -- the call never reached + /// `grpc-status`. + public int getResponseCode() { + return grpcStatus; + } + + /// The underlying HTTP status code. Usually `200` even for a + /// non-zero gRPC status, because the server returns gRPC errors + /// in trailers rather than via HTTP status. Useful when + /// [#getResponseCode()] is [#STATUS_TRANSPORT_FAILURE]. + public int getHttpCode() { + return httpCode; + } + + /// `true` iff [#getResponseCode()] is `0` (gRPC `OK`). + public boolean isOk() { + return grpcStatus == STATUS_OK; + } + + /// The server-supplied `grpc-message` trailer (or the HTTP error + /// message when the call failed before reaching the trailer). + public String getResponseErrorMessage() { + return responseMessage; + } +} diff --git a/CodenameOne/src/com/codename1/io/grpc/GrpcWeb.java b/CodenameOne/src/com/codename1/io/grpc/GrpcWeb.java new file mode 100644 index 0000000000..caa44990bf --- /dev/null +++ b/CodenameOne/src/com/codename1/io/grpc/GrpcWeb.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. + */ +package com.codename1.io.grpc; + +import com.codename1.io.ConnectionRequest; +import com.codename1.io.Data; +import com.codename1.ui.CN; +import com.codename1.util.OnComplete; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +/// High-level gRPC-Web invoker used by generated `@GrpcClient` +/// implementations. Handles HTTP-level transport, gRPC-Web payload +/// framing (`flags + length + payload`), and trailer parsing +/// (`grpc-status`, `grpc-message`). +/// +/// Wire details (from the upstream gRPC-Web spec): +/// +/// - URL: `//` (no trailing slash). +/// - HTTP method: `POST`. +/// - Request `Content-Type`: `application/grpc-web+proto`. +/// - Request headers: `X-Grpc-Web: 1`, `X-User-Agent: grpc-web-cn1/`. +/// - Request body: `0x00 ` -- one data frame. +/// - Response body: zero or more data frames (flags low bit 0) +/// followed by one trailer frame (flags high bit `0x80`) carrying +/// `grpc-status:\r\ngrpc-message:\r\n`. +/// +/// All methods are static. Generated impls call [#invokeUnary] and +/// receive the parsed [GrpcResponse] on the supplied callback. +public final class GrpcWeb { + + /// Content type for binary gRPC-Web payloads. The text variant + /// (`application/grpc-web-text`, base64-encoded) is not + /// supported -- modern Envoy/gRPC-Web proxies all speak binary. + public static final String CONTENT_TYPE = "application/grpc-web+proto"; + + /// gRPC-Web frame flag set in the trailer frame's first byte. + public static final int FLAG_TRAILER = 0x80; + + private GrpcWeb() { + } + + /// Sends a unary gRPC-Web request and invokes `callback` with + /// the decoded response (or a transport-failure marker). + /// + /// The `baseUrl` should not include a trailing slash; the + /// `service` is the fully qualified service path + /// (e.g. `helloworld.Greeter`) and `method` is the gRPC method + /// name (e.g. `SayHello`). The resulting URL is + /// `//`. + public static void invokeUnary( + String baseUrl, + String service, + String method, + String bearerToken, + Req request, + ProtoCodec reqCodec, + ProtoCodec respCodec, + final OnComplete> callback) { + if (callback == null) { + throw new IllegalArgumentException("callback must not be null"); + } + byte[] framed; + try { + framed = frame(request, reqCodec); + } catch (IOException ioe) { + callback.completed(new GrpcResponse( + GrpcResponse.STATUS_INTERNAL, 0, null, + "Failed to encode request: " + ioe.getMessage())); + return; + } + + String url = joinUrl(baseUrl, service, method); + final GrpcConnection conn = new GrpcConnection(respCodec, callback); + conn.setUrl(url); + conn.setHttpMethod("POST"); + conn.setPost(true); + conn.setContentType(CONTENT_TYPE); + conn.addRequestHeader("X-Grpc-Web", "1"); + conn.addRequestHeader("Accept", CONTENT_TYPE); + if (bearerToken != null && bearerToken.length() > 0) { + conn.addRequestHeader("Authorization", bearerToken); + } + conn.setRequestBody(new ByteData(framed)); + CN.addToQueue(conn); + } + + /// Wraps a serialised request message in a single gRPC-Web data + /// frame: `[0x00][length-be32][payload]`. Public so unit tests + /// can verify framing independently of the network path. + public static byte[] frame(T request, ProtoCodec codec) throws IOException { + ByteArrayOutputStream bodyBuf = new ByteArrayOutputStream(); + ProtoWriter w = new ProtoWriter(bodyBuf); + codec.write(w, request); + byte[] body = bodyBuf.toByteArray(); + byte[] out = new byte[5 + body.length]; + out[0] = 0; // data frame, no compression + out[1] = (byte) ((body.length >>> 24) & 0xFF); + out[2] = (byte) ((body.length >>> 16) & 0xFF); + out[3] = (byte) ((body.length >>> 8) & 0xFF); + out[4] = (byte) (body.length & 0xFF); + System.arraycopy(body, 0, out, 5, body.length); + return out; + } + + /// Decodes a complete gRPC-Web response body. Iterates frames, + /// concatenates data payloads, and parses the trailer frame for + /// `grpc-status` / `grpc-message`. Public so callers can replay + /// canned responses in unit tests. + public static GrpcResponse decode(byte[] response, int httpCode, + ProtoCodec respCodec) { + if (response == null || response.length == 0) { + return new GrpcResponse(GrpcResponse.STATUS_TRANSPORT_FAILURE, + httpCode, null, "Empty response body"); + } + ByteArrayOutputStream payload = new ByteArrayOutputStream(); + int status = GrpcResponse.STATUS_OK; + String message = null; + boolean sawTrailer = false; + int pos = 0; + while (pos + 5 <= response.length) { + int flags = response[pos] & 0xFF; + int len = ((response[pos + 1] & 0xFF) << 24) + | ((response[pos + 2] & 0xFF) << 16) + | ((response[pos + 3] & 0xFF) << 8) + | (response[pos + 4] & 0xFF); + pos += 5; + if (pos + len > response.length) { + return new GrpcResponse(GrpcResponse.STATUS_TRANSPORT_FAILURE, + httpCode, null, "Truncated gRPC-Web frame (need " + + len + " bytes, have " + (response.length - pos) + ")"); + } + if ((flags & FLAG_TRAILER) != 0) { + String trailer; + try { + trailer = new String(response, pos, len, "UTF-8"); + } catch (java.io.UnsupportedEncodingException uee) { + trailer = ""; + } + int[] parsed = parseTrailerStatus(trailer); + status = parsed[0]; + message = trailerMessage(trailer); + sawTrailer = true; + } else { + payload.write(response, pos, len); + } + pos += len; + } + if (!sawTrailer) { + return new GrpcResponse(GrpcResponse.STATUS_TRANSPORT_FAILURE, + httpCode, null, + "gRPC-Web response is missing the trailer frame"); + } + if (status != GrpcResponse.STATUS_OK) { + return new GrpcResponse(status, httpCode, null, message); + } + Resp parsed; + try { + ProtoReader r = new ProtoReader(payload.toByteArray()); + parsed = respCodec.read(r); + } catch (IOException ioe) { + return new GrpcResponse(GrpcResponse.STATUS_INTERNAL, httpCode, null, + "Failed to decode response: " + ioe.getMessage()); + } + return new GrpcResponse(GrpcResponse.STATUS_OK, httpCode, parsed, null); + } + + private static int[] parseTrailerStatus(String trailer) { + // Lines are CRLF-separated; the spec allows LF too. + int idx = indexOfHeader(trailer, "grpc-status"); + if (idx < 0) return new int[]{GrpcResponse.STATUS_UNKNOWN}; + int eol = endOfLine(trailer, idx); + String value = trailer.substring(idx, eol).trim(); + try { + return new int[]{Integer.parseInt(value)}; + } catch (NumberFormatException nfe) { + return new int[]{GrpcResponse.STATUS_UNKNOWN}; + } + } + + private static String trailerMessage(String trailer) { + int idx = indexOfHeader(trailer, "grpc-message"); + if (idx < 0) return null; + int eol = endOfLine(trailer, idx); + return trailer.substring(idx, eol).trim(); + } + + /// Returns the character index just past the `:` prefix, + /// or -1 if `name` is absent. Header names are matched + /// case-insensitively per gRPC-Web spec. + private static int indexOfHeader(String trailer, String name) { + int i = 0; + int n = trailer.length(); + int nameLen = name.length(); + while (i < n) { + int lineStart = i; + // Scan to end of line. + int eol = i; + while (eol < n && trailer.charAt(eol) != '\n' && trailer.charAt(eol) != '\r') { + eol++; + } + int colon = trailer.indexOf(':', lineStart); + if (colon >= 0 && colon < eol && (colon - lineStart) == nameLen) { + if (trailer.regionMatches(true, lineStart, name, 0, nameLen)) { + return colon + 1; + } + } + // Skip CRLF / LF. + i = eol; + if (i < n && trailer.charAt(i) == '\r') i++; + if (i < n && trailer.charAt(i) == '\n') i++; + } + return -1; + } + + private static int endOfLine(String s, int from) { + int n = s.length(); + int i = from; + while (i < n && s.charAt(i) != '\n' && s.charAt(i) != '\r') { + i++; + } + return i; + } + + private static String joinUrl(String baseUrl, String service, String method) { + StringBuilder sb = new StringBuilder(); + sb.append(baseUrl == null ? "" : baseUrl); + if (sb.length() > 0 && sb.charAt(sb.length() - 1) == '/') { + sb.setLength(sb.length() - 1); + } + sb.append('/').append(service).append('/').append(method); + return sb.toString(); + } + + /// Body adapter so a raw `byte[]` can flow through + /// `ConnectionRequest.setRequestBody(Data)` without an extra + /// copy. + private static final class ByteData implements Data { + private final byte[] body; + + ByteData(byte[] body) { + this.body = body; + } + + @Override + public void appendTo(OutputStream output) throws IOException { + output.write(body); + } + + @Override + public long getSize() throws IOException { + return body.length; + } + } + + /// Subclasses `ConnectionRequest` so we can suppress its default + /// "non-2xx is an error" handling -- gRPC-Web always returns + /// 200 OK and surfaces failures in the trailer, so a 4xx/5xx + /// from the proxy itself is the only HTTP-level failure mode we + /// need to translate. + private static final class GrpcConnection extends ConnectionRequest { + private final ProtoCodec respCodec; + private final OnComplete callback; + private boolean failed; + private int failedCode; + private String failedMessage; + + GrpcConnection(ProtoCodec respCodec, OnComplete callback) { + this.respCodec = respCodec; + this.callback = callback; + // Disable framework's modal error dialog; we surface + // failures via the callback. + setFailSilently(true); + } + + @Override + protected void handleErrorResponseCode(int code, String message) { + failed = true; + failedCode = code; + failedMessage = message; + // Do not call super -- swallow the framework default + // (which would post an error event); we report through + // the callback in postResponse. + } + + @Override + protected void handleException(Exception err) { + failed = true; + failedCode = 0; + failedMessage = err.getMessage(); + } + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + protected void postResponse() { + super.postResponse(); + if (failed) { + callback.completed(new GrpcResponse(GrpcResponse.STATUS_TRANSPORT_FAILURE, + failedCode, null, failedMessage)); + return; + } + byte[] body = getResponseData(); + GrpcResponse parsed = decode(body, getResponseCode(), respCodec); + callback.completed(parsed); + } + } + +} diff --git a/CodenameOne/src/com/codename1/io/grpc/ProtoCodec.java b/CodenameOne/src/com/codename1/io/grpc/ProtoCodec.java new file mode 100644 index 0000000000..620b6d2ea7 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/grpc/ProtoCodec.java @@ -0,0 +1,35 @@ +/* + * 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.io.grpc; + +import java.io.IOException; + +/// Per-class encoder / decoder for a `@ProtoMessage` type. The +/// Codename One Maven plugin emits one `ProtoCodec` per +/// `@ProtoMessage` class at build time and registers it with +/// [ProtoCodecs] so gRPC clients can locate codecs for nested +/// message types by class without reflection. +/// +/// Implementations must be stateless -- the same codec instance is +/// shared across concurrent requests. +public interface ProtoCodec { + + /// Writes `value`'s populated fields to `out` in the protobuf + /// binary wire format. Does not write a length prefix; callers + /// that need length-delimited framing (such as nested messages + /// or gRPC payload framing) handle that one layer up. + void write(ProtoWriter out, T value) throws IOException; + + /// Reads a message from `in` and returns the populated instance. + /// `in` is expected to be positioned at the start of the message + /// body (no length prefix); callers wrap a slice when reading + /// nested messages. + T read(ProtoReader in) throws IOException; +} diff --git a/CodenameOne/src/com/codename1/io/grpc/ProtoCodecs.java b/CodenameOne/src/com/codename1/io/grpc/ProtoCodecs.java new file mode 100644 index 0000000000..fbd2abdb36 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/grpc/ProtoCodecs.java @@ -0,0 +1,55 @@ +/* + * 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.io.grpc; + +import java.util.HashMap; +import java.util.Map; + +/// Runtime registry that wires `@ProtoMessage` classes to their +/// build-time-generated [ProtoCodec]s. The generated +/// `cn1app.ProtoBootstrap` calls [#register(Class, ProtoCodec)] for +/// every protobuf message in the project; generated gRPC client +/// impls and per-class codecs call [#lookup(Class)] to resolve +/// nested-message types at runtime. +/// +/// Mirrors [com.codename1.io.rest.RestClients]. +public final class ProtoCodecs { + + private static final Map, ProtoCodec> REGISTRY = new HashMap, ProtoCodec>(); + + private ProtoCodecs() { + } + + /// Registers a codec for a `@ProtoMessage` class. Called from + /// the generated `cn1app.ProtoBootstrap`. + public static void register(Class type, ProtoCodec codec) { + if (type == null || codec == null) { + return; + } + REGISTRY.put(type, codec); + } + + /// Returns the codec previously registered for `type`. Throws + /// [IllegalStateException] when no codec is registered so the + /// failure is loud rather than silent (typical cause: the + /// generated `cn1app.ProtoBootstrap` hasn't run, or `type` isn't + /// annotated `@ProtoMessage`). + @SuppressWarnings("unchecked") + public static ProtoCodec lookup(Class type) { + ProtoCodec c = REGISTRY.get(type); + if (c == null) { + throw new IllegalStateException( + "No ProtoCodec registered for " + type.getName() + + " -- did cn1:process-annotations run and is the " + + "class annotated @ProtoMessage?"); + } + return (ProtoCodec) c; + } +} diff --git a/CodenameOne/src/com/codename1/io/grpc/ProtoReader.java b/CodenameOne/src/com/codename1/io/grpc/ProtoReader.java new file mode 100644 index 0000000000..6c75135cab --- /dev/null +++ b/CodenameOne/src/com/codename1/io/grpc/ProtoReader.java @@ -0,0 +1,234 @@ +/* + * 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.io.grpc; + +import java.io.ByteArrayInputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.UnsupportedEncodingException; + +/// Low-level protobuf wire-format reader used by generated +/// [ProtoCodec] implementations. Wraps a `byte[]` so the reader +/// can [#remaining()] and slice nested-message bodies without +/// re-buffering. +/// +/// Generated codecs call [#readTag()] in a loop, dispatch on the +/// field number, and call the appropriate `readXxx` method. Unknown +/// fields are passed to [#skipField(int)] -- the same recovery +/// behaviour `protoc`-generated code exhibits. +public final class ProtoReader { + + private final byte[] buf; + private final int limit; + private int pos; + + public ProtoReader(byte[] data) { + this(data, 0, data == null ? 0 : data.length); + } + + public ProtoReader(byte[] data, int offset, int length) { + this.buf = data; + this.pos = offset; + this.limit = offset + length; + } + + public boolean isAtEnd() { + return pos >= limit; + } + + public int remaining() { + return limit - pos; + } + + /// Reads the next tag (`(fieldNumber << 3) | wireType`) or + /// returns 0 when the stream is at EOF. Generated codecs use + /// the 0 sentinel as the loop exit condition. + public int readTag() throws IOException { + if (isAtEnd()) return 0; + return readVarint32(); + } + + /// Skips a single field whose tag has already been consumed. + /// Wire type extracted from `tag` (`tag & 0x7`). + public void skipField(int tag) throws IOException { + int wire = tag & 0x7; + switch (wire) { + case ProtoWriter.WIRE_VARINT: + readVarint64(); + return; + case ProtoWriter.WIRE_I64: + advance(8); + return; + case ProtoWriter.WIRE_LEN: { + int len = readVarint32(); + advance(len); + return; + } + case ProtoWriter.WIRE_I32: + advance(4); + return; + default: + throw new IOException("Unsupported wire type " + wire + + " for field " + (tag >>> 3)); + } + } + + // -- Primitive readers -------------------------------------------- + + public int readVarint32() throws IOException { + return (int) readVarint64(); + } + + public long readVarint64() throws IOException { + long result = 0L; + int shift = 0; + while (shift < 64) { + int b = readByte(); + result |= (long) (b & 0x7F) << shift; + if ((b & 0x80) == 0) { + return result; + } + shift += 7; + } + throw new IOException("Malformed varint -- exceeds 10 bytes"); + } + + public int readFixed32() throws IOException { + ensure(4); + int b0 = buf[pos++] & 0xFF; + int b1 = buf[pos++] & 0xFF; + int b2 = buf[pos++] & 0xFF; + int b3 = buf[pos++] & 0xFF; + return b0 | (b1 << 8) | (b2 << 16) | (b3 << 24); + } + + public long readFixed64() throws IOException { + ensure(8); + long lo = readFixed32() & 0xFFFFFFFFL; + long hi = readFixed32() & 0xFFFFFFFFL; + return (hi << 32) | lo; + } + + public float readFloat() throws IOException { + return Float.intBitsToFloat(readFixed32()); + } + + public double readDouble() throws IOException { + return Double.longBitsToDouble(readFixed64()); + } + + public boolean readBool() throws IOException { + return readVarint64() != 0L; + } + + public static int zagZig32(int n) { + return (n >>> 1) ^ -(n & 1); + } + + public static long zagZig64(long n) { + return (n >>> 1) ^ -(n & 1L); + } + + public int readSInt32() throws IOException { + return zagZig32(readVarint32()); + } + + public long readSInt64() throws IOException { + return zagZig64(readVarint64()); + } + + public String readString() throws IOException { + int len = readVarint32(); + ensure(len); + try { + String s = new String(buf, pos, len, "UTF-8"); + pos += len; + return s; + } catch (UnsupportedEncodingException uee) { + throw new RuntimeException(uee); + } + } + + public byte[] readBytes() throws IOException { + int len = readVarint32(); + ensure(len); + byte[] out = new byte[len]; + System.arraycopy(buf, pos, out, 0, len); + pos += len; + return out; + } + + /// Reads a length-delimited sub-message via `codec`. + public T readMessage(ProtoCodec codec) throws IOException { + int len = readVarint32(); + ensure(len); + ProtoReader sub = new ProtoReader(buf, pos, len); + pos += len; + return codec.read(sub); + } + + /// Reads a packed `repeated` scalar segment and appends each + /// element to `target` using the supplied [PackedReader] strategy. + public void readPacked(java.util.List target, PackedReader strategy) throws IOException { + int len = readVarint32(); + ensure(len); + int end = pos + len; + ProtoReader sub = new ProtoReader(buf, pos, len); + while (!sub.isAtEnd()) { + target.add(strategy.read(sub)); + } + pos = end; + } + + private int readByte() throws IOException { + if (pos >= limit) throw new EOFException("Unexpected end of protobuf stream"); + return buf[pos++] & 0xFF; + } + + private void ensure(int n) throws IOException { + if (n < 0) throw new IOException("Negative length"); + if (pos + n > limit) { + throw new EOFException("Truncated protobuf stream: need " + n + + " more bytes but " + (limit - pos) + " remaining"); + } + } + + private void advance(int n) throws IOException { + ensure(n); + pos += n; + } + + /// Strategy hook for [#readPacked]. Lets the generated codec + /// reuse this reader without committing to a fixed element type. + public interface PackedReader { + T read(ProtoReader in) throws IOException; + } + + /// Wraps the supplied byte array in a fresh reader. Convenience + /// for unit tests; production code allocates the reader directly. + public static ProtoReader of(byte[] data) { + return new ProtoReader(data == null ? new byte[0] : data); + } + + /// Convenience helper for callers that prefer `ByteArrayInputStream` + /// over byte arrays. Not used internally but kept for symmetry + /// with [ProtoWriter#stream()]. + public static byte[] drain(ByteArrayInputStream in) throws IOException { + int n = in.available(); + byte[] out = new byte[n]; + int read = 0; + while (read < n) { + int r = in.read(out, read, n - read); + if (r < 0) throw new EOFException(); + read += r; + } + return out; + } +} diff --git a/CodenameOne/src/com/codename1/io/grpc/ProtoWriter.java b/CodenameOne/src/com/codename1/io/grpc/ProtoWriter.java new file mode 100644 index 0000000000..11e015d82e --- /dev/null +++ b/CodenameOne/src/com/codename1/io/grpc/ProtoWriter.java @@ -0,0 +1,263 @@ +/* + * 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.io.grpc; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; + +/// Low-level protobuf wire-format writer used by generated +/// [ProtoCodec] implementations. Provides per-scalar-type field +/// emitters that skip default values (proto3 default-omission +/// behaviour) so the byte sequence matches what `protoc`-generated +/// code would emit for the same input. +/// +/// Wire types (3-bit tag suffix): +/// +/// | Code | Name | Used for | +/// |------|--------------|---------------------------------------------| +/// | 0 | VARINT | int32, int64, uint32, uint64, sint*, bool, enum | +/// | 1 | I64 | fixed64, sfixed64, double | +/// | 2 | LEN | string, bytes, embedded messages, packed repeated | +/// | 5 | I32 | fixed32, sfixed32, float | +public final class ProtoWriter { + + public static final int WIRE_VARINT = 0; + public static final int WIRE_I64 = 1; + public static final int WIRE_LEN = 2; + public static final int WIRE_I32 = 5; + + private final OutputStream out; + + public ProtoWriter(OutputStream out) { + this.out = out; + } + + /// Backing stream so generated codecs can stage a sub-message + /// into a `ByteArrayOutputStream` and length-prefix the result. + public OutputStream stream() { + return out; + } + + // -- Primitive emitters ------------------------------------------- + + public void writeTag(int fieldNumber, int wireType) throws IOException { + writeVarint32((fieldNumber << 3) | wireType); + } + + public void writeVarint32(int value) throws IOException { + // Per protobuf spec, int32 is sign-extended to 64 bits before + // varint encoding. Cast to long with sign extension so negative + // ints occupy 10 bytes on the wire (matching protoc). + writeVarint64((long) value); + } + + public void writeVarint64(long value) throws IOException { + while (true) { + if ((value & ~0x7FL) == 0L) { + out.write((int) value); + return; + } + out.write(((int) value & 0x7F) | 0x80); + value >>>= 7; + } + } + + public void writeFixed32(int value) throws IOException { + out.write(value & 0xFF); + out.write((value >>> 8) & 0xFF); + out.write((value >>> 16) & 0xFF); + out.write((value >>> 24) & 0xFF); + } + + public void writeFixed64(long value) throws IOException { + out.write((int) (value & 0xFF)); + out.write((int) ((value >>> 8) & 0xFF)); + out.write((int) ((value >>> 16) & 0xFF)); + out.write((int) ((value >>> 24) & 0xFF)); + out.write((int) ((value >>> 32) & 0xFF)); + out.write((int) ((value >>> 40) & 0xFF)); + out.write((int) ((value >>> 48) & 0xFF)); + out.write((int) ((value >>> 56) & 0xFF)); + } + + public static int zigZag32(int n) { + return (n << 1) ^ (n >> 31); + } + + public static long zigZag64(long n) { + return (n << 1) ^ (n >> 63); + } + + // -- Field helpers ------------------------------------------------ + // + // Each per-type helper skips default values (proto3 semantics). + // Generated codecs call these unconditionally so the per-field + // emit code is one line. + + public void writeInt32(int field, int value) throws IOException { + if (value == 0) return; + writeTag(field, WIRE_VARINT); + writeVarint32(value); + } + + public void writeInt64(int field, long value) throws IOException { + if (value == 0L) return; + writeTag(field, WIRE_VARINT); + writeVarint64(value); + } + + public void writeUInt32(int field, int value) throws IOException { + if (value == 0) return; + writeTag(field, WIRE_VARINT); + // uint32 is encoded as unsigned varint -- mask to 32 bits. + writeVarint64(value & 0xFFFFFFFFL); + } + + public void writeUInt64(int field, long value) throws IOException { + if (value == 0L) return; + writeTag(field, WIRE_VARINT); + writeVarint64(value); + } + + public void writeSInt32(int field, int value) throws IOException { + if (value == 0) return; + writeTag(field, WIRE_VARINT); + writeVarint64(zigZag32(value) & 0xFFFFFFFFL); + } + + public void writeSInt64(int field, long value) throws IOException { + if (value == 0L) return; + writeTag(field, WIRE_VARINT); + writeVarint64(zigZag64(value)); + } + + public void writeFixed32Field(int field, int value) throws IOException { + if (value == 0) return; + writeTag(field, WIRE_I32); + writeFixed32(value); + } + + public void writeFixed64Field(int field, long value) throws IOException { + if (value == 0L) return; + writeTag(field, WIRE_I64); + writeFixed64(value); + } + + public void writeBool(int field, boolean value) throws IOException { + if (!value) return; + writeTag(field, WIRE_VARINT); + out.write(1); + } + + public void writeFloat(int field, float value) throws IOException { + if (value == 0.0f) return; + writeTag(field, WIRE_I32); + writeFixed32(Float.floatToRawIntBits(value)); + } + + public void writeDouble(int field, double value) throws IOException { + if (value == 0.0d) return; + writeTag(field, WIRE_I64); + writeFixed64(Double.doubleToRawLongBits(value)); + } + + public void writeString(int field, String value) throws IOException { + if (value == null || value.length() == 0) return; + writeBytes(field, utf8(value)); + } + + public void writeBytes(int field, byte[] value) throws IOException { + if (value == null || value.length == 0) return; + writeTag(field, WIRE_LEN); + writeVarint32(value.length); + out.write(value); + } + + /// Writes a nested `@ProtoMessage` value as a length-delimited + /// field. Generated codecs call into [ProtoCodecs#lookup(Class)] + /// to find the nested codec. + public void writeMessage(int field, T value, ProtoCodec codec) throws IOException { + if (value == null) return; + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + ProtoWriter sub = new ProtoWriter(buf); + codec.write(sub, value); + byte[] body = buf.toByteArray(); + writeTag(field, WIRE_LEN); + writeVarint32(body.length); + out.write(body); + } + + /// Writes a `repeated` field of nested messages (one tag + length + /// prefix per element -- proto3 doesn't pack length-delimited + /// repeated entries). + public void writeMessageList(int field, java.util.List values, + ProtoCodec codec) throws IOException { + if (values == null || values.isEmpty()) return; + for (int i = 0, n = values.size(); i < n; i++) { + writeMessage(field, values.get(i), codec); + } + } + + /// Writes a `repeated` field of strings (one tag + length prefix + /// per element). + public void writeStringList(int field, java.util.List values) throws IOException { + if (values == null || values.isEmpty()) return; + for (int i = 0, n = values.size(); i < n; i++) { + String v = values.get(i); + if (v == null) continue; + byte[] body = utf8(v); + writeTag(field, WIRE_LEN); + writeVarint32(body.length); + out.write(body); + } + } + + /// Writes a packed `repeated int32` field (proto3 default packing + /// for scalar lists). + public void writePackedInt32(int field, java.util.List values) throws IOException { + if (values == null || values.isEmpty()) return; + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + ProtoWriter sub = new ProtoWriter(buf); + for (int i = 0, n = values.size(); i < n; i++) { + Integer v = values.get(i); + sub.writeVarint32(v == null ? 0 : v.intValue()); + } + byte[] body = buf.toByteArray(); + writeTag(field, WIRE_LEN); + writeVarint32(body.length); + out.write(body); + } + + /// Writes a packed `repeated int64` field. + public void writePackedInt64(int field, java.util.List values) throws IOException { + if (values == null || values.isEmpty()) return; + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + ProtoWriter sub = new ProtoWriter(buf); + for (int i = 0, n = values.size(); i < n; i++) { + Long v = values.get(i); + sub.writeVarint64(v == null ? 0L : v.longValue()); + } + byte[] body = buf.toByteArray(); + writeTag(field, WIRE_LEN); + writeVarint32(body.length); + out.write(body); + } + + private static byte[] utf8(String s) { + try { + return s.getBytes("UTF-8"); + } catch (UnsupportedEncodingException uee) { + // UTF-8 is mandatory on every JVM CN1 targets. + throw new RuntimeException(uee); + } + } +} diff --git a/CodenameOne/src/com/codename1/io/grpc/package-info.java b/CodenameOne/src/com/codename1/io/grpc/package-info.java new file mode 100644 index 0000000000..5a5dfac9f5 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/grpc/package-info.java @@ -0,0 +1,22 @@ +/* + * 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. + */ +/// gRPC-Web client runtime. [GrpcWeb] sends framed protobuf +/// requests and parses the trailer-delimited response; [ProtoCodec] +/// is the per-message encoder/decoder contract; [ProtoCodecs] is +/// the runtime registry the build-time-generated codecs populate; +/// [ProtoWriter] / [ProtoReader] are the low-level wire helpers; +/// [GrpcResponse] is the typed result wrapper; [GrpcClients] is +/// the per-`@GrpcClient` factory registry. +/// +/// End-to-end usage is documented on +/// [com.codename1.annotations.grpc] -- the user-facing entry point +/// is the generated `Grpc.of(baseUrl)` factory rather than +/// any class in this package directly. +package com.codename1.io.grpc; diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 0ab3e04a36..fdded362d6 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -7280,7 +7280,10 @@ public void postInit() { for (String bootstrap : new String[] { "cn1app.MapperBootstrap", "cn1app.BinderBootstrap", - "cn1app.DaoBootstrap"}) { + "cn1app.DaoBootstrap", + "cn1app.RestClientBootstrap", + "cn1app.ProtoBootstrap", + "cn1app.GrpcClientBootstrap"}) { try { Class.forName(bootstrap).newInstance(); } catch (ClassNotFoundException ignored) { diff --git a/docs/developer-guide/Maven-Appendix-Goals.adoc b/docs/developer-guide/Maven-Appendix-Goals.adoc index 7492d8f995..3331d57800 100644 --- a/docs/developer-guide/Maven-Appendix-Goals.adoc +++ b/docs/developer-guide/Maven-Appendix-Goals.adoc @@ -25,6 +25,8 @@ include::appendix_goal_generate_native_interfaces.adoc[] include::appendix_goal_generate_openapi.adoc[] +include::appendix_goal_generate_grpc.adoc[] + include::appendix_goal_guibuilder.adoc[] include::appendix_goal_generate_archetype.adoc[] diff --git a/docs/developer-guide/appendix_goal_generate_grpc.adoc b/docs/developer-guide/appendix_goal_generate_grpc.adoc new file mode 100644 index 0000000000..f5bb3ad029 --- /dev/null +++ b/docs/developer-guide/appendix_goal_generate_grpc.adoc @@ -0,0 +1,168 @@ +=== Generate gRPC client (`generate-grpc`) + +Generates a typed Codename One gRPC-Web client from a proto3 `.proto` +specification. Writes one `@ProtoMessage` record (Java 17+) or class +(Java 8 target) per `message`, one `@ProtoEnum` enum per `enum`, and +one `@GrpcClient`-annotated interface per `service`. The generated +files land in `common/src/main/java` so the project owns the +contract; the matching protobuf codec and gRPC-Web call site 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: `@ProtoMessage` triggers per-class binary protobuf codec +generation and a `cn1app.ProtoBootstrap` registration entry; +`@GrpcClient` triggers per-interface `GrpcImpl` generation +chained through `com.codename1.io.grpc.GrpcWeb.invokeUnary(...)` +and a `cn1app.GrpcClientBootstrap` that wires everything to the +`com.codename1.io.grpc.GrpcClients` registry (same splice pattern +as `@Mapped` mappers). + +==== Usage example + +[source, bash] +---- +mvn -pl common cn1:generate-grpc \ + -Dcn1.grpc.proto=helloworld.proto \ + -Dcn1.grpc.basePackage=com.example.hello +---- + +Configuration: + +[cols="1,3", options="header"] +|=== +| Property | Description + +| `-Dcn1.grpc.proto=PATH` +| Local path to the `.proto` file (proto3 syntax). + +| `-Dcn1.grpc.basePackage=PKG` +| Java package the generated sources are written under. Messages, +enums, and the `@GrpcClient` interface all land directly in +`` (no nested model package -- proto namespaces are +flat by design). + +| `-Dcn1.grpc.outputDirectory=DIR` (optional) +| Defaults to `${project.basedir}/src/main/java`. + +| `-Dcn1.grpc.overwrite=false` (optional) +| Defaults to `true`. Set to `false` to preserve user edits to +existing files (only missing files are written). +|=== + +==== Generated output + +For a `helloworld.proto` declaring `message HelloRequest`, +`message HelloReply`, `enum Mood`, and `service Greeter { rpc +SayHello(HelloRequest) returns (HelloReply); }`, the goal emits +under `common/src/main/java`: + +[listing] +---- +com/example/hello/ + HelloRequest.java // @ProtoMessage record / class + HelloReply.java + Mood.java // @ProtoEnum enum with `number` accessor + GreeterGrpc.java // @GrpcClient interface +---- + +Each `@ProtoMessage` carries one `@ProtoField(tag = N)` per field; +scalar fields use the default varint wire kind, `sint32` / `sint64` +use `wireType = ProtoField.WireKind.SINT`, and `fixed32` / +`fixed64` / `sfixed32` / `sfixed64` use +`wireType = ProtoField.WireKind.FIXED`. Renamed fields (e.g. +`snake_case` -> `camelCase`) carry the original proto name via the +optional `name` attribute so introspection tooling can recover it. + +The `@GrpcClient` interface looks like: + +[source, java] +---- +@GrpcClient("helloworld.Greeter") +public interface GreeterGrpc { + + @Rpc("SayHello") + void sayHello(HelloRequest request, + @Header("Authorization") String bearerToken, + OnComplete> callback); + + static GreeterGrpc of(String baseUrl) { + return GrpcClients.create(GreeterGrpc.class, baseUrl); + } +} +---- + +Call sites use the static factory: + +[source, java] +---- +GreeterGrpc g = GreeterGrpc.of("https://api.example.com"); +HelloRequest req = new HelloRequest(); +req.name = "world"; +g.sayHello(req, "Bearer " + token, response -> { + if (response.isOk()) { + renderGreeting(response.getResponseData().message); + } else { + showError("gRPC status " + response.getResponseCode() + + ": " + response.getResponseErrorMessage()); + } +}); +---- + +The `GrpcImpl` class that actually performs the gRPC-Web +POST lives in `target/generated-sources` -- the project source +never references it directly. The build server probes the project +zip for `cn1app.ProtoBootstrap` and `cn1app.GrpcClientBootstrap` +and splices the registry wiring in, mirroring the existing +`cn1app.MapperBootstrap` pattern. + +==== Wire protocol + +The runtime speaks **gRPC-Web binary** +(`application/grpc-web+proto`) over HTTP/1.1, which is the standard +mobile / browser variant of gRPC supported by Envoy, the official +`grpcweb` Go proxy, and the gRPC-Web filter in modern gRPC server +implementations. Plain HTTP/2 gRPC requires trailers that +`ConnectionRequest` does not expose; gRPC-Web carries `grpc-status` +in a synthetic trailer frame in the response body instead. + +A successful call: + +. Encodes the request message via the build-time-generated +`ProtoCodec` for the request type. +. Wraps the encoded bytes in a 5-byte frame header +(`flag + length`) and posts to `//` +with `Content-Type: application/grpc-web+proto` and `X-Grpc-Web: 1`. +. Reads the response body, iterates frames, accumulates data +payload, and parses the trailer frame for `grpc-status` / +`grpc-message`. +. Decodes the accumulated payload via the response codec and +invokes the supplied `OnComplete>`. + +==== Scope + +* Unary RPCs (single request, single response). Streaming RPCs +(`stream` keyword on request or response) are rejected at +generation time -- gRPC-Web client streaming requires HTTP/2. +* proto3 syntax. proto2 `required` is rejected; `optional` is +accepted but treated as a nullable field. +* Scalar types: `int32`, `int64`, `uint32`, `uint64`, `sint32`, +`sint64`, `fixed32`, `fixed64`, `sfixed32`, `sfixed64`, `float`, +`double`, `bool`, `string`, `bytes`. +* `enum` declarations (nested or top-level) emit `@ProtoEnum` enums +with a `public final int number` field and a `static T +forNumber(int n)` lookup. +* Nested `message` declarations are emitted as top-level siblings +in the same package so the generated codec layout stays flat. +* `repeated` fields land as `java.util.List`. Scalar lists are +encoded packed on the wire (proto3 default); the reader accepts +both packed and unpacked forms. +* `oneof` collapses to a group of nullable fields on the parent +message -- the mutual-exclusion guarantee is lost but round-trips +work. Use application code to enforce the invariant if needed. +* Not yet supported: `map`, well-known types (Timestamp, +Empty, etc.), file `import`, streaming RPCs. These produce a clear +error from the parser pointing at the offending line. +* Authentication: a bearer token is exposed uniformly as a +`@Header("Authorization") String bearerToken` parameter on every +RPC method, mirroring the OpenAPI bearer-token slot. 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 56a3604b7f..934f8a693d 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 @@ -2045,6 +2045,15 @@ protected static String annotationFrameworksInstallSource(File sourceZip, String if (projectHasBootstrap(sourceZip, "cn1app/DaoBootstrap.class")) { sb.append(indent).append("new cn1app.DaoBootstrap();\n"); } + if (projectHasBootstrap(sourceZip, "cn1app/RestClientBootstrap.class")) { + sb.append(indent).append("new cn1app.RestClientBootstrap();\n"); + } + if (projectHasBootstrap(sourceZip, "cn1app/ProtoBootstrap.class")) { + sb.append(indent).append("new cn1app.ProtoBootstrap();\n"); + } + if (projectHasBootstrap(sourceZip, "cn1app/GrpcClientBootstrap.class")) { + sb.append(indent).append("new cn1app.GrpcClientBootstrap();\n"); + } return sb.toString(); } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateGrpcMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateGrpcMojo.java new file mode 100644 index 0000000000..9df796d9b3 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateGrpcMojo.java @@ -0,0 +1,905 @@ +/* + * 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 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 gRPC client sources from a +/// proto3 `.proto` specification. +/// +/// Invocation: +/// +/// ``` +/// mvn cn1:generate-grpc -Dcn1.grpc.proto=helloworld.proto \ +/// -Dcn1.grpc.basePackage=com.example.hello +/// ``` +/// +/// Outputs (under `` in `src/main/java`): +/// +/// - one `@ProtoMessage` record (Java 17+) or class (Java 8 target) +/// per `message` declared in the `.proto` file, with one +/// `@ProtoField(tag = N)` per field; +/// - one `@ProtoEnum` enum per `enum` declared in the file; +/// - one `@GrpcClient` interface per `service` declared in the file, +/// with one `@Rpc("Method")`-annotated method per `rpc` line. Each +/// method takes the request message, an `@Header("Authorization") +/// String bearerToken`, and an `OnComplete>` +/// callback. A `static of(String baseUrl)` factory wires the +/// interface to the runtime registry. +/// +/// Streaming RPCs (`stream` keyword on request or response) are not +/// supported in this release and produce a build error pointing at +/// the offending line. +@Mojo(name = "generate-grpc", + defaultPhase = LifecyclePhase.NONE, + requiresProject = true, + threadSafe = true) +public class GenerateGrpcMojo extends AbstractMojo { + + @Parameter(defaultValue = "${project}", readonly = true) + private MavenProject project; + + /// Path or URL of the `.proto` file. Override via + /// `-Dcn1.grpc.proto=...`. + @Parameter(property = "cn1.grpc.proto") + private String proto; + + /// Java base package the generated sources are emitted under. + /// Override via `-Dcn1.grpc.basePackage=...`. The proto `package` + /// declaration in the source `.proto` file does *not* override + /// this -- it is preserved separately as the gRPC service path. + @Parameter(property = "cn1.grpc.basePackage") + private String basePackage; + + /// Output directory for the generated sources. Defaults to + /// `${project.basedir}/src/main/java` because the emitted code is + /// user-edited. + @Parameter(property = "cn1.grpc.outputDirectory", + defaultValue = "${project.basedir}/src/main/java") + private File outputDirectory; + + /// When `true` (default) existing files at the destination are + /// overwritten. Pass `-Dcn1.grpc.overwrite=false` to keep your + /// hand-edits and only emit missing files. + @Parameter(property = "cn1.grpc.overwrite", defaultValue = "true") + private boolean overwrite; + + @Override + public void execute() throws MojoExecutionException, MojoFailureException { + String effectiveProto = effective(proto, "cn1.grpc.proto"); + String effectivePackage = effective(basePackage, "cn1.grpc.basePackage"); + if (effectiveProto == null || effectiveProto.length() == 0) { + throw new MojoFailureException( + "No .proto file supplied. Pass -Dcn1.grpc.proto= " + + "or configure in the plugin block."); + } + if (effectivePackage == null || effectivePackage.length() == 0) { + throw new MojoFailureException( + "No base package supplied. Pass -Dcn1.grpc.basePackage= " + + "or configure in the plugin block."); + } + + File protoFile = new File(effectiveProto); + if (!protoFile.exists()) { + throw new MojoFailureException("Proto file not found: " + protoFile); + } + String source; + try { + source = new String(Files.readAllBytes(protoFile.toPath()), StandardCharsets.UTF_8); + } catch (IOException ioe) { + throw new MojoFailureException("Could not read " + protoFile + ": " + ioe.getMessage(), ioe); + } + + ProtoFile parsed; + try { + parsed = new ProtoParser(source, protoFile.getName()).parseFile(); + } catch (ProtoParseException ppe) { + throw new MojoFailureException(ppe.getMessage(), ppe); + } + + int target = GenerateOpenApiMojo.parseJavaVersion(detectJavaTargetString()); + boolean emitRecords = target >= 17; + getLog().info("cn1:generate-grpc target=" + target + " emitRecords=" + emitRecords + + " basePackage=" + effectivePackage + + " protoPackage=" + (parsed.protoPackage == null ? "(none)" : parsed.protoPackage)); + + Generator gen = new Generator(parsed, effectivePackage, outputDirectory, overwrite, + emitRecords, getLog()); + try { + gen.run(); + } catch (IOException ioe) { + throw new MojoExecutionException("Failed to write generated sources: " + + ioe.getMessage(), ioe); + } + } + + 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; + } + + // ---------------------------------------------------------------- + // AST + // ---------------------------------------------------------------- + + static final class ProtoFile { + String protoPackage; // e.g. "helloworld" + final List messages = new ArrayList(); + final List enums = new ArrayList(); + final List services = new ArrayList(); + } + + static final class ProtoMessage { + String name; // proto simple name + final List nestedMessages = new ArrayList(); + final List nestedEnums = new ArrayList(); + final List fields = new ArrayList(); + } + + static final class ProtoField { + boolean repeated; + String typeName; // proto type token (scalar or message/enum simple name) + String name; // proto field name (snake_case typical) + int tag; + } + + static final class ProtoEnum { + String name; + final List values = new ArrayList(); + } + + static final class ProtoEnumValue { + String name; + int number; + } + + static final class ProtoService { + String name; + final List rpcs = new ArrayList(); + } + + static final class ProtoRpc { + String name; + String requestType; + String responseType; + } + + static final class ProtoParseException extends RuntimeException { + ProtoParseException(String msg) { super(msg); } + } + + // ---------------------------------------------------------------- + // Tokenizer + parser + // ---------------------------------------------------------------- + + static final class ProtoParser { + private final String src; + private final String file; + private int pos; + private int line; + + ProtoParser(String src, String file) { + this.src = src; + this.file = file; + this.pos = 0; + this.line = 1; + } + + ProtoFile parseFile() { + ProtoFile pf = new ProtoFile(); + while (true) { + skipWhitespaceAndComments(); + if (pos >= src.length()) break; + if (peek("syntax")) { + consumeToSemicolon(); // syntax = "proto3"; + } else if (peek("package")) { + consume("package"); + pf.protoPackage = readDottedIdent(); + expect(';'); + } else if (peek("import")) { + consumeToSemicolon(); // imports are ignored + } else if (peek("option")) { + consumeToSemicolon(); // file-level options ignored + } else if (peek("message")) { + pf.messages.add(parseMessage()); + } else if (peek("enum")) { + pf.enums.add(parseEnum()); + } else if (peek("service")) { + pf.services.add(parseService()); + } else if (peek(";")) { + pos++; + } else { + throw err("Unexpected token at top level: '" + previewToken() + "'"); + } + } + return pf; + } + + private ProtoMessage parseMessage() { + consume("message"); + ProtoMessage m = new ProtoMessage(); + m.name = readIdent(); + expect('{'); + while (true) { + skipWhitespaceAndComments(); + if (peek("}")) { pos++; break; } + if (peek("message")) { + m.nestedMessages.add(parseMessage()); + } else if (peek("enum")) { + m.nestedEnums.add(parseEnum()); + } else if (peek("option") || peek("reserved") || peek("extensions")) { + consumeToSemicolon(); + } else if (peek("oneof")) { + parseOneof(m); + } else if (peek("map")) { + throw err("`map` fields are not supported in this release " + + "(message " + m.name + ")"); + } else if (peek(";")) { + pos++; + } else { + m.fields.add(parseField()); + } + } + return m; + } + + /// Treats every member of a `oneof { ... }` as a regular + /// optional field on the parent message. This loses the + /// mutual-exclusion guarantee but lets the codec round-trip + /// any single value the server emits. The user can enforce + /// the oneof shape in application code. + private void parseOneof(ProtoMessage m) { + consume("oneof"); + readIdent(); // discard oneof name + expect('{'); + while (true) { + skipWhitespaceAndComments(); + if (peek("}")) { pos++; break; } + if (peek("option")) { consumeToSemicolon(); continue; } + if (peek(";")) { pos++; continue; } + m.fields.add(parseField()); + } + } + + private ProtoField parseField() { + ProtoField f = new ProtoField(); + if (peek("repeated")) { + consume("repeated"); + f.repeated = true; + } else if (peek("optional")) { + consume("optional"); // proto3 optional -- ignored, fields are nullable by default in our mapping + } else if (peek("required")) { + throw err("proto2 `required` fields are not supported (use proto3)"); + } + f.typeName = readDottedIdent(); + f.name = readIdent(); + expect('='); + f.tag = readInt(); + // Skip field options `[deprecated = true, ...]`. + skipWhitespaceAndComments(); + if (pos < src.length() && src.charAt(pos) == '[') { + int depth = 1; + pos++; + while (pos < src.length() && depth > 0) { + char c = src.charAt(pos); + if (c == '[') depth++; + else if (c == ']') depth--; + if (c == '\n') line++; + pos++; + } + } + expect(';'); + return f; + } + + private ProtoEnum parseEnum() { + consume("enum"); + ProtoEnum e = new ProtoEnum(); + e.name = readIdent(); + expect('{'); + while (true) { + skipWhitespaceAndComments(); + if (peek("}")) { pos++; break; } + if (peek("option") || peek("reserved")) { consumeToSemicolon(); continue; } + if (peek(";")) { pos++; continue; } + ProtoEnumValue v = new ProtoEnumValue(); + v.name = readIdent(); + expect('='); + v.number = readInt(); + // Skip value options [...]. + skipWhitespaceAndComments(); + if (pos < src.length() && src.charAt(pos) == '[') { + int depth = 1; + pos++; + while (pos < src.length() && depth > 0) { + char c = src.charAt(pos); + if (c == '[') depth++; + else if (c == ']') depth--; + if (c == '\n') line++; + pos++; + } + } + expect(';'); + e.values.add(v); + } + return e; + } + + private ProtoService parseService() { + consume("service"); + ProtoService s = new ProtoService(); + s.name = readIdent(); + expect('{'); + while (true) { + skipWhitespaceAndComments(); + if (peek("}")) { pos++; break; } + if (peek("option")) { consumeToSemicolon(); continue; } + if (peek(";")) { pos++; continue; } + if (peek("rpc")) { + s.rpcs.add(parseRpc(s.name)); + } else { + throw err("Unexpected token in service body: '" + previewToken() + "'"); + } + } + return s; + } + + private ProtoRpc parseRpc(String serviceName) { + consume("rpc"); + ProtoRpc r = new ProtoRpc(); + r.name = readIdent(); + expect('('); + skipWhitespaceAndComments(); + if (peek("stream")) { + throw err("Streaming RPCs are not supported in this release " + + "(service " + serviceName + ".rpc " + r.name + ")"); + } + r.requestType = readDottedIdent(); + expect(')'); + skipWhitespaceAndComments(); + consume("returns"); + expect('('); + skipWhitespaceAndComments(); + if (peek("stream")) { + throw err("Streaming RPCs are not supported in this release " + + "(service " + serviceName + ".rpc " + r.name + ")"); + } + r.responseType = readDottedIdent(); + expect(')'); + skipWhitespaceAndComments(); + // Optional `{ option ... }` body. + if (pos < src.length() && src.charAt(pos) == '{') { + int depth = 1; + pos++; + while (pos < src.length() && depth > 0) { + char c = src.charAt(pos); + if (c == '{') depth++; + else if (c == '}') depth--; + if (c == '\n') line++; + pos++; + } + } else if (pos < src.length() && src.charAt(pos) == ';') { + pos++; + } + return r; + } + + // -- Token primitives ---------------------------------------- + + private boolean peek(String keyword) { + skipWhitespaceAndComments(); + int n = keyword.length(); + if (pos + n > src.length()) return false; + if (!src.regionMatches(pos, keyword, 0, n)) return false; + // Single-char punctuation matches without identifier boundary. + if (n == 1 && !Character.isJavaIdentifierPart(keyword.charAt(0))) return true; + if (pos + n < src.length() && Character.isJavaIdentifierPart(src.charAt(pos + n))) return false; + return true; + } + + private void consume(String keyword) { + if (!peek(keyword)) { + throw err("Expected '" + keyword + "' but got '" + previewToken() + "'"); + } + pos += keyword.length(); + } + + private void expect(char c) { + skipWhitespaceAndComments(); + if (pos >= src.length() || src.charAt(pos) != c) { + throw err("Expected '" + c + "' but got '" + previewToken() + "'"); + } + pos++; + } + + private void consumeToSemicolon() { + while (pos < src.length()) { + char c = src.charAt(pos); + if (c == '\n') line++; + pos++; + if (c == ';') return; + } + } + + private String readIdent() { + skipWhitespaceAndComments(); + int start = pos; + if (pos >= src.length() || !Character.isJavaIdentifierStart(src.charAt(pos))) { + throw err("Expected identifier, got '" + previewToken() + "'"); + } + while (pos < src.length() && Character.isJavaIdentifierPart(src.charAt(pos))) pos++; + return src.substring(start, pos); + } + + /// Reads a dotted identifier like `foo.bar.Baz`. Leading dot + /// allowed (proto3 allows leading-dot fully-qualified refs); + /// we strip it. + private String readDottedIdent() { + skipWhitespaceAndComments(); + if (pos < src.length() && src.charAt(pos) == '.') pos++; + StringBuilder sb = new StringBuilder(); + sb.append(readIdent()); + while (true) { + skipWhitespaceAndComments(); + if (pos < src.length() && src.charAt(pos) == '.') { + pos++; + sb.append('.').append(readIdent()); + } else { + break; + } + } + return sb.toString(); + } + + private int readInt() { + skipWhitespaceAndComments(); + int start = pos; + if (pos < src.length() && src.charAt(pos) == '-') pos++; + while (pos < src.length() && Character.isDigit(src.charAt(pos))) pos++; + if (pos == start) throw err("Expected integer literal"); + try { + return Integer.parseInt(src.substring(start, pos)); + } catch (NumberFormatException nfe) { + throw err("Bad integer literal '" + src.substring(start, pos) + "'"); + } + } + + private String previewToken() { + if (pos >= src.length()) return ""; + int end = Math.min(pos + 16, src.length()); + return src.substring(pos, end); + } + + private void skipWhitespaceAndComments() { + while (pos < src.length()) { + char c = src.charAt(pos); + if (c == '\n') { line++; pos++; continue; } + if (Character.isWhitespace(c)) { pos++; continue; } + if (c == '/' && pos + 1 < src.length()) { + char d = src.charAt(pos + 1); + if (d == '/') { + while (pos < src.length() && src.charAt(pos) != '\n') pos++; + continue; + } + if (d == '*') { + pos += 2; + while (pos + 1 < src.length() + && !(src.charAt(pos) == '*' && src.charAt(pos + 1) == '/')) { + if (src.charAt(pos) == '\n') line++; + pos++; + } + if (pos + 1 < src.length()) pos += 2; + continue; + } + } + return; + } + } + + private ProtoParseException err(String msg) { + return new ProtoParseException(file + ":" + line + ": " + msg); + } + } + + // ---------------------------------------------------------------- + // Generator -- proto AST to Java source. + // ---------------------------------------------------------------- + + static final class Generator { + private final ProtoFile file; + private final String basePackage; + private final File outputDir; + private final boolean overwrite; + private final boolean emitRecords; + private final org.apache.maven.plugin.logging.Log log; + + /// Simple-name -> kind ("message" | "enum"). Lets a field type + /// like `Status` (an enum) emit a different ProtoField shape + /// from `Pet` (a message). + private final Map typeKinds = new LinkedHashMap(); + /// Simple-name -> generated Java FQN. + private final Map typeJavaFqn = new LinkedHashMap(); + + Generator(ProtoFile file, String basePackage, File outputDir, boolean overwrite, + boolean emitRecords, org.apache.maven.plugin.logging.Log log) { + this.file = file; + this.basePackage = basePackage; + this.outputDir = outputDir; + this.overwrite = overwrite; + this.emitRecords = emitRecords; + this.log = log; + } + + void run() throws IOException { + // Pass 1: collect known type names (messages + enums, including nested) so + // field-type resolution can determine whether `Status` is a scalar (no), an + // enum (so emit `int number` accessor) or a message (so emit nested codec). + for (ProtoMessage m : file.messages) collectTypes(m); + for (ProtoEnum e : file.enums) { + typeKinds.put(e.name, "enum"); + typeJavaFqn.put(e.name, basePackage + "." + e.name); + } + + File pkgDir = new File(outputDir, basePackage.replace('.', '/')); + ensureDir(pkgDir); + + for (ProtoMessage m : file.messages) { + emitMessage(pkgDir, m); + } + for (ProtoEnum e : file.enums) { + emitEnum(pkgDir, e, basePackage); + } + for (ProtoService s : file.services) { + emitService(pkgDir, s); + } + + log.info("Generated " + file.messages.size() + " @ProtoMessage class(es), " + + file.enums.size() + " @ProtoEnum(s), and " + + file.services.size() + " @GrpcClient interface(s) under " + outputDir); + } + + private void collectTypes(ProtoMessage m) { + typeKinds.put(m.name, "message"); + typeJavaFqn.put(m.name, basePackage + "." + m.name); + for (ProtoMessage n : m.nestedMessages) collectTypes(n); + for (ProtoEnum e : m.nestedEnums) { + typeKinds.put(e.name, "enum"); + typeJavaFqn.put(e.name, basePackage + "." + e.name); + } + } + + // -- Message emission ----------------------------------------- + + private void emitMessage(File dir, ProtoMessage m) throws IOException { + // Emit nested types as top-level siblings to keep the + // generated Java layout flat -- nested-class emission + // would force inner-class codec generation in the + // annotation processor and isn't worth the complexity for + // the first cut. + for (ProtoMessage n : m.nestedMessages) emitMessage(dir, n); + for (ProtoEnum e : m.nestedEnums) emitEnum(dir, e, basePackage); + + File f = new File(dir, m.name + ".java"); + if (f.exists() && !overwrite) { + log.debug("skip existing " + f); + return; + } + StringBuilder sb = new StringBuilder(2048); + sb.append("// Generated by cn1:generate-grpc.\n"); + sb.append("package ").append(basePackage).append(";\n\n"); + sb.append("import com.codename1.annotations.grpc.ProtoField;\n"); + sb.append("import com.codename1.annotations.grpc.ProtoMessage;\n"); + if (hasRepeated(m)) sb.append("import java.util.List;\n"); + sb.append("\n"); + sb.append("@ProtoMessage\n"); + if (emitRecords) { + sb.append("public record ").append(m.name).append("(\n"); + for (int i = 0; i < m.fields.size(); i++) { + ProtoField pf = m.fields.get(i); + if (i > 0) sb.append(",\n"); + sb.append(" ").append(protoFieldAnnotation(pf)).append(' ') + .append(javaTypeFor(pf)).append(' ').append(javaName(pf.name)); + } + sb.append("\n) {}\n"); + } else { + sb.append("public class ").append(m.name).append(" {\n"); + for (ProtoField pf : m.fields) { + sb.append(" ").append(protoFieldAnnotation(pf)).append('\n'); + sb.append(" public ").append(javaTypeFor(pf)).append(' ') + .append(javaName(pf.name)).append(";\n"); + } + sb.append(" public ").append(m.name).append("() {}\n"); + sb.append("}\n"); + } + writeFile(f, sb.toString()); + } + + private boolean hasRepeated(ProtoMessage m) { + for (ProtoField pf : m.fields) { + if (pf.repeated) return true; + } + return false; + } + + private String protoFieldAnnotation(ProtoField pf) { + String wire = wireKindFor(pf.typeName); + StringBuilder sb = new StringBuilder(); + sb.append("@ProtoField(tag = ").append(pf.tag); + if (!"DEFAULT".equals(wire)) { + sb.append(", wireType = ProtoField.WireKind.").append(wire); + } + if (!javaName(pf.name).equals(pf.name)) { + sb.append(", name = \"").append(escapeJava(pf.name)).append("\""); + } + sb.append(')'); + return sb.toString(); + } + + private static String wireKindFor(String protoType) { + if ("sint32".equals(protoType) || "sint64".equals(protoType)) return "SINT"; + if ("fixed32".equals(protoType) || "fixed64".equals(protoType) + || "sfixed32".equals(protoType) || "sfixed64".equals(protoType)) return "FIXED"; + return "DEFAULT"; + } + + private String javaTypeFor(ProtoField pf) { + String element = javaTypeForScalarOrRef(pf.typeName); + if (pf.repeated) { + return "List<" + boxIfPrimitive(element) + ">"; + } + return element; + } + + private String javaTypeForScalarOrRef(String protoType) { + if ("int32".equals(protoType) || "sint32".equals(protoType) + || "uint32".equals(protoType) || "fixed32".equals(protoType) + || "sfixed32".equals(protoType)) return "int"; + if ("int64".equals(protoType) || "sint64".equals(protoType) + || "uint64".equals(protoType) || "fixed64".equals(protoType) + || "sfixed64".equals(protoType)) return "long"; + if ("float".equals(protoType)) return "float"; + if ("double".equals(protoType)) return "double"; + if ("bool".equals(protoType)) return "boolean"; + if ("string".equals(protoType)) return "String"; + if ("bytes".equals(protoType)) return "byte[]"; + // Message / enum reference. Strip leading proto-package + // segments and resolve by simple name. + String simple = protoType; + int dot = simple.lastIndexOf('.'); + if (dot >= 0) simple = simple.substring(dot + 1); + String fqn = typeJavaFqn.get(simple); + return fqn != null ? fqn : ("java.lang.Object /* unknown proto type: " + protoType + " */"); + } + + // -- Enum emission -------------------------------------------- + + private void emitEnum(File dir, ProtoEnum e, String pkg) throws IOException { + File f = new File(dir, e.name + ".java"); + if (f.exists() && !overwrite) { + log.debug("skip existing " + f); + return; + } + StringBuilder sb = new StringBuilder(1024); + sb.append("// Generated by cn1:generate-grpc.\n"); + sb.append("package ").append(pkg).append(";\n\n"); + sb.append("import com.codename1.annotations.grpc.ProtoEnum;\n\n"); + sb.append("@ProtoEnum\n"); + sb.append("public enum ").append(e.name).append(" {\n"); + for (int i = 0; i < e.values.size(); i++) { + ProtoEnumValue v = e.values.get(i); + sb.append(" ").append(v.name).append("(").append(v.number).append(")"); + sb.append(i == e.values.size() - 1 ? ";\n" : ",\n"); + } + sb.append("\n public final int number;\n"); + sb.append(" ").append(e.name).append("(int n) { this.number = n; }\n\n"); + sb.append(" public static ").append(e.name).append(" forNumber(int n) {\n"); + sb.append(" for (").append(e.name).append(" v : values()) {\n"); + sb.append(" if (v.number == n) return v;\n"); + sb.append(" }\n"); + sb.append(" return null;\n"); + sb.append(" }\n"); + sb.append("}\n"); + writeFile(f, sb.toString()); + } + + // -- Service / @GrpcClient interface emission ----------------- + + private void emitService(File dir, ProtoService s) throws IOException { + String className = s.name.endsWith("Grpc") ? s.name : s.name + "Grpc"; + File f = new File(dir, className + ".java"); + if (f.exists() && !overwrite) { + log.debug("skip existing " + f); + return; + } + String serviceFqn = file.protoPackage == null || file.protoPackage.length() == 0 + ? s.name + : file.protoPackage + "." + s.name; + StringBuilder sb = new StringBuilder(2048); + sb.append("// Generated by cn1:generate-grpc.\n"); + sb.append("package ").append(basePackage).append(";\n\n"); + sb.append("import com.codename1.annotations.grpc.GrpcClient;\n"); + sb.append("import com.codename1.annotations.grpc.Rpc;\n"); + sb.append("import com.codename1.annotations.rest.Header;\n"); + sb.append("import com.codename1.io.grpc.GrpcClients;\n"); + sb.append("import com.codename1.io.grpc.GrpcResponse;\n"); + sb.append("import com.codename1.util.OnComplete;\n\n"); + sb.append("@GrpcClient(\"").append(escapeJava(serviceFqn)).append("\")\n"); + sb.append("public interface ").append(className).append(" {\n\n"); + Set usedNames = new LinkedHashSet(); + for (ProtoRpc rpc : s.rpcs) emitRpcMethod(sb, rpc, usedNames); + sb.append(" static ").append(className).append(" of(String baseUrl) {\n"); + sb.append(" return GrpcClients.create(").append(className).append(".class, baseUrl);\n"); + sb.append(" }\n"); + sb.append("}\n"); + writeFile(f, sb.toString()); + } + + private void emitRpcMethod(StringBuilder sb, ProtoRpc rpc, Set usedNames) { + String methodName = uniqueName(lowerFirst(rpc.name), usedNames); + String reqType = resolveMessageType(rpc.requestType); + String respType = resolveMessageType(rpc.responseType); + sb.append(" @Rpc(\"").append(escapeJava(rpc.name)).append("\")\n"); + sb.append(" void ").append(methodName).append('(') + .append(reqType).append(" request, ") + .append("@Header(\"Authorization\") String bearerToken, ") + .append("OnComplete> callback);\n\n"); + } + + private String resolveMessageType(String protoType) { + String simple = protoType; + int dot = simple.lastIndexOf('.'); + if (dot >= 0) simple = simple.substring(dot + 1); + String fqn = typeJavaFqn.get(simple); + if (fqn == null) { + throw new ProtoParseException("Unknown message type referenced in rpc: " + + protoType + " (only messages declared in the same .proto are recognised)"); + } + return fqn; + } + + // -- Helpers -------------------------------------------------- + + private static String uniqueName(String base, Set used) { + if (used.add(base)) return base; + int n = 2; + while (!used.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 boxIfPrimitive(String type) { + if ("int".equals(type)) return "Integer"; + if ("long".equals(type)) return "Long"; + if ("float".equals(type)) return "Float"; + if ("double".equals(type)) return "Double"; + if ("boolean".equals(type)) return "Boolean"; + if ("byte".equals(type)) return "Byte"; + if ("short".equals(type)) return "Short"; + if ("char".equals(type)) return "Character"; + return type; + } + + /// Maps a proto field name (typical `snake_case`) to a Java + /// identifier. Leaves the name alone when it is already a + /// valid Java identifier; otherwise camel-cases it. + static String javaName(String protoName) { + if (protoName == null || protoName.length() == 0) return "field"; + boolean hasUnderscore = protoName.indexOf('_') >= 0; + String candidate; + if (hasUnderscore) { + StringBuilder sb = new StringBuilder(protoName.length()); + boolean upper = false; + for (int i = 0; i < protoName.length(); i++) { + char c = protoName.charAt(i); + if (c == '_') { upper = true; continue; } + sb.append(upper ? Character.toUpperCase(c) : c); + upper = false; + } + candidate = sb.toString(); + } else { + candidate = protoName; + } + 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); + if (c == '"' || c == '\\') sb.append('\\'); + 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(); + } + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/GrpcClientAnnotationProcessor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/GrpcClientAnnotationProcessor.java new file mode 100644 index 0000000000..26300e2e1c --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/GrpcClientAnnotationProcessor.java @@ -0,0 +1,445 @@ +/* + * 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 `@GrpcClient` processor. Scans the project's compiled +/// classes for `@GrpcClient`-annotated interfaces, validates them, +/// and emits: +/// +/// 1. One `Impl` per `@GrpcClient` interface in the +/// same package. The impl chains +/// `com.codename1.io.grpc.GrpcWeb.invokeUnary(baseUrl, service, +/// method, bearerToken, request, RequestProtoCodec.INSTANCE, +/// ResponseProtoCodec.INSTANCE, callback)` for each +/// `@Rpc`-annotated method. +/// 2. A single `cn1app.GrpcClientBootstrap` that registers every +/// accepted interface with `com.codename1.io.grpc.GrpcClients`. +/// +/// Mirrors [RestClientAnnotationProcessor] in structure. +public final class GrpcClientAnnotationProcessor extends AbstractAnnotationProcessor { + + public static final String GRPC_CLIENT_DESC = "Lcom/codename1/annotations/grpc/GrpcClient;"; + public static final String RPC_DESC = "Lcom/codename1/annotations/grpc/Rpc;"; + public static final String HEADER_DESC = "Lcom/codename1/annotations/rest/Header;"; + + static final String BOOTSTRAP_BINARY = "cn1app.GrpcClientBootstrap"; + static final String BOOTSTRAP_SIMPLE = "GrpcClientBootstrap"; + static final String BOOTSTRAP_PACKAGE = "cn1app"; + + private static final Set DESCRIPTORS; + static { + Set s = new LinkedHashSet(); + s.add(GRPC_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(GRPC_CLIENT_DESC); + if (clientAnno == null) return; + if (!cls.isInterface()) { + ctx.error(cls, "@GrpcClient requires an interface; " + + cls.getBinaryName() + " is not an interface"); + return; + } + if (!cls.isPublic()) { + ctx.error(cls, "@GrpcClient interface " + cls.getBinaryName() + + " must be public"); + return; + } + + GrpcApi api = new GrpcApi(); + 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; + api.defaultService = clientAnno.getStringOrDefault("value", ""); + + 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; + + AnnotationValues rpc = m.getAnnotation(RPC_DESC); + if (rpc == null) { + ctx.error(cls, "@GrpcClient method " + api.binaryName + "." + m.getName() + + " must carry an @Rpc annotation"); + anyError = true; + continue; + } + String rpcMethod = rpc.getString("value"); + String rpcServiceOverride = rpc.getStringOrDefault("service", ""); + String serviceFqn = rpcServiceOverride.length() > 0 ? rpcServiceOverride : api.defaultService; + if (serviceFqn == null || serviceFqn.length() == 0) { + ctx.error(cls, "@Rpc on " + api.binaryName + "." + m.getName() + + " has no service path -- set @GrpcClient(\"\") " + + "on the interface or @Rpc(service=\"\", value=\"\")"); + anyError = true; + continue; + } + if (rpcMethod == null || rpcMethod.length() == 0) { + ctx.error(cls, "@Rpc on " + api.binaryName + "." + m.getName() + + " requires a method name (value)"); + anyError = true; + continue; + } + + GrpcMethod gm = new GrpcMethod(); + gm.name = m.getName(); + gm.descriptor = m.getDescriptor(); + gm.signature = m.getSignature(); + gm.rpcMethod = rpcMethod; + gm.service = serviceFqn; + + Type[] paramTypes = Type.getArgumentTypes(gm.descriptor); + List> paramAnnotations = m.getParameterAnnotations(); + String[] genericSigs = RestClientAnnotationProcessor.parseGenericParameterSignatures( + gm.signature, paramTypes.length); + + int requestIndex = -1; + int bearerIndex = -1; + int callbackIndex = -1; + boolean methodHasError = false; + + 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 hdr = pa == null ? null : pa.get(HEADER_DESC); + + if ("Lcom/codename1/util/OnComplete;".equals(descriptor)) { + if (callbackIndex >= 0) { + ctx.error(cls, "@GrpcClient method " + api.binaryName + "." + gm.name + + " declares more than one OnComplete callback"); + methodHasError = true; + break; + } + callbackIndex = i; + String payloadSig = genericSigs == null ? null : genericSigs[i]; + gm.responseBinaryName = extractGrpcResponsePayload(payloadSig); + continue; + } + if (hdr != null) { + String hv = hdr.getStringOrDefault("value", ""); + if ("Authorization".equalsIgnoreCase(hv)) { + bearerIndex = i; + continue; + } + ctx.error(cls, "@GrpcClient method " + api.binaryName + "." + gm.name + + " carries @Header(\"" + hv + "\") -- only @Header(\"Authorization\") " + + "is honoured for gRPC clients in this release"); + methodHasError = true; + break; + } + if (requestIndex < 0) { + requestIndex = i; + gm.requestBinaryName = descriptor.startsWith("L") && descriptor.endsWith(";") + ? descriptor.substring(1, descriptor.length() - 1).replace('/', '.') + : "java.lang.Object"; + continue; + } + ctx.error(cls, "@GrpcClient method " + api.binaryName + "." + gm.name + + " has an unrecognised parameter at position " + i + + " (descriptor " + descriptor + "); expected: " + + "(, [@Header(\"Authorization\") String], OnComplete>)"); + methodHasError = true; + break; + } + + if (methodHasError) { + anyError = true; + continue; + } + if (requestIndex < 0) { + ctx.error(cls, "@GrpcClient method " + api.binaryName + "." + gm.name + + " must declare a request-message parameter before the callback"); + anyError = true; + continue; + } + if (callbackIndex < 0) { + ctx.error(cls, "@GrpcClient method " + api.binaryName + "." + gm.name + + " must end with an OnComplete> callback"); + anyError = true; + continue; + } + gm.requestParamIndex = requestIndex; + gm.bearerParamIndex = bearerIndex; + gm.callbackParamIndex = callbackIndex; + gm.paramTypes = paramTypes; + api.methods.add(gm); + } + + if (!anyError) { + accepted.put(api.binaryName, api); + } + } + + @Override + public void finish(ProcessorContext ctx) throws ProcessingException { + if (ctx.hasErrors()) return; + if (accepted.isEmpty()) return; + + Map sources = new LinkedHashMap(); + for (GrpcApi 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 @GrpcClient sources: " + + ioe.getMessage(), ioe); + } + ctx.getLog().info("cn1: generated " + accepted.size() + + " @GrpcClient impl(s) + " + BOOTSTRAP_BINARY); + } + + // ---------------------------------------------------------------- + // Source generation + // ---------------------------------------------------------------- + + private static String generateImplSource(GrpcApi 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 baseUrl;\n\n"); + sb.append(" public ").append(api.implSimpleName).append("(String baseUrl) {\n"); + sb.append(" this.baseUrl = baseUrl;\n"); + sb.append(" }\n\n"); + for (GrpcMethod gm : api.methods) { + emitMethod(sb, gm); + } + sb.append("}\n"); + return sb.toString(); + } + + private static void emitMethod(StringBuilder sb, GrpcMethod gm) { + sb.append(" public void ").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"); + + String requestExpr = "p" + gm.requestParamIndex; + String bearerExpr = gm.bearerParamIndex < 0 ? "null" : "p" + gm.bearerParamIndex; + String callbackExpr = "p" + gm.callbackParamIndex; + String reqCodec = gm.requestBinaryName + "ProtoCodec.INSTANCE"; + String respCodec = gm.responseBinaryName + "ProtoCodec.INSTANCE"; + + sb.append(" com.codename1.io.grpc.GrpcWeb.invokeUnary(\n") + .append(" baseUrl, \"").append(escape(gm.service)).append("\", \"") + .append(escape(gm.rpcMethod)).append("\",\n") + .append(" ").append(bearerExpr).append(",\n") + .append(" ").append(requestExpr).append(",\n") + .append(" ").append(reqCodec).append(",\n") + .append(" ").append(respCodec).append(",\n") + .append(" ").append(callbackExpr).append(");\n"); + sb.append(" }\n\n"); + } + + /// Returns the Java type literal for the impl-method parameter at + /// position `i`. The callback re-uses the generic signature so the + /// impl's method signature stays parameterized with `GrpcResponse` + /// the way the user declared it. + private static String paramJavaType(GrpcMethod gm, int i) { + if (i == gm.callbackParamIndex) { + return "com.codename1.util.OnComplete>"; + } + String descriptor = gm.paramTypes[i].getDescriptor(); + if (descriptor.startsWith("L") && descriptor.endsWith(";")) { + return descriptor.substring(1, descriptor.length() - 1).replace('/', '.'); + } + // Should never happen for gRPC client methods (request type is + // always a reference, bearerToken is String) but cover the + // edge case rather than emit invalid source. + return primitiveJava(descriptor); + } + + private static String primitiveJava(String descriptor) { + switch (descriptor) { + case "I": return "int"; + case "J": return "long"; + case "F": return "float"; + case "D": return "double"; + case "Z": return "boolean"; + case "B": return "byte"; + case "S": return "short"; + case "C": return "char"; + default: 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("/// gRPC-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 (GrpcApi api : apis) { + sb.append(" com.codename1.io.grpc.GrpcClients.register(") + .append(api.binaryName) + .append(".class, new com.codename1.io.grpc.GrpcClients.Factory<") + .append(api.binaryName).append(">() {\n"); + sb.append(" public ").append(api.binaryName).append(" create(String baseUrl) {\n"); + sb.append(" return new ").append(api.implBinaryName).append("(baseUrl);\n"); + sb.append(" }\n"); + sb.append(" });\n"); + } + sb.append(" }\n"); + sb.append("}\n"); + return sb.toString(); + } + + // ---------------------------------------------------------------- + // Signature helpers + // ---------------------------------------------------------------- + + /// Extracts the response payload type from the callback + /// parameter's generic signature + /// `Lcom/codename1/util/OnComplete;>;`. + /// Returns `java.lang.Object` when the signature is missing. + static String extractGrpcResponsePayload(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); + int innerLt = inner.indexOf('<'); + int innerGt = inner.lastIndexOf('>'); + if (innerLt < 0 || innerGt < 0 || innerLt > innerGt) return "java.lang.Object"; + String payload = inner.substring(innerLt + 1, innerGt); + if (payload.startsWith("L") && payload.endsWith(";")) { + return payload.substring(1, payload.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); + if (c == '"' || c == '\\') b.append('\\'); + b.append(c); + } + return b.toString(); + } + + // ---------------------------------------------------------------- + // Accumulators + // ---------------------------------------------------------------- + + static final class GrpcApi { + String binaryName; + String packageName; + String simpleName; + String implBinaryName; + String implSimpleName; + String defaultService; + final List methods = new ArrayList(); + } + + static final class GrpcMethod { + String name; + String descriptor; + String signature; + String rpcMethod; + String service; + int requestParamIndex = -1; + int bearerParamIndex = -1; + int callbackParamIndex = -1; + String requestBinaryName; + String responseBinaryName; + Type[] paramTypes; + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/ProtoMessageAnnotationProcessor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/ProtoMessageAnnotationProcessor.java new file mode 100644 index 0000000000..f4d0bd7424 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/ProtoMessageAnnotationProcessor.java @@ -0,0 +1,733 @@ +/* + * 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.FieldInfo; +import com.codename1.maven.annotations.JavaSourceCompiler; +import com.codename1.maven.annotations.ProcessingException; +import com.codename1.maven.annotations.ProcessorContext; + +import java.io.IOException; +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 java.util.ArrayList; + +/// Build-time `@ProtoMessage` / `@ProtoEnum` processor. Emits one +/// `ProtoCodec` per `@ProtoMessage` class and aggregates +/// them in `cn1app.ProtoBootstrap`. Generated codecs implement +/// `com.codename1.io.grpc.ProtoCodec` and register themselves at +/// startup via `com.codename1.io.grpc.ProtoCodecs.register(...)` so +/// gRPC clients can resolve nested-message and enum codecs at +/// runtime without reflection. +public final class ProtoMessageAnnotationProcessor extends AbstractAnnotationProcessor { + + public static final String PROTO_MESSAGE_DESC = "Lcom/codename1/annotations/grpc/ProtoMessage;"; + public static final String PROTO_ENUM_DESC = "Lcom/codename1/annotations/grpc/ProtoEnum;"; + public static final String PROTO_FIELD_DESC = "Lcom/codename1/annotations/grpc/ProtoField;"; + + static final String BOOTSTRAP_BINARY = "cn1app.ProtoBootstrap"; + static final String BOOTSTRAP_SIMPLE = "ProtoBootstrap"; + static final String BOOTSTRAP_PACKAGE = "cn1app"; + + private static final Set DESCRIPTORS; + static { + Set s = new LinkedHashSet(); + s.add(PROTO_MESSAGE_DESC); + s.add(PROTO_ENUM_DESC); + DESCRIPTORS = Collections.unmodifiableSet(s); + } + + private final TreeMap messages = new TreeMap(); + /// Internal-name set of enums declared in the project so message-field + /// resolution can tell `Lcom/example/Status;` (enum) apart from + /// `Lcom/example/Pet;` (message). + private final Set enums = new LinkedHashSet(); + + @Override + public Set getAnnotationDescriptors() { + return DESCRIPTORS; + } + + @Override + public void start(ProcessorContext ctx) throws ProcessingException { + messages.clear(); + enums.clear(); + } + + @Override + public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws ProcessingException { + if (cls.isSynthetic()) return; + boolean isMessage = cls.getClassAnnotation(PROTO_MESSAGE_DESC) != null; + boolean isEnum = cls.getClassAnnotation(PROTO_ENUM_DESC) != null; + if (!isMessage && !isEnum) return; + + if (isEnum) { + enums.add(cls.getInternalName()); + return; + } + + if (cls.isInterface() || cls.isAbstract()) { + ctx.error(cls, "@ProtoMessage requires a concrete class or record; " + + cls.getBinaryName() + " is abstract or an interface"); + return; + } + + ProtoClass pc = new ProtoClass(); + pc.binaryName = cls.getBinaryName(); + pc.simpleName = simpleName(pc.binaryName); + pc.packageName = packageOf(pc.binaryName); + pc.codecSimpleName = pc.simpleName + "ProtoCodec"; + pc.codecBinaryName = pc.packageName.length() == 0 + ? pc.codecSimpleName + : pc.packageName + "." + pc.codecSimpleName; + pc.isRecord = cls.isRecord(); + + Set tagsSeen = new LinkedHashSet(); + for (FieldInfo f : cls.getFields()) { + if (f.isStatic()) continue; + if (f.getName().startsWith("this$")) continue; + AnnotationValues pf = f.getAnnotation(PROTO_FIELD_DESC); + if (pf == null) continue; + int tag = pf.getIntOrDefault("tag", 0); + if (tag <= 0) { + ctx.error(cls, "@ProtoField on " + pc.binaryName + "." + f.getName() + + " must declare a positive tag"); + continue; + } + if (!tagsSeen.add(tag)) { + ctx.error(cls, "@ProtoField on " + pc.binaryName + "." + f.getName() + + " reuses tag " + tag + " from another field"); + continue; + } + ProtoFieldInfo pfi = new ProtoFieldInfo(); + pfi.name = f.getName(); + pfi.descriptor = f.getDescriptor(); + pfi.signature = f.getSignature(); + pfi.tag = tag; + pfi.wireKind = wireKindFromAnnotation(pf); + classifyField(pfi, ctx, cls); + pc.fields.add(pfi); + } + messages.put(pc.binaryName, pc); + } + + @Override + public void finish(ProcessorContext ctx) throws ProcessingException { + if (ctx.hasErrors()) return; + if (messages.isEmpty() && enums.isEmpty()) return; + + // Second resolution pass: any field whose static type is a project + // class but is not annotated @ProtoMessage / @ProtoEnum needs to be + // resolved against the index now that we've seen every class. + for (ProtoClass pc : messages.values()) { + for (ProtoFieldInfo f : pc.fields) { + resolveReferenceKind(f, ctx); + } + } + if (ctx.hasErrors()) return; + + if (messages.isEmpty()) return; // enums alone don't need a bootstrap + + Map sources = new LinkedHashMap(); + for (ProtoClass pc : messages.values()) { + sources.put(pc.codecBinaryName, generateCodecSource(pc)); + } + sources.put(BOOTSTRAP_BINARY, generateBootstrapSource(messages.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 @ProtoMessage sources: " + + ioe.getMessage(), ioe); + } + ctx.getLog().info("cn1: generated " + messages.size() + + " @ProtoMessage codec(s) + " + BOOTSTRAP_BINARY); + } + + // ---------------------------------------------------------------- + // Classification + // ---------------------------------------------------------------- + + private static String wireKindFromAnnotation(AnnotationValues pf) { + Object v = pf.get("wireType"); + if (v instanceof String[]) { + String[] enumRef = (String[]) v; + if (enumRef.length == 2) return enumRef[1]; + } + return "DEFAULT"; + } + + /// Classifies the field based on its JVM descriptor and (for + /// `List`) the generic signature. Reference types are left + /// pending until `resolveReferenceKind` runs in `finish`, by + /// which point the index of `@ProtoEnum` classes is complete. + private void classifyField(ProtoFieldInfo pfi, ProcessorContext ctx, AnnotatedClass cls) { + String d = pfi.descriptor; + if ("I".equals(d)) { + pfi.kind = intKind(pfi.wireKind); + return; + } + if ("J".equals(d)) { + pfi.kind = longKind(pfi.wireKind); + return; + } + if ("F".equals(d)) { pfi.kind = ProtoKind.FLOAT; return; } + if ("D".equals(d)) { pfi.kind = ProtoKind.DOUBLE; return; } + if ("Z".equals(d)) { pfi.kind = ProtoKind.BOOL; return; } + if ("[B".equals(d)) { pfi.kind = ProtoKind.BYTES; return; } + if ("Ljava/lang/String;".equals(d)) { pfi.kind = ProtoKind.STRING; return; } + if ("Ljava/lang/Integer;".equals(d)) { pfi.kind = intKind(pfi.wireKind); pfi.boxed = true; return; } + if ("Ljava/lang/Long;".equals(d)) { pfi.kind = longKind(pfi.wireKind); pfi.boxed = true; return; } + if ("Ljava/lang/Float;".equals(d)) { pfi.kind = ProtoKind.FLOAT; pfi.boxed = true; return; } + if ("Ljava/lang/Double;".equals(d)) { pfi.kind = ProtoKind.DOUBLE; pfi.boxed = true; return; } + if ("Ljava/lang/Boolean;".equals(d)) { pfi.kind = ProtoKind.BOOL; pfi.boxed = true; return; } + if ("Ljava/util/List;".equals(d)) { + pfi.repeated = true; + // Element type comes from the generic signature. + String element = parseListElement(pfi.signature); + if (element == null) { + ctx.error(cls, "@ProtoField on " + cls.getBinaryName() + "." + pfi.name + + " is List but lacks a generic element type"); + return; + } + pfi.elementDescriptor = element; + classifyListElement(pfi, element); + return; + } + if (d.startsWith("L") && d.endsWith(";")) { + // Reference type -- defer to resolveReferenceKind once the + // @ProtoEnum index is complete. + pfi.pendingReference = true; + pfi.referenceInternalName = d.substring(1, d.length() - 1); + return; + } + ctx.error(cls, "@ProtoField on " + cls.getBinaryName() + "." + pfi.name + + " has unsupported type descriptor " + d); + } + + private static ProtoKind intKind(String wire) { + if ("SINT".equals(wire)) return ProtoKind.SINT32; + if ("FIXED".equals(wire)) return ProtoKind.FIXED32; + return ProtoKind.INT32; + } + + private static ProtoKind longKind(String wire) { + if ("SINT".equals(wire)) return ProtoKind.SINT64; + if ("FIXED".equals(wire)) return ProtoKind.FIXED64; + return ProtoKind.INT64; + } + + /// Parses the element type out of a `Ljava/util/List<...>;` generic + /// signature. Returns the inner descriptor (e.g. `Ljava/lang/String;` + /// or `Lcom/example/Pet;`) or `null` if the signature is missing. + static String parseListElement(String signature) { + if (signature == null) return null; + int lt = signature.indexOf('<'); + int gt = signature.lastIndexOf('>'); + if (lt < 0 || gt < 0 || gt <= lt + 1) return null; + return signature.substring(lt + 1, gt); + } + + private void classifyListElement(ProtoFieldInfo pfi, String element) { + if (element.length() == 0) return; + char c = element.charAt(0); + switch (c) { + case 'L': + pfi.referenceInternalName = element.substring(1, element.length() - 1); + if ("java/lang/String".equals(pfi.referenceInternalName)) { + pfi.kind = ProtoKind.STRING; + } else if ("java/lang/Integer".equals(pfi.referenceInternalName)) { + pfi.kind = intKind(pfi.wireKind); + pfi.boxed = true; + } else if ("java/lang/Long".equals(pfi.referenceInternalName)) { + pfi.kind = longKind(pfi.wireKind); + pfi.boxed = true; + } else if ("java/lang/Float".equals(pfi.referenceInternalName)) { + pfi.kind = ProtoKind.FLOAT; + pfi.boxed = true; + } else if ("java/lang/Double".equals(pfi.referenceInternalName)) { + pfi.kind = ProtoKind.DOUBLE; + pfi.boxed = true; + } else if ("java/lang/Boolean".equals(pfi.referenceInternalName)) { + pfi.kind = ProtoKind.BOOL; + pfi.boxed = true; + } else { + pfi.pendingReference = true; + } + return; + default: + // Lists of primitives aren't expressible in JVM + // signatures, so anything else is invalid. + pfi.kind = ProtoKind.UNSUPPORTED; + } + } + + /// Resolves a deferred-reference field by checking whether the + /// referenced type is a `@ProtoEnum`. Anything else is treated as + /// a `@ProtoMessage` -- the generated codec lookup at runtime + /// will throw if no codec is registered, which gives a + /// less-useful error message than this validation but lets a + /// project ship cycles between project-local message types. + private void resolveReferenceKind(ProtoFieldInfo pfi, ProcessorContext ctx) { + if (!pfi.pendingReference) return; + pfi.pendingReference = false; + if (enums.contains(pfi.referenceInternalName)) { + pfi.kind = ProtoKind.ENUM; + return; + } + // Assume message. (We can't validate the referenced type is + // really @ProtoMessage without walking the index again; in + // practice typos surface at runtime via ProtoCodecs.lookup.) + pfi.kind = ProtoKind.MESSAGE; + } + + // ---------------------------------------------------------------- + // Source generation + // ---------------------------------------------------------------- + + private static String generateCodecSource(ProtoClass pc) { + StringBuilder sb = new StringBuilder(2048); + if (pc.packageName.length() > 0) { + sb.append("package ").append(pc.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(pc.codecSimpleName) + .append(" implements com.codename1.io.grpc.ProtoCodec<") + .append(pc.binaryName).append("> {\n\n"); + + sb.append(" public static final ").append(pc.codecSimpleName) + .append(" INSTANCE = new ").append(pc.codecSimpleName).append("();\n\n"); + + sb.append(" public static void register() {\n"); + sb.append(" com.codename1.io.grpc.ProtoCodecs.register(") + .append(pc.binaryName).append(".class, INSTANCE);\n"); + sb.append(" }\n\n"); + + sb.append(" public ").append(pc.codecSimpleName).append("() {\n }\n\n"); + + emitWrite(sb, pc); + emitRead(sb, pc); + + sb.append("}\n"); + return sb.toString(); + } + + private static void emitWrite(StringBuilder sb, ProtoClass pc) { + sb.append(" public void write(com.codename1.io.grpc.ProtoWriter out, ") + .append(pc.binaryName).append(" value) throws java.io.IOException {\n"); + sb.append(" if (value == null) return;\n"); + for (ProtoFieldInfo f : pc.fields) { + emitWriteField(sb, pc, f); + } + sb.append(" }\n\n"); + } + + private static String readExpr(ProtoClass pc, ProtoFieldInfo f) { + return pc.isRecord ? "value." + f.name + "()" : "value." + f.name; + } + + private static void emitWriteField(StringBuilder sb, ProtoClass pc, ProtoFieldInfo f) { + String e = readExpr(pc, f); + int tag = f.tag; + if (f.repeated) { + switch (f.kind) { + case STRING: + sb.append(" out.writeStringList(").append(tag).append(", ").append(e).append(");\n"); + return; + case MESSAGE: { + String codec = codecFor(f.referenceInternalName); + sb.append(" out.writeMessageList(").append(tag).append(", ").append(e) + .append(", ").append(codec).append(");\n"); + return; + } + case INT32: case SINT32: case FIXED32: + case INT64: case SINT64: case FIXED64: + case FLOAT: case DOUBLE: case BOOL: case ENUM: + emitRepeatedScalarWrite(sb, f, e, tag); + return; + case BYTES: + sb.append(" if (").append(e).append(" != null) {\n"); + sb.append(" for (int _i = 0, _n = ").append(e).append(".size(); _i < _n; _i++) {\n"); + sb.append(" out.writeBytes(").append(tag).append(", ").append(e).append(".get(_i));\n"); + sb.append(" }\n"); + sb.append(" }\n"); + return; + default: + sb.append(" // TODO unsupported repeated kind ").append(f.kind).append("\n"); + return; + } + } + switch (f.kind) { + case INT32: sb.append(" out.writeInt32(").append(tag).append(", ").append(e).append(");\n"); return; + case SINT32: sb.append(" out.writeSInt32(").append(tag).append(", ").append(e).append(");\n"); return; + case FIXED32: sb.append(" out.writeFixed32Field(").append(tag).append(", ").append(e).append(");\n"); return; + case INT64: sb.append(" out.writeInt64(").append(tag).append(", ").append(e).append(");\n"); return; + case SINT64: sb.append(" out.writeSInt64(").append(tag).append(", ").append(e).append(");\n"); return; + case FIXED64: sb.append(" out.writeFixed64Field(").append(tag).append(", ").append(e).append(");\n"); return; + case FLOAT: sb.append(" out.writeFloat(").append(tag).append(", ").append(e).append(");\n"); return; + case DOUBLE: sb.append(" out.writeDouble(").append(tag).append(", ").append(e).append(");\n"); return; + case BOOL: sb.append(" out.writeBool(").append(tag).append(", ").append(e).append(");\n"); return; + case STRING: sb.append(" out.writeString(").append(tag).append(", ").append(e).append(");\n"); return; + case BYTES: sb.append(" out.writeBytes(").append(tag).append(", ").append(e).append(");\n"); return; + case ENUM: + sb.append(" if (").append(e).append(" != null) {\n"); + sb.append(" out.writeInt32(").append(tag).append(", ").append(e).append(".number);\n"); + sb.append(" }\n"); + return; + case MESSAGE: { + String codec = codecFor(f.referenceInternalName); + sb.append(" out.writeMessage(").append(tag).append(", ").append(e) + .append(", ").append(codec).append(");\n"); + return; + } + default: + sb.append(" // TODO unsupported kind ").append(f.kind).append("\n"); + } + } + + /// Packed repeated scalar emit: opens a `ByteArrayOutputStream`, + /// writes every element using its varint / fixed-width encoder, + /// then attaches the buffer as a length-delimited field. proto3 + /// servers accept either packed or unpacked, so the writer + /// chooses packed for compactness; the reader handles both. + private static void emitRepeatedScalarWrite(StringBuilder sb, ProtoFieldInfo f, String e, int tag) { + sb.append(" if (").append(e).append(" != null && !").append(e).append(".isEmpty()) {\n"); + sb.append(" java.io.ByteArrayOutputStream _buf = new java.io.ByteArrayOutputStream();\n"); + sb.append(" com.codename1.io.grpc.ProtoWriter _sub = new com.codename1.io.grpc.ProtoWriter(_buf);\n"); + sb.append(" for (int _i = 0, _n = ").append(e).append(".size(); _i < _n; _i++) {\n"); + switch (f.kind) { + case INT32: sb.append(" _sub.writeVarint32(").append(e).append(".get(_i));\n"); break; + case SINT32: sb.append(" _sub.writeVarint64(com.codename1.io.grpc.ProtoWriter.zigZag32(").append(e).append(".get(_i)) & 0xFFFFFFFFL);\n"); break; + case FIXED32: sb.append(" _sub.writeFixed32(").append(e).append(".get(_i));\n"); break; + case INT64: sb.append(" _sub.writeVarint64(").append(e).append(".get(_i));\n"); break; + case SINT64: sb.append(" _sub.writeVarint64(com.codename1.io.grpc.ProtoWriter.zigZag64(").append(e).append(".get(_i)));\n"); break; + case FIXED64: sb.append(" _sub.writeFixed64(").append(e).append(".get(_i));\n"); break; + case FLOAT: sb.append(" _sub.writeFixed32(Float.floatToRawIntBits(").append(e).append(".get(_i)));\n"); break; + case DOUBLE: sb.append(" _sub.writeFixed64(Double.doubleToRawLongBits(").append(e).append(".get(_i)));\n"); break; + case BOOL: sb.append(" _sub.writeVarint32(").append(e).append(".get(_i) ? 1 : 0);\n"); break; + case ENUM: sb.append(" _sub.writeVarint32(").append(e).append(".get(_i).number);\n"); break; + default: break; + } + sb.append(" }\n"); + sb.append(" byte[] _body = _buf.toByteArray();\n"); + sb.append(" out.writeTag(").append(tag).append(", com.codename1.io.grpc.ProtoWriter.WIRE_LEN);\n"); + sb.append(" out.writeVarint32(_body.length);\n"); + sb.append(" out.stream().write(_body);\n"); + sb.append(" }\n"); + } + + private static String codecFor(String referenceInternalName) { + String binary = referenceInternalName.replace('/', '.'); + return binary + "ProtoCodec.INSTANCE"; + } + + // -- Read -------------------------------------------------------- + + private static void emitRead(StringBuilder sb, ProtoClass pc) { + sb.append(" public ").append(pc.binaryName) + .append(" read(com.codename1.io.grpc.ProtoReader in) throws java.io.IOException {\n"); + if (pc.isRecord) { + for (ProtoFieldInfo f : pc.fields) { + sb.append(" ").append(localType(f)).append(" _") + .append(f.name).append(" = ").append(localInit(f)).append(";\n"); + } + } else { + sb.append(" ").append(pc.binaryName).append(" value = new ") + .append(pc.binaryName).append("();\n"); + } + // For repeated fields on POJO we need to lazily new the list. + sb.append(" int _tag;\n"); + sb.append(" while ((_tag = in.readTag()) != 0) {\n"); + sb.append(" int _field = _tag >>> 3;\n"); + sb.append(" int _wire = _tag & 7;\n"); + sb.append(" switch (_field) {\n"); + for (ProtoFieldInfo f : pc.fields) { + emitReadCase(sb, pc, f); + } + sb.append(" default: in.skipField(_tag); break;\n"); + sb.append(" }\n"); + sb.append(" }\n"); + if (pc.isRecord) { + sb.append(" return new ").append(pc.binaryName).append("("); + for (int i = 0; i < pc.fields.size(); i++) { + if (i > 0) sb.append(", "); + sb.append("_").append(pc.fields.get(i).name); + } + sb.append(");\n"); + } else { + sb.append(" return value;\n"); + } + sb.append(" }\n"); + } + + private static String localType(ProtoFieldInfo f) { + // For records we need a Java type literal for the local + // that ultimately feeds the canonical constructor. + if (f.repeated) { + return "java.util.List<" + elementJavaType(f) + ">"; + } + switch (f.kind) { + case INT32: case SINT32: case FIXED32: return f.boxed ? "java.lang.Integer" : "int"; + case INT64: case SINT64: case FIXED64: return f.boxed ? "java.lang.Long" : "long"; + case FLOAT: return f.boxed ? "java.lang.Float" : "float"; + case DOUBLE: return f.boxed ? "java.lang.Double" : "double"; + case BOOL: return f.boxed ? "java.lang.Boolean" : "boolean"; + case STRING: return "java.lang.String"; + case BYTES: return "byte[]"; + case MESSAGE: case ENUM: return f.referenceInternalName.replace('/', '.'); + default: return "java.lang.Object"; + } + } + + private static String elementJavaType(ProtoFieldInfo f) { + switch (f.kind) { + case INT32: case SINT32: case FIXED32: return "java.lang.Integer"; + case INT64: case SINT64: case FIXED64: return "java.lang.Long"; + case FLOAT: return "java.lang.Float"; + case DOUBLE: return "java.lang.Double"; + case BOOL: return "java.lang.Boolean"; + case STRING: return "java.lang.String"; + case BYTES: return "byte[]"; + case MESSAGE: case ENUM: return f.referenceInternalName.replace('/', '.'); + default: return "java.lang.Object"; + } + } + + private static String localInit(ProtoFieldInfo f) { + if (f.repeated) return "new java.util.ArrayList<" + elementJavaType(f) + ">()"; + if (f.boxed) return "null"; + switch (f.kind) { + case INT32: case SINT32: case FIXED32: return "0"; + case INT64: case SINT64: case FIXED64: return "0L"; + case FLOAT: return "0.0f"; + case DOUBLE: return "0.0d"; + case BOOL: return "false"; + default: return "null"; + } + } + + private static void emitReadCase(StringBuilder sb, ProtoClass pc, ProtoFieldInfo f) { + sb.append(" case ").append(f.tag).append(": {\n"); + if (f.repeated) { + emitReadRepeated(sb, pc, f); + } else { + emitReadScalar(sb, pc, f); + } + sb.append(" break;\n"); + sb.append(" }\n"); + } + + private static String writeTarget(ProtoClass pc, ProtoFieldInfo f) { + return pc.isRecord ? "_" + f.name : "value." + f.name; + } + + private static void emitReadScalar(StringBuilder sb, ProtoClass pc, ProtoFieldInfo f) { + String target = writeTarget(pc, f); + switch (f.kind) { + case INT32: sb.append(" ").append(target).append(" = in.readVarint32();\n"); break; + case SINT32: sb.append(" ").append(target).append(" = in.readSInt32();\n"); break; + case FIXED32: sb.append(" ").append(target).append(" = in.readFixed32();\n"); break; + case INT64: sb.append(" ").append(target).append(" = in.readVarint64();\n"); break; + case SINT64: sb.append(" ").append(target).append(" = in.readSInt64();\n"); break; + case FIXED64: sb.append(" ").append(target).append(" = in.readFixed64();\n"); break; + case FLOAT: sb.append(" ").append(target).append(" = in.readFloat();\n"); break; + case DOUBLE: sb.append(" ").append(target).append(" = in.readDouble();\n"); break; + case BOOL: sb.append(" ").append(target).append(" = in.readBool();\n"); break; + case STRING: sb.append(" ").append(target).append(" = in.readString();\n"); break; + case BYTES: sb.append(" ").append(target).append(" = in.readBytes();\n"); break; + case ENUM: + sb.append(" ").append(target).append(" = ") + .append(f.referenceInternalName.replace('/', '.')) + .append(".forNumber(in.readVarint32());\n"); + break; + case MESSAGE: + sb.append(" ").append(target).append(" = in.readMessage(") + .append(codecFor(f.referenceInternalName)).append(");\n"); + break; + default: + sb.append(" in.skipField(_tag);\n"); + } + } + + private static void emitReadRepeated(StringBuilder sb, ProtoClass pc, ProtoFieldInfo f) { + String target = pc.isRecord ? "_" + f.name : "value." + f.name; + if (!pc.isRecord) { + // POJO: lazily new the list if null (declared field can be + // null when the message is decoded with no occurrences). + sb.append(" if (").append(target).append(" == null) ") + .append(target).append(" = new java.util.ArrayList<").append(elementJavaType(f)).append(">();\n"); + } + switch (f.kind) { + case STRING: + sb.append(" ").append(target).append(".add(in.readString());\n"); + return; + case MESSAGE: + sb.append(" ").append(target).append(".add(in.readMessage(") + .append(codecFor(f.referenceInternalName)).append("));\n"); + return; + case BYTES: + sb.append(" ").append(target).append(".add(in.readBytes());\n"); + return; + case ENUM: { + final String enumType = f.referenceInternalName.replace('/', '.'); + sb.append(" if (_wire == com.codename1.io.grpc.ProtoWriter.WIRE_LEN) {\n"); + sb.append(" in.readPacked(").append(target).append(", new com.codename1.io.grpc.ProtoReader.PackedReader<") + .append(enumType).append(">() {\n"); + sb.append(" public ").append(enumType).append(" read(com.codename1.io.grpc.ProtoReader _r) throws java.io.IOException {\n"); + sb.append(" return ").append(enumType).append(".forNumber(_r.readVarint32());\n"); + sb.append(" }\n"); + sb.append(" });\n"); + sb.append(" } else {\n"); + sb.append(" ").append(target).append(".add(").append(enumType).append(".forNumber(in.readVarint32()));\n"); + sb.append(" }\n"); + return; + } + default: + // Scalar repeated: support both packed and unpacked. + sb.append(" if (_wire == com.codename1.io.grpc.ProtoWriter.WIRE_LEN) {\n"); + sb.append(" in.readPacked(").append(target).append(", new com.codename1.io.grpc.ProtoReader.PackedReader<") + .append(elementJavaType(f)).append(">() {\n"); + sb.append(" public ").append(elementJavaType(f)).append(" read(com.codename1.io.grpc.ProtoReader _r) throws java.io.IOException {\n"); + sb.append(" ").append(elementReadExpr(f)).append("\n"); + sb.append(" }\n"); + sb.append(" });\n"); + sb.append(" } else {\n"); + sb.append(" ").append(target).append(".add(") + .append(elementReadInline(f)).append(");\n"); + sb.append(" }\n"); + } + } + + private static String elementReadExpr(ProtoFieldInfo f) { + switch (f.kind) { + case INT32: return "return _r.readVarint32();"; + case SINT32: return "return _r.readSInt32();"; + case FIXED32: return "return _r.readFixed32();"; + case INT64: return "return _r.readVarint64();"; + case SINT64: return "return _r.readSInt64();"; + case FIXED64: return "return _r.readFixed64();"; + case FLOAT: return "return _r.readFloat();"; + case DOUBLE: return "return _r.readDouble();"; + case BOOL: return "return _r.readBool();"; + default: return "return null;"; + } + } + + private static String elementReadInline(ProtoFieldInfo f) { + switch (f.kind) { + case INT32: return "in.readVarint32()"; + case SINT32: return "in.readSInt32()"; + case FIXED32: return "in.readFixed32()"; + case INT64: return "in.readVarint64()"; + case SINT64: return "in.readSInt64()"; + case FIXED64: return "in.readFixed64()"; + case FLOAT: return "in.readFloat()"; + case DOUBLE: return "in.readDouble()"; + case BOOL: return "in.readBool()"; + default: return "null"; + } + } + + private static String generateBootstrapSource(Iterable classes) { + 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("/// gRPC protobuf codec bootstrap. The iOS / Android per-build\n"); + sb.append("/// application stub instantiates this class before Display.init\n"); + sb.append("/// (the build server probes the project zip for it and emits the\n"); + sb.append("/// install line conditionally); JavaSEPort picks it up via\n"); + sb.append("/// Class.forName 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 (ProtoClass pc : classes) { + sb.append(" ").append(pc.codecBinaryName).append(".register();\n"); + } + sb.append(" }\n"); + sb.append("}\n"); + return sb.toString(); + } + + // ---------------------------------------------------------------- + // Helpers + // ---------------------------------------------------------------- + + private static String simpleName(String binaryName) { + int dot = binaryName.lastIndexOf('.'); + return dot < 0 ? binaryName : binaryName.substring(dot + 1); + } + + private static String packageOf(String binaryName) { + int dot = binaryName.lastIndexOf('.'); + return dot < 0 ? "" : binaryName.substring(0, dot); + } + + // ---------------------------------------------------------------- + // Accumulators + // ---------------------------------------------------------------- + + enum ProtoKind { + INT32, SINT32, FIXED32, + INT64, SINT64, FIXED64, + FLOAT, DOUBLE, BOOL, + STRING, BYTES, + MESSAGE, ENUM, + UNSUPPORTED + } + + static final class ProtoClass { + String binaryName; + String simpleName; + String packageName; + String codecSimpleName; + String codecBinaryName; + boolean isRecord; + final List fields = new ArrayList(); + } + + static final class ProtoFieldInfo { + String name; + String descriptor; + String signature; + int tag; + String wireKind = "DEFAULT"; + boolean repeated; + boolean boxed; + ProtoKind kind; + boolean pendingReference; + String referenceInternalName; + String elementDescriptor; + } +} 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 c40525751f..212927fabb 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 @@ -3,3 +3,5 @@ com.codename1.maven.processors.MappingAnnotationProcessor com.codename1.maven.processors.BindingAnnotationProcessor com.codename1.maven.processors.OrmAnnotationProcessor com.codename1.maven.processors.RestClientAnnotationProcessor +com.codename1.maven.processors.ProtoMessageAnnotationProcessor +com.codename1.maven.processors.GrpcClientAnnotationProcessor diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/io/grpc/GrpcWebTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/io/grpc/GrpcWebTest.java new file mode 100644 index 0000000000..71bf5101d4 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/io/grpc/GrpcWebTest.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + */ +package com.codename1.io.grpc; + +import org.junit.Test; + +import java.io.ByteArrayOutputStream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/// Tests gRPC-Web payload framing and trailer parsing in +/// [GrpcWeb]. Doesn't touch the network -- assembles raw response +/// bytes and runs them through [GrpcWeb#decode]. +public class GrpcWebTest { + + @Test + public void frameWrapsPayloadWithFiveByteHeader() throws Exception { + ProtoCodec codec = new ProtoCodec() { + public void write(ProtoWriter out, String v) throws java.io.IOException { + out.writeString(1, v); + } + public String read(ProtoReader in) throws java.io.IOException { + int tag = in.readTag(); + return tag == 0 ? "" : in.readString(); + } + }; + byte[] framed = GrpcWeb.frame("hi", codec); + assertEquals("flag byte is 0 for data frame", 0, framed[0]); + int len = ((framed[1] & 0xFF) << 24) | ((framed[2] & 0xFF) << 16) + | ((framed[3] & 0xFF) << 8) | (framed[4] & 0xFF); + assertEquals("length matches payload size", framed.length - 5, len); + } + + @Test + public void decodeOkResponseUnframesAndDeserialises() throws Exception { + ProtoCodec codec = new ProtoCodec() { + public void write(ProtoWriter out, String v) throws java.io.IOException { + out.writeString(1, v); + } + public String read(ProtoReader in) throws java.io.IOException { + String s = ""; + int tag; + while ((tag = in.readTag()) != 0) { + if ((tag >>> 3) == 1) s = in.readString(); + else in.skipField(tag); + } + return s; + } + }; + byte[] payload = ProtoEncodeHelper.encodeString(1, "hello world"); + byte[] frame = withFrameHeader((byte) 0, payload); + byte[] trailer = "grpc-status:0\r\ngrpc-message:OK\r\n".getBytes("UTF-8"); + byte[] trailerFrame = withFrameHeader((byte) 0x80, trailer); + byte[] body = concat(frame, trailerFrame); + + GrpcResponse resp = GrpcWeb.decode(body, 200, codec); + assertTrue("status 0 -> ok", resp.isOk()); + assertEquals(200, resp.getHttpCode()); + assertEquals("hello world", resp.getResponseData()); + } + + @Test + public void decodeStatusFailureLeavesNullPayload() throws Exception { + ProtoCodec codec = new ProtoCodec() { + public void write(ProtoWriter out, String v) {} + public String read(ProtoReader in) { return ""; } + }; + byte[] trailer = "grpc-status:7\r\ngrpc-message:nope\r\n".getBytes("UTF-8"); + byte[] body = withFrameHeader((byte) 0x80, trailer); + GrpcResponse resp = GrpcWeb.decode(body, 200, codec); + assertFalse("non-zero gRPC status -> not ok", resp.isOk()); + assertEquals(GrpcResponse.STATUS_PERMISSION_DENIED, resp.getResponseCode()); + assertEquals("nope", resp.getResponseErrorMessage()); + assertNull(resp.getResponseData()); + } + + @Test + public void decodeWithoutTrailerReportsTransportFailure() { + ProtoCodec codec = new ProtoCodec() { + public void write(ProtoWriter out, String v) {} + public String read(ProtoReader in) { return ""; } + }; + // Data frame only, no trailer -> transport failure. + byte[] body = withFrameHeader((byte) 0, new byte[]{ 1, 2, 3 }); + GrpcResponse resp = GrpcWeb.decode(body, 200, codec); + assertEquals(GrpcResponse.STATUS_TRANSPORT_FAILURE, resp.getResponseCode()); + } + + @Test + public void decodeEmptyBodyReportsTransportFailure() { + ProtoCodec codec = new ProtoCodec() { + public void write(ProtoWriter out, String v) {} + public String read(ProtoReader in) { return ""; } + }; + GrpcResponse resp = GrpcWeb.decode(new byte[0], 502, codec); + assertEquals(GrpcResponse.STATUS_TRANSPORT_FAILURE, resp.getResponseCode()); + assertEquals(502, resp.getHttpCode()); + } + + // -- helpers ------------------------------------------------------ + + private static byte[] withFrameHeader(byte flags, byte[] body) { + byte[] out = new byte[5 + body.length]; + out[0] = flags; + out[1] = (byte) ((body.length >>> 24) & 0xFF); + out[2] = (byte) ((body.length >>> 16) & 0xFF); + out[3] = (byte) ((body.length >>> 8) & 0xFF); + out[4] = (byte) (body.length & 0xFF); + System.arraycopy(body, 0, out, 5, body.length); + return out; + } + + private static byte[] concat(byte[] a, byte[] b) { + byte[] out = new byte[a.length + b.length]; + System.arraycopy(a, 0, out, 0, a.length); + System.arraycopy(b, 0, out, a.length, b.length); + return out; + } + + private static final class ProtoEncodeHelper { + static byte[] encodeString(int tag, String s) throws Exception { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + new ProtoWriter(buf).writeString(tag, s); + return buf.toByteArray(); + } + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/io/grpc/ProtoCodecTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/io/grpc/ProtoCodecTest.java new file mode 100644 index 0000000000..10c7bd0661 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/io/grpc/ProtoCodecTest.java @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + */ +package com.codename1.io.grpc; + +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.util.Arrays; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/// Wire-level round-trip tests for [ProtoWriter] / [ProtoReader]. +/// Every test encodes one or more fields, decodes them back, and +/// verifies bit-for-bit fidelity against the values that went in. +/// Independent of any generated codec. +public class ProtoCodecTest { + + @Test + public void varintRoundTrip() throws Exception { + long[] cases = { 0L, 1L, 127L, 128L, 16383L, 16384L, + Integer.MAX_VALUE, ((long) Integer.MAX_VALUE) + 1L, + Long.MAX_VALUE, -1L, Long.MIN_VALUE }; + for (long v : cases) { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + new ProtoWriter(buf).writeVarint64(v); + ProtoReader r = new ProtoReader(buf.toByteArray()); + assertEquals("varint round-trip for " + v, v, r.readVarint64()); + assertTrue("reader consumed entire buffer for " + v, r.isAtEnd()); + } + } + + @Test + public void zigZagRoundTrip() { + int[] s32 = { 0, -1, 1, -2, 2, Integer.MAX_VALUE, Integer.MIN_VALUE }; + for (int v : s32) { + assertEquals("zigzag32 round-trip for " + v, + v, ProtoReader.zagZig32(ProtoWriter.zigZag32(v))); + } + long[] s64 = { 0L, -1L, 1L, Long.MAX_VALUE, Long.MIN_VALUE }; + for (long v : s64) { + assertEquals("zigzag64 round-trip for " + v, + v, ProtoReader.zagZig64(ProtoWriter.zigZag64(v))); + } + } + + @Test + public void fixed32And64() throws Exception { + int[] f32 = { 0, 1, -1, 0x7FFFFFFF, (int) 0xDEADBEEF }; + for (int v : f32) { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + new ProtoWriter(buf).writeFixed32(v); + assertEquals(4, buf.size()); + assertEquals(v, new ProtoReader(buf.toByteArray()).readFixed32()); + } + long[] f64 = { 0L, 1L, -1L, 0x7FFFFFFFFFFFFFFFL, 0xCAFEBABEDEADBEEFL }; + for (long v : f64) { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + new ProtoWriter(buf).writeFixed64(v); + assertEquals(8, buf.size()); + assertEquals(v, new ProtoReader(buf.toByteArray()).readFixed64()); + } + } + + @Test + public void scalarFieldsSkipDefaults() throws Exception { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + ProtoWriter w = new ProtoWriter(buf); + w.writeInt32(1, 0); // default -- skipped + w.writeInt32(2, 42); // emitted + w.writeString(3, ""); // default -- skipped + w.writeString(4, "hi"); // emitted + w.writeBool(5, false); // default -- skipped + w.writeBool(6, true); // emitted + assertFalse("default-valued fields must be skipped", buf.size() == 0 + && false); // ensure we still produce bytes for non-defaults + ProtoReader r = new ProtoReader(buf.toByteArray()); + int tag1 = r.readTag(); assertEquals(2, tag1 >>> 3); assertEquals(42, r.readVarint32()); + int tag2 = r.readTag(); assertEquals(4, tag2 >>> 3); assertEquals("hi", r.readString()); + int tag3 = r.readTag(); assertEquals(6, tag3 >>> 3); assertTrue(r.readBool()); + assertTrue("no further fields", r.isAtEnd()); + } + + @Test + public void floatAndDoubleRoundTrip() throws Exception { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + ProtoWriter w = new ProtoWriter(buf); + w.writeFloat(1, 3.14f); + w.writeDouble(2, 2.71828); + ProtoReader r = new ProtoReader(buf.toByteArray()); + assertEquals(1, r.readTag() >>> 3); assertEquals(3.14f, r.readFloat(), 0.0f); + assertEquals(2, r.readTag() >>> 3); assertEquals(2.71828, r.readDouble(), 0.0); + } + + @Test + public void bytesAndStringFields() throws Exception { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + ProtoWriter w = new ProtoWriter(buf); + w.writeString(1, "hello é"); + byte[] payload = new byte[]{ 1, 2, 3, (byte) 0xFF }; + w.writeBytes(2, payload); + ProtoReader r = new ProtoReader(buf.toByteArray()); + assertEquals(1, r.readTag() >>> 3); + assertEquals("hello é", r.readString()); + assertEquals(2, r.readTag() >>> 3); + assertArrayEquals(payload, r.readBytes()); + } + + @Test + public void nestedMessageRoundTrip() throws Exception { + ProtoCodec innerCodec = new ProtoCodec() { + public void write(ProtoWriter out, Inner v) throws java.io.IOException { + out.writeInt32(1, v.n); + out.writeString(2, v.s); + } + public Inner read(ProtoReader in) throws java.io.IOException { + Inner i = new Inner(); + int tag; + while ((tag = in.readTag()) != 0) { + switch (tag >>> 3) { + case 1: i.n = in.readVarint32(); break; + case 2: i.s = in.readString(); break; + default: in.skipField(tag); + } + } + return i; + } + }; + Inner src = new Inner(); src.n = 7; src.s = "child"; + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + ProtoWriter w = new ProtoWriter(buf); + w.writeMessage(5, src, innerCodec); + ProtoReader r = new ProtoReader(buf.toByteArray()); + assertEquals(5, r.readTag() >>> 3); + Inner decoded = r.readMessage(innerCodec); + assertEquals(7, decoded.n); + assertEquals("child", decoded.s); + } + + @Test + public void packedRepeatedInt32() throws Exception { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + new ProtoWriter(buf).writePackedInt32(1, Arrays.asList(1, 2, 3, 127, 128)); + ProtoReader r = new ProtoReader(buf.toByteArray()); + int tag = r.readTag(); + assertEquals(1, tag >>> 3); + assertEquals(ProtoWriter.WIRE_LEN, tag & 7); + java.util.List out = new java.util.ArrayList(); + r.readPacked(out, new ProtoReader.PackedReader() { + public Integer read(ProtoReader rr) throws java.io.IOException { return rr.readVarint32(); } + }); + assertEquals(Arrays.asList(1, 2, 3, 127, 128), out); + } + + @Test + public void skipUnknownField() throws Exception { + // Write a known field then an unknown one; reader should + // consume the known field, see the unknown tag, skip it, + // then EOF. + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + ProtoWriter w = new ProtoWriter(buf); + w.writeString(1, "x"); + w.writeInt32(99, 0xDEADBEEF); + ProtoReader r = new ProtoReader(buf.toByteArray()); + assertEquals(1, r.readTag() >>> 3); + assertEquals("x", r.readString()); + int unknown = r.readTag(); + assertEquals(99, unknown >>> 3); + r.skipField(unknown); + assertTrue(r.isAtEnd()); + } + + private static final class Inner { + int n; + String s; + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/GenerateGrpcMojoTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/GenerateGrpcMojoTest.java new file mode 100644 index 0000000000..3874617b6b --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/GenerateGrpcMojoTest.java @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + */ +package com.codename1.maven; + +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.io.IOException; +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Drives the proto3 parser + generator against an inline `.proto` +/// fixture to verify message / enum / service emission shape on both +/// the record (Java 17+) and class (Java 8) code paths. +class GenerateGrpcMojoTest { + + private static final String HELLOWORLD_PROTO = + "// header comment\n" + + "syntax = \"proto3\";\n" + + "package helloworld;\n\n" + + "/* request */\n" + + "message HelloRequest {\n" + + " string name = 1;\n" + + " repeated string aliases = 2;\n" + + " Mood mood = 3;\n" + + "}\n\n" + + "message HelloReply {\n" + + " string message = 1;\n" + + " sint32 emoji_code = 2;\n" + + " HelloRequest echo = 3;\n" + + "}\n\n" + + "enum Mood {\n" + + " UNKNOWN = 0;\n" + + " HAPPY = 1;\n" + + " SAD = 2;\n" + + "}\n\n" + + "service Greeter {\n" + + " rpc SayHello (HelloRequest) returns (HelloReply);\n" + + "}\n"; + + @Test + void emitsMessagesEnumsAndServiceAsRecords(@TempDir Path tmp) throws Exception { + GenerateGrpcMojo.ProtoFile parsed = parse(HELLOWORLD_PROTO); + File out = tmp.toFile(); + new GenerateGrpcMojo.Generator(parsed, "com.example.hello", out, + /*overwrite*/ true, /*emitRecords*/ true, new SystemStreamLog()).run(); + + String reqSrc = readString(out, "com/example/hello/HelloRequest.java"); + assertTrue(reqSrc.contains("@ProtoMessage"), "HelloRequest should be @ProtoMessage"); + assertTrue(reqSrc.contains("public record HelloRequest("), + "HelloRequest should be a record on Java 17 target; was:\n" + reqSrc); + assertTrue(reqSrc.contains("@ProtoField(tag = 1) String name"), + "name field shape; was:\n" + reqSrc); + assertTrue(reqSrc.contains("@ProtoField(tag = 2) List aliases"), + "repeated string -> List; was:\n" + reqSrc); + assertTrue(reqSrc.contains("com.example.hello.Mood mood"), + "Mood resolved to FQN; was:\n" + reqSrc); + + String replySrc = readString(out, "com/example/hello/HelloReply.java"); + assertTrue(replySrc.contains("@ProtoField(tag = 2, wireType = ProtoField.WireKind.SINT, name = \"emoji_code\") int emojiCode"), + "sint32 -> SINT wire kind; snake_case -> camelCase preserves name; was:\n" + replySrc); + assertTrue(replySrc.contains("com.example.hello.HelloRequest echo"), + "nested message resolved; was:\n" + replySrc); + + String moodSrc = readString(out, "com/example/hello/Mood.java"); + assertTrue(moodSrc.contains("@ProtoEnum"), "Mood should be @ProtoEnum"); + assertTrue(moodSrc.contains("public enum Mood"), "Mood should be an enum"); + assertTrue(moodSrc.contains("HAPPY(1)"), "Mood values include HAPPY(1); was:\n" + moodSrc); + assertTrue(moodSrc.contains("public static Mood forNumber(int n)"), + "forNumber lookup helper present"); + + String svcSrc = readString(out, "com/example/hello/GreeterGrpc.java"); + assertTrue(svcSrc.contains("@GrpcClient(\"helloworld.Greeter\")"), + "service path joins proto package + service name; was:\n" + svcSrc); + assertTrue(svcSrc.contains("@Rpc(\"SayHello\")"), "rpc binding; was:\n" + svcSrc); + assertTrue(svcSrc.contains("void sayHello(com.example.hello.HelloRequest request, " + + "@Header(\"Authorization\") String bearerToken, " + + "OnComplete> callback);"), + "RPC method shape; was:\n" + svcSrc); + assertTrue(svcSrc.contains("static GreeterGrpc of(String baseUrl)"), + "static of(...) factory; was:\n" + svcSrc); + assertTrue(svcSrc.contains("GrpcClients.create(GreeterGrpc.class, baseUrl)"), + "of(...) delegates to GrpcClients.create"); + } + + @Test + void emitsClassesOnJava8Target(@TempDir Path tmp) throws Exception { + GenerateGrpcMojo.ProtoFile parsed = parse(HELLOWORLD_PROTO); + File out = tmp.toFile(); + new GenerateGrpcMojo.Generator(parsed, "com.example.hello", out, true, + /*emitRecords*/ false, new SystemStreamLog()).run(); + + String reqSrc = readString(out, "com/example/hello/HelloRequest.java"); + assertTrue(reqSrc.contains("public class HelloRequest {"), + "class form on Java 8 target"); + assertTrue(reqSrc.contains("public String name;"), "name as public field"); + assertTrue(reqSrc.contains("public HelloRequest() {}"), "no-arg ctor"); + } + + @Test + void respectsOverwriteFalse(@TempDir Path tmp) throws Exception { + GenerateGrpcMojo.ProtoFile parsed = parse(HELLOWORLD_PROTO); + File out = tmp.toFile(); + File svcDir = new File(out, "com/example/hello"); + if (!svcDir.exists() && !svcDir.mkdirs()) throw new IOException("mkdirs"); + File svcFile = new File(svcDir, "GreeterGrpc.java"); + Files.write(svcFile.toPath(), "// hand-edited".getBytes(StandardCharsets.UTF_8)); + + new GenerateGrpcMojo.Generator(parsed, "com.example.hello", out, + /*overwrite*/ false, true, new SystemStreamLog()).run(); + String svcSrc = new String(Files.readAllBytes(svcFile.toPath()), StandardCharsets.UTF_8); + assertTrue(svcSrc.startsWith("// hand-edited"), + "overwrite=false should preserve user edits"); + } + + @Test + void streamingRpcRejected() { + String src = "syntax = \"proto3\"; service S { rpc R (stream Req) returns (Resp); }\n"; + GenerateGrpcMojo.ProtoParseException ex = assertThrows( + GenerateGrpcMojo.ProtoParseException.class, + () -> new GenerateGrpcMojo.ProtoParser(src, "t.proto").parseFile()); + assertTrue(ex.getMessage().contains("Streaming RPCs"), + "expected streaming rejection; got: " + ex.getMessage()); + } + + @Test + void snakeCaseFieldNameConversion() { + assertEquals("emojiCode", GenerateGrpcMojo.Generator.javaName("emoji_code")); + assertEquals("alreadyCamel", GenerateGrpcMojo.Generator.javaName("alreadyCamel")); + assertEquals("class_", GenerateGrpcMojo.Generator.javaName("class"), + "reserved word should be suffixed with underscore"); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + private static GenerateGrpcMojo.ProtoFile parse(String src) { + return new GenerateGrpcMojo.ProtoParser(src, "t.proto").parseFile(); + } + + private static String readString(File root, String relative) throws IOException { + File f = new File(root, relative); + assertTrue(f.exists(), "expected file " + relative + " at " + f); + return new String(Files.readAllBytes(f.toPath()), StandardCharsets.UTF_8); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/GrpcClientAnnotationProcessorTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/GrpcClientAnnotationProcessorTest.java new file mode 100644 index 0000000000..e0b372e01c --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/GrpcClientAnnotationProcessorTest.java @@ -0,0 +1,220 @@ +/* + * 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 `GrpcClientAnnotationProcessor`. Compiles a +/// `@GrpcClient` Greeter fixture, runs both the proto and gRPC +/// processors, asserts the generated impl chains +/// `GrpcWeb.invokeUnary` with the right service / method names and +/// codec references. +public class GrpcClientAnnotationProcessorTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void emitsImplAndBootstrapForGreeter() throws Exception { + File classes = tmp.newFolder("classes"); + Map sources = new LinkedHashMap(); + sources.put("com.example.hello.HelloRequest", + "package com.example.hello;\n" + + "import com.codename1.annotations.grpc.*;\n" + + "@ProtoMessage public class HelloRequest {\n" + + " @ProtoField(tag = 1) public String name;\n" + + " public HelloRequest() {}\n" + + "}\n"); + sources.put("com.example.hello.HelloReply", + "package com.example.hello;\n" + + "import com.codename1.annotations.grpc.*;\n" + + "@ProtoMessage public class HelloReply {\n" + + " @ProtoField(tag = 1) public String message;\n" + + " public HelloReply() {}\n" + + "}\n"); + sources.put("com.example.hello.GreeterGrpc", + "package com.example.hello;\n" + + "import com.codename1.annotations.grpc.*;\n" + + "import com.codename1.annotations.rest.Header;\n" + + "import com.codename1.io.grpc.GrpcResponse;\n" + + "import com.codename1.util.OnComplete;\n" + + "@GrpcClient(\"helloworld.Greeter\")\n" + + "public interface GreeterGrpc {\n" + + " @Rpc(\"SayHello\")\n" + + " void sayHello(HelloRequest req,\n" + + " @Header(\"Authorization\") String bearerToken,\n" + + " OnComplete> cb);\n" + + "}\n"); + JavaSourceCompiler.compile(sources, classes, Arrays.asList(testClassesDir())); + + // Run the proto processor first so the codecs exist in the + // classpath before the gRPC processor's impl source compiles. + ProcessorContext protoCtx = runProtoProcessor(classes); + if (protoCtx.hasErrors()) failProcessor("proto", protoCtx); + + ProcessorContext grpcCtx = runGrpcProcessor(classes); + if (grpcCtx.hasErrors()) failProcessor("gRPC", grpcCtx); + + assertTrue("expected GreeterGrpcImpl.class", + new File(classes, "com/example/hello/GreeterGrpcImpl.class").exists()); + assertTrue("expected GrpcClientBootstrap.class", + new File(classes, "cn1app/GrpcClientBootstrap.class").exists()); + + String implSrc = generateImplSourceForFixture(classes); + assertTrue("impl should call GrpcWeb.invokeUnary; was:\n" + implSrc, + implSrc.contains("com.codename1.io.grpc.GrpcWeb.invokeUnary")); + assertTrue("impl should pass the gRPC service path", + implSrc.contains("\"helloworld.Greeter\"")); + assertTrue("impl should pass the gRPC method name", + implSrc.contains("\"SayHello\"")); + assertTrue("impl should reference request codec INSTANCE", + implSrc.contains("com.example.hello.HelloRequestProtoCodec.INSTANCE")); + assertTrue("impl should reference response codec INSTANCE", + implSrc.contains("com.example.hello.HelloReplyProtoCodec.INSTANCE")); + + String bootSrc = generateBootstrapSourceForFixture(classes); + assertTrue("bootstrap should register GreeterGrpc; was:\n" + bootSrc, + bootSrc.contains("GrpcClients.register(com.example.hello.GreeterGrpc.class")); + assertTrue("bootstrap should instantiate GreeterGrpcImpl", + bootSrc.contains("new com.example.hello.GreeterGrpcImpl(baseUrl)")); + } + + @Test + public void rejectsMissingRpcAnnotation() throws Exception { + File classes = tmp.newFolder("classes"); + Map sources = new LinkedHashMap(); + sources.put("com.example.x.Req", + "package com.example.x;\n" + + "import com.codename1.annotations.grpc.*;\n" + + "@ProtoMessage public class Req { public Req() {} }\n"); + sources.put("com.example.x.Resp", + "package com.example.x;\n" + + "import com.codename1.annotations.grpc.*;\n" + + "@ProtoMessage public class Resp { public Resp() {} }\n"); + sources.put("com.example.x.MissingRpc", + "package com.example.x;\n" + + "import com.codename1.annotations.grpc.GrpcClient;\n" + + "import com.codename1.io.grpc.GrpcResponse;\n" + + "import com.codename1.util.OnComplete;\n" + + "@GrpcClient(\"x.MissingRpc\")\n" + + "public interface MissingRpc {\n" + + " void unmarked(Req r, OnComplete> cb);\n" + + "}\n"); + JavaSourceCompiler.compile(sources, classes, Arrays.asList(testClassesDir())); + ProcessorContext ctx = runGrpcProcessor(classes); + assertTrue("expected error on method without @Rpc", ctx.hasErrors()); + } + + @Test + public void rejectsGrpcClientOnConcreteClass() throws Exception { + File classes = tmp.newFolder("classes"); + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource("com.example.Bad", + "package com.example;\n" + + "import com.codename1.annotations.grpc.GrpcClient;\n" + + "@GrpcClient(\"x.Bad\") public class Bad {}\n"), + classes, Arrays.asList(testClassesDir())); + ProcessorContext ctx = runGrpcProcessor(classes); + assertTrue("expected error on @GrpcClient applied to a class", ctx.hasErrors()); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + private ProcessorContext runProtoProcessor(File classesDir) throws Exception { + Map index = ClassScanner.scan(classesDir); + ProtoMessageAnnotationProcessor proc = new ProtoMessageAnnotationProcessor(); + 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 ProcessorContext runGrpcProcessor(File classesDir) throws Exception { + Map index = ClassScanner.scan(classesDir); + GrpcClientAnnotationProcessor proc = new GrpcClientAnnotationProcessor(); + 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 { + Map index = ClassScanner.scan(classesDir); + GrpcClientAnnotationProcessor proc = new GrpcClientAnnotationProcessor(); + 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); + } + java.lang.reflect.Field accepted = GrpcClientAnnotationProcessor.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 = GrpcClientAnnotationProcessor.class + .getDeclaredMethod("generateImplSource", + Class.forName("com.codename1.maven.processors.GrpcClientAnnotationProcessor$GrpcApi")); + m.setAccessible(true); + return (String) m.invoke(null, api); + } + + private String generateBootstrapSourceForFixture(File classesDir) throws Exception { + Map index = ClassScanner.scan(classesDir); + GrpcClientAnnotationProcessor proc = new GrpcClientAnnotationProcessor(); + 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); + } + java.lang.reflect.Field accepted = GrpcClientAnnotationProcessor.class + .getDeclaredField("accepted"); + accepted.setAccessible(true); + java.util.TreeMap map = (java.util.TreeMap) accepted.get(proc); + java.lang.reflect.Method m = GrpcClientAnnotationProcessor.class + .getDeclaredMethod("generateBootstrapSource", Iterable.class); + m.setAccessible(true); + return (String) m.invoke(null, map.values()); + } + + private static void failProcessor(String which, ProcessorContext ctx) { + StringBuilder sb = new StringBuilder(which + " 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 = GrpcClientAnnotationProcessorTest.class.getProtectionDomain() + .getCodeSource().getLocation(); + return new File(url.toURI()); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/ProtoMessageAnnotationProcessorTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/ProtoMessageAnnotationProcessorTest.java new file mode 100644 index 0000000000..c1e3b0334f --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/ProtoMessageAnnotationProcessorTest.java @@ -0,0 +1,206 @@ +/* + * 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 `ProtoMessageAnnotationProcessor`. Compiles +/// a `@ProtoMessage` Pet fixture, runs the processor, asserts the +/// generated codec source carries the expected +/// `ProtoWriter` / `ProtoReader` calls and the bootstrap registers +/// the codec. +public class ProtoMessageAnnotationProcessorTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void emitsCodecAndBootstrapForPet() throws Exception { + File classes = tmp.newFolder("classes"); + Map sources = new LinkedHashMap(); + sources.put("com.example.Pet", + "package com.example;\n" + + "import com.codename1.annotations.grpc.ProtoField;\n" + + "import com.codename1.annotations.grpc.ProtoMessage;\n" + + "import java.util.List;\n" + + "@ProtoMessage public class Pet {\n" + + " @ProtoField(tag = 1) public long id;\n" + + " @ProtoField(tag = 2) public String name;\n" + + " @ProtoField(tag = 3) public List tags;\n" + + " @ProtoField(tag = 4) public Owner owner;\n" + + " public Pet() {}\n" + + "}\n"); + sources.put("com.example.Owner", + "package com.example;\n" + + "import com.codename1.annotations.grpc.ProtoField;\n" + + "import com.codename1.annotations.grpc.ProtoMessage;\n" + + "@ProtoMessage public class Owner {\n" + + " @ProtoField(tag = 1) public String email;\n" + + " public Owner() {}\n" + + "}\n"); + JavaSourceCompiler.compile(sources, classes, Arrays.asList(testClassesDir())); + + ProcessorContext ctx = runProcessor(classes); + if (ctx.hasErrors()) { + StringBuilder sb = new StringBuilder("processor reported errors:\n"); + for (ProcessorContext.ProcessingError e : ctx.getErrors()) sb.append(' ').append(e).append('\n'); + fail(sb.toString()); + } + + // Codecs land at .ProtoCodec, bootstrap at cn1app.ProtoBootstrap. + assertTrue("expected PetProtoCodec.class", + new File(classes, "com/example/PetProtoCodec.class").exists()); + assertTrue("expected OwnerProtoCodec.class", + new File(classes, "com/example/OwnerProtoCodec.class").exists()); + assertTrue("expected ProtoBootstrap.class", + new File(classes, "cn1app/ProtoBootstrap.class").exists()); + + String petCodecSrc = generateCodecSourceForFixture(classes, "com.example.Pet"); + assertTrue("codec emits writeInt64 for id; was:\n" + petCodecSrc, + petCodecSrc.contains("out.writeInt64(1, value.id);")); + assertTrue("codec emits writeString for name", + petCodecSrc.contains("out.writeString(2, value.name);")); + assertTrue("codec emits writeStringList for tags", + petCodecSrc.contains("out.writeStringList(3, value.tags);")); + assertTrue("codec emits writeMessage with OwnerProtoCodec.INSTANCE for owner", + petCodecSrc.contains("out.writeMessage(4, value.owner, com.example.OwnerProtoCodec.INSTANCE);")); + + // Read path dispatches on tag. + assertTrue("read emits switch on field number", + petCodecSrc.contains("switch (_field) {")); + assertTrue("read uses readMessage with the Owner codec", + petCodecSrc.contains("in.readMessage(com.example.OwnerProtoCodec.INSTANCE)")); + + String bootSrc = generateBootstrapSourceForFixture(classes); + assertTrue("bootstrap registers Pet codec; was:\n" + bootSrc, + bootSrc.contains("com.example.PetProtoCodec.register();")); + assertTrue("bootstrap registers Owner codec", + bootSrc.contains("com.example.OwnerProtoCodec.register();")); + } + + @Test + public void rejectsZeroTag() throws Exception { + File classes = tmp.newFolder("classes"); + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource("com.example.Bad", + "package com.example;\n" + + "import com.codename1.annotations.grpc.*;\n" + + "@ProtoMessage public class Bad {\n" + + " @ProtoField(tag = 0) public int n;\n" + + " public Bad() {}\n" + + "}\n"), + classes, Arrays.asList(testClassesDir())); + ProcessorContext ctx = runProcessor(classes); + assertTrue("expected error on tag=0", ctx.hasErrors()); + } + + @Test + public void rejectsDuplicateTags() throws Exception { + File classes = tmp.newFolder("classes"); + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource("com.example.Dup", + "package com.example;\n" + + "import com.codename1.annotations.grpc.*;\n" + + "@ProtoMessage public class Dup {\n" + + " @ProtoField(tag = 1) public int a;\n" + + " @ProtoField(tag = 1) public int b;\n" + + " public Dup() {}\n" + + "}\n"), + classes, Arrays.asList(testClassesDir())); + ProcessorContext ctx = runProcessor(classes); + assertTrue("expected error on duplicate tag", ctx.hasErrors()); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + private ProcessorContext runProcessor(File classesDir) throws Exception { + Map index = ClassScanner.scan(classesDir); + ProtoMessageAnnotationProcessor proc = new ProtoMessageAnnotationProcessor(); + 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 generateCodecSourceForFixture(File classesDir, String fqn) throws Exception { + Map index = ClassScanner.scan(classesDir); + ProtoMessageAnnotationProcessor proc = new ProtoMessageAnnotationProcessor(); + 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); + } + // Resolve pending references the way finish() would (but skip the + // compile step so we get the raw source back). + java.lang.reflect.Method r = ProtoMessageAnnotationProcessor.class + .getDeclaredMethod("resolveReferenceKind", + Class.forName("com.codename1.maven.processors.ProtoMessageAnnotationProcessor$ProtoFieldInfo"), + ProcessorContext.class); + r.setAccessible(true); + java.lang.reflect.Field accepted = ProtoMessageAnnotationProcessor.class + .getDeclaredField("messages"); + accepted.setAccessible(true); + java.util.TreeMap map = (java.util.TreeMap) accepted.get(proc); + Object target = map.get(fqn); + java.lang.reflect.Field fields = target.getClass().getDeclaredField("fields"); + fields.setAccessible(true); + for (Object f : (java.util.List) fields.get(target)) { + r.invoke(proc, f, ctx); + } + java.lang.reflect.Method gen = ProtoMessageAnnotationProcessor.class + .getDeclaredMethod("generateCodecSource", + Class.forName("com.codename1.maven.processors.ProtoMessageAnnotationProcessor$ProtoClass")); + gen.setAccessible(true); + return (String) gen.invoke(null, target); + } + + private String generateBootstrapSourceForFixture(File classesDir) throws Exception { + Map index = ClassScanner.scan(classesDir); + ProtoMessageAnnotationProcessor proc = new ProtoMessageAnnotationProcessor(); + 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); + } + java.lang.reflect.Field accepted = ProtoMessageAnnotationProcessor.class + .getDeclaredField("messages"); + accepted.setAccessible(true); + java.util.TreeMap map = (java.util.TreeMap) accepted.get(proc); + java.lang.reflect.Method m = ProtoMessageAnnotationProcessor.class + .getDeclaredMethod("generateBootstrapSource", Iterable.class); + m.setAccessible(true); + return (String) m.invoke(null, map.values()); + } + + private static File testClassesDir() throws Exception { + URL url = ProtoMessageAnnotationProcessorTest.class.getProtectionDomain() + .getCodeSource().getLocation(); + return new File(url.toURI()); + } +} From f9496ef67f17c0b4a75d8c6ed40532ed1310d1a2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 29 May 2026 21:43:32 +0300 Subject: [PATCH 2/5] Fix CI: CLDC-safe float/double bit conversions + doc lint Ant build uses a CLDC11.jar bootclasspath that lacks Float .floatToRawIntBits / Double.doubleToRawLongBits. Switch to the plain floatToIntBits / doubleToLongBits variants -- the only behavioural difference is that NaN bit patterns collapse to the canonical NaN, which protobuf does not distinguish anyway. Also: Vale Microsoft.Contractions wanted "doesn't" over "does not" in the appendix; LanguageTool's English dictionary did not recognise "protobuf", "varint", and "enums" so add them to the developer-guide accept list. Co-Authored-By: Claude Opus 4.7 (1M context) --- CodenameOne/src/com/codename1/io/grpc/ProtoWriter.java | 4 ++-- docs/developer-guide/appendix_goal_generate_grpc.adoc | 2 +- docs/developer-guide/languagetool-accept.txt | 9 +++++++++ .../processors/ProtoMessageAnnotationProcessor.java | 4 ++-- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/CodenameOne/src/com/codename1/io/grpc/ProtoWriter.java b/CodenameOne/src/com/codename1/io/grpc/ProtoWriter.java index 11e015d82e..f8812497a0 100644 --- a/CodenameOne/src/com/codename1/io/grpc/ProtoWriter.java +++ b/CodenameOne/src/com/codename1/io/grpc/ProtoWriter.java @@ -161,13 +161,13 @@ public void writeBool(int field, boolean value) throws IOException { public void writeFloat(int field, float value) throws IOException { if (value == 0.0f) return; writeTag(field, WIRE_I32); - writeFixed32(Float.floatToRawIntBits(value)); + writeFixed32(Float.floatToIntBits(value)); } public void writeDouble(int field, double value) throws IOException { if (value == 0.0d) return; writeTag(field, WIRE_I64); - writeFixed64(Double.doubleToRawLongBits(value)); + writeFixed64(Double.doubleToLongBits(value)); } public void writeString(int field, String value) throws IOException { diff --git a/docs/developer-guide/appendix_goal_generate_grpc.adoc b/docs/developer-guide/appendix_goal_generate_grpc.adoc index f5bb3ad029..65a0917241 100644 --- a/docs/developer-guide/appendix_goal_generate_grpc.adoc +++ b/docs/developer-guide/appendix_goal_generate_grpc.adoc @@ -123,7 +123,7 @@ The runtime speaks **gRPC-Web binary** mobile / browser variant of gRPC supported by Envoy, the official `grpcweb` Go proxy, and the gRPC-Web filter in modern gRPC server implementations. Plain HTTP/2 gRPC requires trailers that -`ConnectionRequest` does not expose; gRPC-Web carries `grpc-status` +`ConnectionRequest` doesn't expose; gRPC-Web carries `grpc-status` in a synthetic trailer frame in the response body instead. A successful call: diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index 722246391f..d5de5a9211 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -550,3 +550,12 @@ Ollama [Aa]nthropic SAPI espeak + +# Protocol Buffers / gRPC technical terms used in the generate-grpc +# appendix and the @ProtoMessage / @GrpcClient runtime docs. "protobuf" +# is the de-facto short form of "Protocol Buffers"; "varint" is the +# Protocol Buffers variable-length integer encoding; "enums" is the +# plural form of the Java keyword. +[Pp]rotobuf +[Vv]arint +[Ee]nums diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/ProtoMessageAnnotationProcessor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/ProtoMessageAnnotationProcessor.java index f4d0bd7424..110688b296 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/ProtoMessageAnnotationProcessor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/ProtoMessageAnnotationProcessor.java @@ -433,8 +433,8 @@ private static void emitRepeatedScalarWrite(StringBuilder sb, ProtoFieldInfo f, case INT64: sb.append(" _sub.writeVarint64(").append(e).append(".get(_i));\n"); break; case SINT64: sb.append(" _sub.writeVarint64(com.codename1.io.grpc.ProtoWriter.zigZag64(").append(e).append(".get(_i)));\n"); break; case FIXED64: sb.append(" _sub.writeFixed64(").append(e).append(".get(_i));\n"); break; - case FLOAT: sb.append(" _sub.writeFixed32(Float.floatToRawIntBits(").append(e).append(".get(_i)));\n"); break; - case DOUBLE: sb.append(" _sub.writeFixed64(Double.doubleToRawLongBits(").append(e).append(".get(_i)));\n"); break; + case FLOAT: sb.append(" _sub.writeFixed32(Float.floatToIntBits(").append(e).append(".get(_i)));\n"); break; + case DOUBLE: sb.append(" _sub.writeFixed64(Double.doubleToLongBits(").append(e).append(".get(_i)));\n"); break; case BOOL: sb.append(" _sub.writeVarint32(").append(e).append(".get(_i) ? 1 : 0);\n"); break; case ENUM: sb.append(" _sub.writeVarint32(").append(e).append(".get(_i).number);\n"); break; default: break; From c776db8f346275c250f8e73f96540e4a63e37a42 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 29 May 2026 22:19:01 +0300 Subject: [PATCH 3/5] Fix SpotBugs EQ_DOESNT_OVERRIDE_EQUALS on GrpcConnection The inner GrpcConnection extends ConnectionRequest and adds state (response codec, callback, failure flags). SpotBugs flagged the missing equals/hashCode override; match the pattern used by RequestBuilder.Connection (delegate to super then compare the new fields). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/com/codename1/io/grpc/GrpcWeb.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CodenameOne/src/com/codename1/io/grpc/GrpcWeb.java b/CodenameOne/src/com/codename1/io/grpc/GrpcWeb.java index caa44990bf..fdf70b30f9 100644 --- a/CodenameOne/src/com/codename1/io/grpc/GrpcWeb.java +++ b/CodenameOne/src/com/codename1/io/grpc/GrpcWeb.java @@ -274,6 +274,26 @@ private static final class GrpcConnection extends ConnectionRequest { private int failedCode; private String failedMessage; + @Override + public boolean equals(Object o) { + if (!(o instanceof GrpcConnection)) return false; + GrpcConnection that = (GrpcConnection) o; + return super.equals(o) + && failed == that.failed + && failedCode == that.failedCode + && (respCodec == null ? that.respCodec == null : respCodec.equals(that.respCodec)) + && (callback == null ? that.callback == null : callback.equals(that.callback)) + && (failedMessage == null ? that.failedMessage == null : failedMessage.equals(that.failedMessage)); + } + + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + (respCodec == null ? 0 : respCodec.hashCode()); + result = 31 * result + (callback == null ? 0 : callback.hashCode()); + return result; + } + GrpcConnection(ProtoCodec respCodec, OnComplete callback) { this.respCodec = respCodec; this.callback = callback; From 4013808e35a606134f0f14766ac87cb10bbf90a7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 29 May 2026 22:35:12 +0300 Subject: [PATCH 4/5] Fix PMD: drop redundant public on nested enum + add braces to ifs PMD's ControlStatementBraces rule rejects every `if (cond) stmt;` style one-liner; brace the body so the rule passes. 28 sites across ProtoWriter / ProtoReader / GrpcWeb. No behaviour change. Also drop `public` from `WireKind` enum nested inside the `@ProtoField` annotation -- members of an annotation type are implicitly public, so PMD's UnnecessaryModifier rule flags the qualifier as redundant. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../annotations/grpc/ProtoField.java | 2 +- .../src/com/codename1/io/grpc/GrpcWeb.java | 10 ++--- .../com/codename1/io/grpc/ProtoReader.java | 8 ++-- .../com/codename1/io/grpc/ProtoWriter.java | 38 +++++++++---------- 4 files changed, 29 insertions(+), 29 deletions(-) diff --git a/CodenameOne/src/com/codename1/annotations/grpc/ProtoField.java b/CodenameOne/src/com/codename1/annotations/grpc/ProtoField.java index a62728dc6f..18975acce6 100644 --- a/CodenameOne/src/com/codename1/annotations/grpc/ProtoField.java +++ b/CodenameOne/src/com/codename1/annotations/grpc/ProtoField.java @@ -45,7 +45,7 @@ /// `int64` / `uint32` / `uint64` (varint). `SINT` matches /// `sint32` / `sint64` (ZigZag-encoded varint). `FIXED` matches /// `fixed32` / `fixed64` / `sfixed32` / `sfixed64` (fixed-width). - public enum WireKind { + enum WireKind { DEFAULT, SINT, FIXED } } diff --git a/CodenameOne/src/com/codename1/io/grpc/GrpcWeb.java b/CodenameOne/src/com/codename1/io/grpc/GrpcWeb.java index fdf70b30f9..ff6c60e57d 100644 --- a/CodenameOne/src/com/codename1/io/grpc/GrpcWeb.java +++ b/CodenameOne/src/com/codename1/io/grpc/GrpcWeb.java @@ -177,7 +177,7 @@ public static GrpcResponse decode(byte[] response, int httpCode, private static int[] parseTrailerStatus(String trailer) { // Lines are CRLF-separated; the spec allows LF too. int idx = indexOfHeader(trailer, "grpc-status"); - if (idx < 0) return new int[]{GrpcResponse.STATUS_UNKNOWN}; + if (idx < 0) { return new int[]{GrpcResponse.STATUS_UNKNOWN}; } int eol = endOfLine(trailer, idx); String value = trailer.substring(idx, eol).trim(); try { @@ -189,7 +189,7 @@ private static int[] parseTrailerStatus(String trailer) { private static String trailerMessage(String trailer) { int idx = indexOfHeader(trailer, "grpc-message"); - if (idx < 0) return null; + if (idx < 0) { return null; } int eol = endOfLine(trailer, idx); return trailer.substring(idx, eol).trim(); } @@ -216,8 +216,8 @@ private static int indexOfHeader(String trailer, String name) { } // Skip CRLF / LF. i = eol; - if (i < n && trailer.charAt(i) == '\r') i++; - if (i < n && trailer.charAt(i) == '\n') i++; + if (i < n && trailer.charAt(i) == '\r') { i++; } + if (i < n && trailer.charAt(i) == '\n') { i++; } } return -1; } @@ -276,7 +276,7 @@ private static final class GrpcConnection extends ConnectionRequest { @Override public boolean equals(Object o) { - if (!(o instanceof GrpcConnection)) return false; + if (!(o instanceof GrpcConnection)) { return false; } GrpcConnection that = (GrpcConnection) o; return super.equals(o) && failed == that.failed diff --git a/CodenameOne/src/com/codename1/io/grpc/ProtoReader.java b/CodenameOne/src/com/codename1/io/grpc/ProtoReader.java index 6c75135cab..a818586468 100644 --- a/CodenameOne/src/com/codename1/io/grpc/ProtoReader.java +++ b/CodenameOne/src/com/codename1/io/grpc/ProtoReader.java @@ -51,7 +51,7 @@ public int remaining() { /// returns 0 when the stream is at EOF. Generated codecs use /// the 0 sentinel as the loop exit condition. public int readTag() throws IOException { - if (isAtEnd()) return 0; + if (isAtEnd()) { return 0; } return readVarint32(); } @@ -188,12 +188,12 @@ public void readPacked(java.util.List target, PackedReader strategy) t } private int readByte() throws IOException { - if (pos >= limit) throw new EOFException("Unexpected end of protobuf stream"); + if (pos >= limit) { throw new EOFException("Unexpected end of protobuf stream"); } return buf[pos++] & 0xFF; } private void ensure(int n) throws IOException { - if (n < 0) throw new IOException("Negative length"); + if (n < 0) { throw new IOException("Negative length"); } if (pos + n > limit) { throw new EOFException("Truncated protobuf stream: need " + n + " more bytes but " + (limit - pos) + " remaining"); @@ -226,7 +226,7 @@ public static byte[] drain(ByteArrayInputStream in) throws IOException { int read = 0; while (read < n) { int r = in.read(out, read, n - read); - if (r < 0) throw new EOFException(); + if (r < 0) { throw new EOFException(); } read += r; } return out; diff --git a/CodenameOne/src/com/codename1/io/grpc/ProtoWriter.java b/CodenameOne/src/com/codename1/io/grpc/ProtoWriter.java index f8812497a0..f430433ae6 100644 --- a/CodenameOne/src/com/codename1/io/grpc/ProtoWriter.java +++ b/CodenameOne/src/com/codename1/io/grpc/ProtoWriter.java @@ -104,79 +104,79 @@ public static long zigZag64(long n) { // emit code is one line. public void writeInt32(int field, int value) throws IOException { - if (value == 0) return; + if (value == 0) { return; } writeTag(field, WIRE_VARINT); writeVarint32(value); } public void writeInt64(int field, long value) throws IOException { - if (value == 0L) return; + if (value == 0L) { return; } writeTag(field, WIRE_VARINT); writeVarint64(value); } public void writeUInt32(int field, int value) throws IOException { - if (value == 0) return; + if (value == 0) { return; } writeTag(field, WIRE_VARINT); // uint32 is encoded as unsigned varint -- mask to 32 bits. writeVarint64(value & 0xFFFFFFFFL); } public void writeUInt64(int field, long value) throws IOException { - if (value == 0L) return; + if (value == 0L) { return; } writeTag(field, WIRE_VARINT); writeVarint64(value); } public void writeSInt32(int field, int value) throws IOException { - if (value == 0) return; + if (value == 0) { return; } writeTag(field, WIRE_VARINT); writeVarint64(zigZag32(value) & 0xFFFFFFFFL); } public void writeSInt64(int field, long value) throws IOException { - if (value == 0L) return; + if (value == 0L) { return; } writeTag(field, WIRE_VARINT); writeVarint64(zigZag64(value)); } public void writeFixed32Field(int field, int value) throws IOException { - if (value == 0) return; + if (value == 0) { return; } writeTag(field, WIRE_I32); writeFixed32(value); } public void writeFixed64Field(int field, long value) throws IOException { - if (value == 0L) return; + if (value == 0L) { return; } writeTag(field, WIRE_I64); writeFixed64(value); } public void writeBool(int field, boolean value) throws IOException { - if (!value) return; + if (!value) { return; } writeTag(field, WIRE_VARINT); out.write(1); } public void writeFloat(int field, float value) throws IOException { - if (value == 0.0f) return; + if (value == 0.0f) { return; } writeTag(field, WIRE_I32); writeFixed32(Float.floatToIntBits(value)); } public void writeDouble(int field, double value) throws IOException { - if (value == 0.0d) return; + if (value == 0.0d) { return; } writeTag(field, WIRE_I64); writeFixed64(Double.doubleToLongBits(value)); } public void writeString(int field, String value) throws IOException { - if (value == null || value.length() == 0) return; + if (value == null || value.length() == 0) { return; } writeBytes(field, utf8(value)); } public void writeBytes(int field, byte[] value) throws IOException { - if (value == null || value.length == 0) return; + if (value == null || value.length == 0) { return; } writeTag(field, WIRE_LEN); writeVarint32(value.length); out.write(value); @@ -186,7 +186,7 @@ public void writeBytes(int field, byte[] value) throws IOException { /// field. Generated codecs call into [ProtoCodecs#lookup(Class)] /// to find the nested codec. public void writeMessage(int field, T value, ProtoCodec codec) throws IOException { - if (value == null) return; + if (value == null) { return; } ByteArrayOutputStream buf = new ByteArrayOutputStream(); ProtoWriter sub = new ProtoWriter(buf); codec.write(sub, value); @@ -201,7 +201,7 @@ public void writeMessage(int field, T value, ProtoCodec codec) throws IOE /// repeated entries). public void writeMessageList(int field, java.util.List values, ProtoCodec codec) throws IOException { - if (values == null || values.isEmpty()) return; + if (values == null || values.isEmpty()) { return; } for (int i = 0, n = values.size(); i < n; i++) { writeMessage(field, values.get(i), codec); } @@ -210,10 +210,10 @@ public void writeMessageList(int field, java.util.List values, /// Writes a `repeated` field of strings (one tag + length prefix /// per element). public void writeStringList(int field, java.util.List values) throws IOException { - if (values == null || values.isEmpty()) return; + if (values == null || values.isEmpty()) { return; } for (int i = 0, n = values.size(); i < n; i++) { String v = values.get(i); - if (v == null) continue; + if (v == null) { continue; } byte[] body = utf8(v); writeTag(field, WIRE_LEN); writeVarint32(body.length); @@ -224,7 +224,7 @@ public void writeStringList(int field, java.util.List values) throws IOE /// Writes a packed `repeated int32` field (proto3 default packing /// for scalar lists). public void writePackedInt32(int field, java.util.List values) throws IOException { - if (values == null || values.isEmpty()) return; + if (values == null || values.isEmpty()) { return; } ByteArrayOutputStream buf = new ByteArrayOutputStream(); ProtoWriter sub = new ProtoWriter(buf); for (int i = 0, n = values.size(); i < n; i++) { @@ -239,7 +239,7 @@ public void writePackedInt32(int field, java.util.List values) throws I /// Writes a packed `repeated int64` field. public void writePackedInt64(int field, java.util.List values) throws IOException { - if (values == null || values.isEmpty()) return; + if (values == null || values.isEmpty()) { return; } ByteArrayOutputStream buf = new ByteArrayOutputStream(); ProtoWriter sub = new ProtoWriter(buf); for (int i = 0, n = values.size(); i < n; i++) { From 9516751bc0462c6c97b06586b0b15e6a885fd2d9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 29 May 2026 22:51:56 +0300 Subject: [PATCH 5/5] Fix Checkstyle LeftCurlyCheck: expand single-line braced ifs Checkstyle requires `{` to be followed by a line break, which the previous round of PMD ControlStatementBraces fixes violated by braceing the same-line form (`if (cond) { stmt; }`). Expand each flagged site to the canonical multi-line shape: if (cond) { stmt; } 28 sites across ProtoWriter / ProtoReader / GrpcWeb; no behaviour change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/com/codename1/io/grpc/GrpcWeb.java | 20 +++-- .../com/codename1/io/grpc/ProtoReader.java | 16 +++- .../com/codename1/io/grpc/ProtoWriter.java | 76 ++++++++++++++----- 3 files changed, 84 insertions(+), 28 deletions(-) diff --git a/CodenameOne/src/com/codename1/io/grpc/GrpcWeb.java b/CodenameOne/src/com/codename1/io/grpc/GrpcWeb.java index ff6c60e57d..5bc77fd824 100644 --- a/CodenameOne/src/com/codename1/io/grpc/GrpcWeb.java +++ b/CodenameOne/src/com/codename1/io/grpc/GrpcWeb.java @@ -177,7 +177,9 @@ public static GrpcResponse decode(byte[] response, int httpCode, private static int[] parseTrailerStatus(String trailer) { // Lines are CRLF-separated; the spec allows LF too. int idx = indexOfHeader(trailer, "grpc-status"); - if (idx < 0) { return new int[]{GrpcResponse.STATUS_UNKNOWN}; } + if (idx < 0) { + return new int[]{GrpcResponse.STATUS_UNKNOWN}; + } int eol = endOfLine(trailer, idx); String value = trailer.substring(idx, eol).trim(); try { @@ -189,7 +191,9 @@ private static int[] parseTrailerStatus(String trailer) { private static String trailerMessage(String trailer) { int idx = indexOfHeader(trailer, "grpc-message"); - if (idx < 0) { return null; } + if (idx < 0) { + return null; + } int eol = endOfLine(trailer, idx); return trailer.substring(idx, eol).trim(); } @@ -216,8 +220,12 @@ private static int indexOfHeader(String trailer, String name) { } // Skip CRLF / LF. i = eol; - if (i < n && trailer.charAt(i) == '\r') { i++; } - if (i < n && trailer.charAt(i) == '\n') { i++; } + if (i < n && trailer.charAt(i) == '\r') { + i++; + } + if (i < n && trailer.charAt(i) == '\n') { + i++; + } } return -1; } @@ -276,7 +284,9 @@ private static final class GrpcConnection extends ConnectionRequest { @Override public boolean equals(Object o) { - if (!(o instanceof GrpcConnection)) { return false; } + if (!(o instanceof GrpcConnection)) { + return false; + } GrpcConnection that = (GrpcConnection) o; return super.equals(o) && failed == that.failed diff --git a/CodenameOne/src/com/codename1/io/grpc/ProtoReader.java b/CodenameOne/src/com/codename1/io/grpc/ProtoReader.java index a818586468..089e25d817 100644 --- a/CodenameOne/src/com/codename1/io/grpc/ProtoReader.java +++ b/CodenameOne/src/com/codename1/io/grpc/ProtoReader.java @@ -51,7 +51,9 @@ public int remaining() { /// returns 0 when the stream is at EOF. Generated codecs use /// the 0 sentinel as the loop exit condition. public int readTag() throws IOException { - if (isAtEnd()) { return 0; } + if (isAtEnd()) { + return 0; + } return readVarint32(); } @@ -188,12 +190,16 @@ public void readPacked(java.util.List target, PackedReader strategy) t } private int readByte() throws IOException { - if (pos >= limit) { throw new EOFException("Unexpected end of protobuf stream"); } + if (pos >= limit) { + throw new EOFException("Unexpected end of protobuf stream"); + } return buf[pos++] & 0xFF; } private void ensure(int n) throws IOException { - if (n < 0) { throw new IOException("Negative length"); } + if (n < 0) { + throw new IOException("Negative length"); + } if (pos + n > limit) { throw new EOFException("Truncated protobuf stream: need " + n + " more bytes but " + (limit - pos) + " remaining"); @@ -226,7 +232,9 @@ public static byte[] drain(ByteArrayInputStream in) throws IOException { int read = 0; while (read < n) { int r = in.read(out, read, n - read); - if (r < 0) { throw new EOFException(); } + if (r < 0) { + throw new EOFException(); + } read += r; } return out; diff --git a/CodenameOne/src/com/codename1/io/grpc/ProtoWriter.java b/CodenameOne/src/com/codename1/io/grpc/ProtoWriter.java index f430433ae6..496106c919 100644 --- a/CodenameOne/src/com/codename1/io/grpc/ProtoWriter.java +++ b/CodenameOne/src/com/codename1/io/grpc/ProtoWriter.java @@ -104,79 +104,105 @@ public static long zigZag64(long n) { // emit code is one line. public void writeInt32(int field, int value) throws IOException { - if (value == 0) { return; } + if (value == 0) { + return; + } writeTag(field, WIRE_VARINT); writeVarint32(value); } public void writeInt64(int field, long value) throws IOException { - if (value == 0L) { return; } + if (value == 0L) { + return; + } writeTag(field, WIRE_VARINT); writeVarint64(value); } public void writeUInt32(int field, int value) throws IOException { - if (value == 0) { return; } + if (value == 0) { + return; + } writeTag(field, WIRE_VARINT); // uint32 is encoded as unsigned varint -- mask to 32 bits. writeVarint64(value & 0xFFFFFFFFL); } public void writeUInt64(int field, long value) throws IOException { - if (value == 0L) { return; } + if (value == 0L) { + return; + } writeTag(field, WIRE_VARINT); writeVarint64(value); } public void writeSInt32(int field, int value) throws IOException { - if (value == 0) { return; } + if (value == 0) { + return; + } writeTag(field, WIRE_VARINT); writeVarint64(zigZag32(value) & 0xFFFFFFFFL); } public void writeSInt64(int field, long value) throws IOException { - if (value == 0L) { return; } + if (value == 0L) { + return; + } writeTag(field, WIRE_VARINT); writeVarint64(zigZag64(value)); } public void writeFixed32Field(int field, int value) throws IOException { - if (value == 0) { return; } + if (value == 0) { + return; + } writeTag(field, WIRE_I32); writeFixed32(value); } public void writeFixed64Field(int field, long value) throws IOException { - if (value == 0L) { return; } + if (value == 0L) { + return; + } writeTag(field, WIRE_I64); writeFixed64(value); } public void writeBool(int field, boolean value) throws IOException { - if (!value) { return; } + if (!value) { + return; + } writeTag(field, WIRE_VARINT); out.write(1); } public void writeFloat(int field, float value) throws IOException { - if (value == 0.0f) { return; } + if (value == 0.0f) { + return; + } writeTag(field, WIRE_I32); writeFixed32(Float.floatToIntBits(value)); } public void writeDouble(int field, double value) throws IOException { - if (value == 0.0d) { return; } + if (value == 0.0d) { + return; + } writeTag(field, WIRE_I64); writeFixed64(Double.doubleToLongBits(value)); } public void writeString(int field, String value) throws IOException { - if (value == null || value.length() == 0) { return; } + if (value == null || value.length() == 0) { + return; + } writeBytes(field, utf8(value)); } public void writeBytes(int field, byte[] value) throws IOException { - if (value == null || value.length == 0) { return; } + if (value == null || value.length == 0) { + return; + } writeTag(field, WIRE_LEN); writeVarint32(value.length); out.write(value); @@ -186,7 +212,9 @@ public void writeBytes(int field, byte[] value) throws IOException { /// field. Generated codecs call into [ProtoCodecs#lookup(Class)] /// to find the nested codec. public void writeMessage(int field, T value, ProtoCodec codec) throws IOException { - if (value == null) { return; } + if (value == null) { + return; + } ByteArrayOutputStream buf = new ByteArrayOutputStream(); ProtoWriter sub = new ProtoWriter(buf); codec.write(sub, value); @@ -201,7 +229,9 @@ public void writeMessage(int field, T value, ProtoCodec codec) throws IOE /// repeated entries). public void writeMessageList(int field, java.util.List values, ProtoCodec codec) throws IOException { - if (values == null || values.isEmpty()) { return; } + if (values == null || values.isEmpty()) { + return; + } for (int i = 0, n = values.size(); i < n; i++) { writeMessage(field, values.get(i), codec); } @@ -210,10 +240,14 @@ public void writeMessageList(int field, java.util.List values, /// Writes a `repeated` field of strings (one tag + length prefix /// per element). public void writeStringList(int field, java.util.List values) throws IOException { - if (values == null || values.isEmpty()) { return; } + if (values == null || values.isEmpty()) { + return; + } for (int i = 0, n = values.size(); i < n; i++) { String v = values.get(i); - if (v == null) { continue; } + if (v == null) { + continue; + } byte[] body = utf8(v); writeTag(field, WIRE_LEN); writeVarint32(body.length); @@ -224,7 +258,9 @@ public void writeStringList(int field, java.util.List values) throws IOE /// Writes a packed `repeated int32` field (proto3 default packing /// for scalar lists). public void writePackedInt32(int field, java.util.List values) throws IOException { - if (values == null || values.isEmpty()) { return; } + if (values == null || values.isEmpty()) { + return; + } ByteArrayOutputStream buf = new ByteArrayOutputStream(); ProtoWriter sub = new ProtoWriter(buf); for (int i = 0, n = values.size(); i < n; i++) { @@ -239,7 +275,9 @@ public void writePackedInt32(int field, java.util.List values) throws I /// Writes a packed `repeated int64` field. public void writePackedInt64(int field, java.util.List values) throws IOException { - if (values == null || values.isEmpty()) { return; } + if (values == null || values.isEmpty()) { + return; + } ByteArrayOutputStream buf = new ByteArrayOutputStream(); ProtoWriter sub = new ProtoWriter(buf); for (int i = 0, n = values.size(); i < n; i++) {