From bbfa1d89830c2e8b0b0a52bb064a54636bf899f4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:58:47 +0200 Subject: [PATCH 1/5] JSON mapping: codecs, creators, generics, arrays and null semantics (#5995) Extends both the backend's build-time JSON codecs and the client's @Mapped mappers with one shared set of annotations: - @JsonAdapter / JsonTypeAdapter: application codecs, called directly, no reflection; validated at build time. - @JsonCreator: immutable DTOs built through a constructor or factory. - Typed generic DTOs (Page gets its own codec) and one-dimensional arrays; shapes that stay unsupported fail the build by name. - @JsonStrict (unknown members refused), @JsonProperty aliases/required, @JsonOmitNull, @JsonFormat(STRING/NUMBER) for dates and numbers. - Path diagnostics ($.items[2].id: required member is missing) and a configurable depth limit (cn1.json.maxDepth). Default behaviour is unchanged: a class that uses none of the new options reads and writes as before, including an explicit null on the client. Docs gain a "Shaping the JSON contract" section and a Jackson migration table. Co-Authored-By: Claude Opus 5.5 --- .../codename1/annotations/JsonAdapter.java | 68 + .../codename1/annotations/JsonCreator.java | 63 + .../com/codename1/annotations/JsonFormat.java | 58 + .../codename1/annotations/JsonOmitNull.java | 39 + .../codename1/annotations/JsonProperty.java | 28 +- .../com/codename1/annotations/JsonStrict.java | 42 + .../annotations/JsonTypeAdapter.java | 61 + .../src/com/codename1/mapping/JsonValues.java | 345 +++++ .../src/com/codename1/mapping/Mappers.java | 6 + .../Annotation-JSON-XML-Mapping.asciidoc | 29 + docs/developer-guide/Backend-Web.asciidoc | 163 +- .../maven/processors/BackendJsonCodecs.java | 1164 +++++++++++++-- .../MappingAnnotationProcessor.java | 1330 ++++++++++++++++- .../maven/processors/PropertyTypeKind.java | 26 +- .../maven/processors/BackendBeansTest.java | 175 ++- .../MappingAnnotationProcessorTest.java | 393 +++++ ...RestControllerAnnotationProcessorTest.java | 9 +- .../src/com/codename1/backend/Backend.java | 4 + .../com/codename1/impl/backend/JsonCodec.java | 140 +- 19 files changed, 3886 insertions(+), 257 deletions(-) create mode 100644 CodenameOne/src/com/codename1/annotations/JsonAdapter.java create mode 100644 CodenameOne/src/com/codename1/annotations/JsonCreator.java create mode 100644 CodenameOne/src/com/codename1/annotations/JsonFormat.java create mode 100644 CodenameOne/src/com/codename1/annotations/JsonOmitNull.java create mode 100644 CodenameOne/src/com/codename1/annotations/JsonStrict.java create mode 100644 CodenameOne/src/com/codename1/annotations/JsonTypeAdapter.java create mode 100644 CodenameOne/src/com/codename1/mapping/JsonValues.java diff --git a/CodenameOne/src/com/codename1/annotations/JsonAdapter.java b/CodenameOne/src/com/codename1/annotations/JsonAdapter.java new file mode 100644 index 00000000000..c2e907b5687 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/JsonAdapter.java @@ -0,0 +1,68 @@ +/* + * 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.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Hands a type's JSON form to the application's own [JsonTypeAdapter]. +/// +/// On a field it applies to that field; on a class, to every field, element +/// and body of that class, which is how a type the generated codecs cannot +/// map -- a money value, an identifier wrapper -- gets one form everywhere. +/// A field's adapter wins over its class's. +/// +/// ```java +/// public final class CentsAdapter implements JsonTypeAdapter { +/// public Object toJson(Money m) { return m == null ? null : Long.valueOf(m.cents()); } +/// public Money fromJson(Object json) { +/// if (json == null) { +/// return null; +/// } +/// // instanceof, never a bare cast: a failed cast does not throw on iOS. +/// if (!(json instanceof Number)) { +/// throw new IllegalArgumentException("expected cents"); +/// } +/// return new Money(((Number) json).longValue()); +/// } +/// } +/// +/// @Mapped +/// public class Invoice { +/// @JsonAdapter(CentsAdapter.class) +/// public Money total; +/// } +/// ``` +/// +/// The adapter needs a public constructor without arguments. The generated code +/// creates one instance per adapter class and calls it directly, so nothing is +/// found by name at run time and the translated binary keeps the class. +@com.codename1.impl.SharedWithBackend +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.TYPE, ElementType.FIELD}) +public @interface JsonAdapter { + /// The adapter class. + Class value(); +} diff --git a/CodenameOne/src/com/codename1/annotations/JsonCreator.java b/CodenameOne/src/com/codename1/annotations/JsonCreator.java new file mode 100644 index 00000000000..054d662f336 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/JsonCreator.java @@ -0,0 +1,63 @@ +/* + * 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.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// The constructor, or static factory method, that builds an immutable class +/// from JSON. +/// +/// A class with final fields and no constructor without arguments cannot be +/// filled in field by field, so its JSON is read into one local per member and +/// handed to this constructor at once -- as a record's canonical constructor +/// is. Every parameter carries [JsonProperty] naming the member it is read +/// from; a compiled class does not keep parameter names, so there is nothing +/// else to match them by. A member a body leaves out arrives as its type's +/// default (`null`, `0`, `false`) unless it is `required`. +/// +/// ```java +/// @Mapped +/// public final class Point { +/// private final int x; +/// private final int y; +/// +/// @JsonCreator +/// public Point(@JsonProperty("x") int x, @JsonProperty("y") int y) { +/// this.x = x; +/// this.y = y; +/// } +/// +/// public int getX() { return x; } +/// public int getY() { return y; } +/// } +/// ``` +/// +/// The generated code calls it directly; nothing is looked up by name. +@com.codename1.impl.SharedWithBackend +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.CONSTRUCTOR, ElementType.METHOD}) +public @interface JsonCreator { +} diff --git a/CodenameOne/src/com/codename1/annotations/JsonFormat.java b/CodenameOne/src/com/codename1/annotations/JsonFormat.java new file mode 100644 index 00000000000..d073d51d83c --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/JsonFormat.java @@ -0,0 +1,58 @@ +/* + * 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.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Picks the JSON shape a date or number field is written in. +/// +/// - A `java.util.Date` is milliseconds since the epoch by default. With +/// [Shape#STRING] it is an ISO-8601 instant in UTC, +/// `2026-10-25T08:30:00.000Z`. +/// - A number is a JSON number by default. With [Shape#STRING] it is the same +/// digits in quotes -- what a JavaScript client needs for a `long` past 2^53, +/// which its numbers cannot hold exactly. +/// +/// Reading accepts either shape whatever the annotation says, so a field can +/// change its format without breaking the clients that still send the old one. +/// Any other combination is refused by the build. +@com.codename1.impl.SharedWithBackend +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface JsonFormat { + /// The shape the value is written in. + Shape value(); + + /// A JSON shape for a scalar value. + enum Shape { + /// The type's own form: a number, or a date as epoch milliseconds. + DEFAULT, + /// A string: a number's digits, or a date in ISO-8601. + STRING, + /// A number: a date as epoch milliseconds. + NUMBER + } +} diff --git a/CodenameOne/src/com/codename1/annotations/JsonOmitNull.java b/CodenameOne/src/com/codename1/annotations/JsonOmitNull.java new file mode 100644 index 00000000000..0f2f75674d0 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/JsonOmitNull.java @@ -0,0 +1,39 @@ +/* + * 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.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Leaves a member that is `null` out of the JSON instead of writing `null`. +/// +/// On a field it applies to that field; on a class, to every field of it. +/// Reading is unaffected: an absent member leaves the field as the constructor +/// left it, and an explicit `null` sets it to `null`. +@com.codename1.impl.SharedWithBackend +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.TYPE, ElementType.FIELD}) +public @interface JsonOmitNull { +} diff --git a/CodenameOne/src/com/codename1/annotations/JsonProperty.java b/CodenameOne/src/com/codename1/annotations/JsonProperty.java index d36bde0dd0e..21c16412262 100644 --- a/CodenameOne/src/com/codename1/annotations/JsonProperty.java +++ b/CodenameOne/src/com/codename1/annotations/JsonProperty.java @@ -27,16 +27,32 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -/// Renames a `@Mapped` field in the JSON projection. The default JSON key is -/// the field name; `@JsonProperty` lets a field map to `snake_case` or any -/// alternative spelling without touching the Java identifier. +/// Names a field in the JSON form, and says how a body may spell it. +/// +/// The default JSON key is the field name; `value` lets a field map to +/// `snake_case` or any other spelling without touching the Java identifier. +/// `aliases` are further names a body may use for the same member -- an old +/// spelling a client still sends -- and are only ever read: the value is +/// always written under `value`. `required` makes a body that leaves the +/// member out a refused body, naming the member; an explicit `null` is still +/// a value, so it is accepted (and refused for a primitive, which cannot hold it). +/// +/// On a parameter of a [JsonCreator] constructor or factory it names the member +/// that argument is read from, since a compiled class does not keep its +/// parameter names. /// /// The server's generated codecs read it too, so a class shared between an app /// and its backend has one JSON form on both sides. @com.codename1.impl.SharedWithBackend @Retention(RetentionPolicy.CLASS) -@Target(ElementType.FIELD) +@Target({ElementType.FIELD, ElementType.PARAMETER}) public @interface JsonProperty { - /// The JSON key. - String value(); + /// The JSON key; empty for the field's own name. + String value() default ""; + + /// Other keys a body may carry the value under. Read only, never written. + String[] aliases() default {}; + + /// Whether a body that leaves the member out is refused. + boolean required() default false; } diff --git a/CodenameOne/src/com/codename1/annotations/JsonStrict.java b/CodenameOne/src/com/codename1/annotations/JsonStrict.java new file mode 100644 index 00000000000..37e103f5ed2 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/JsonStrict.java @@ -0,0 +1,42 @@ +/* + * 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.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Refuses a body that carries a member this class does not declare. +/// +/// By default an unknown member is ignored, as a Spring Boot application +/// ignores one, so a newer client can talk to an older server. A contract that +/// would rather hear about a misspelt member than lose it silently marks the +/// class: the read then fails with the member's path, for example +/// `$.items[2].colour: unknown member`. Names a field lists in +/// [JsonProperty#aliases] are known; names of [JsonIgnore] fields are not. +@com.codename1.impl.SharedWithBackend +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface JsonStrict { +} diff --git a/CodenameOne/src/com/codename1/annotations/JsonTypeAdapter.java b/CodenameOne/src/com/codename1/annotations/JsonTypeAdapter.java new file mode 100644 index 00000000000..0a0b833290a --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/JsonTypeAdapter.java @@ -0,0 +1,61 @@ +/* + * 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.annotations; + +/// Converts a value to and from its JSON form, for a field or class marked +/// [JsonAdapter]. +/// +/// Both directions use the parsed form of JSON: `null`, a `String`, a +/// `Boolean`, a `Number`, a `java.util.Map` with `String` keys for an object +/// and a `java.util.List` for an array, nested as deeply as needed. A parser may +/// hand a number over as any `Number` subclass, so read one through +/// `Number#longValue()` or `Number#doubleValue()` rather than casting it to a +/// particular class. +/// +/// Throw `IllegalArgumentException` from [#fromJson(Object)] to refuse a value; +/// the generated code adds where in the document it was. `T` is the type +/// converted. +@com.codename1.impl.SharedWithBackend +public interface JsonTypeAdapter { + /// The JSON form of `value`, which may be `null`. + /// + /// #### Parameters + /// + /// - `value`: the value to write + /// + /// #### Returns + /// + /// its parsed JSON form + Object toJson(T value); + + /// The value `json` is the form of; `json` may be `null`. + /// + /// #### Parameters + /// + /// - `json`: the parsed JSON value + /// + /// #### Returns + /// + /// the value it stands for + T fromJson(Object json); +} diff --git a/CodenameOne/src/com/codename1/mapping/JsonValues.java b/CodenameOne/src/com/codename1/mapping/JsonValues.java new file mode 100644 index 00000000000..f43149fd6a0 --- /dev/null +++ b/CodenameOne/src/com/codename1/mapping/JsonValues.java @@ -0,0 +1,345 @@ +/* + * 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.mapping; + +import java.util.Date; + +/// Conversions the generated `@Mapped` mappers share: the lenient readers for +/// `@JsonFormat` fields, ISO-8601 dates computed by arithmetic, and the path +/// prefixing of a nested failure. Called only from generated code. +/// +/// Nothing here consults a locale or a time zone: neither is guaranteed on a +/// device, and a wire format must not depend on the phone's settings. Dates are +/// always written in UTC. +public final class JsonValues { + private JsonValues() { + } + + /// A JSON number, or a string holding one, as a `long`. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: when the value is neither + public static long toLong(Object v) { + if (v instanceof Number) { + return ((Number) v).longValue(); + } + if (v instanceof String) { + String s = ((String) v).trim(); + try { + return Long.parseLong(s); + } catch (NumberFormatException e) { + return (long) parseDouble(s); + } + } + throw new IllegalArgumentException("expected a number"); + } + + /// A JSON whole number in `[min, max]`, for an array element of an + /// integral type: a fraction or a value out of range is refused rather + /// than truncated, as the server's codec refuses it. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: when the value is not such a number + public static long toIntegral(Object v, long min, long max) { + long l; + if (v instanceof Double || v instanceof Float) { + double d = ((Number) v).doubleValue(); + // max + 1.0, not max: (double) Long.MAX_VALUE rounds up to 2^63, + // which would pass a plain d > max and then clamp in the cast. + if (d != Math.floor(d) || d < min || d >= max + 1.0) { + throw new IllegalArgumentException("expected a whole number in range, found " + v); + } + l = (long) d; + } else if (v instanceof String) { + String s = ((String) v).trim(); + try { + l = Long.parseLong(s); + } catch (NumberFormatException e) { + return toIntegral(Double.valueOf(parseDouble(s)), min, max); + } + } else if (v instanceof Number) { + l = ((Number) v).longValue(); + } else { + throw new IllegalArgumentException("expected a number"); + } + if (l < min || l > max) { + throw new IllegalArgumentException("expected a whole number in range, found " + v); + } + return l; + } + + /// `json` read by `mapper`, which must be a JSON object. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: when `json` is not an object + public static Object object(com.codename1.mapping.Mapper mapper, Object json) { + if (!(json instanceof java.util.Map) || mapper == null) { + throw new IllegalArgumentException("expected a JSON object"); + } + return mapper.fromMap((java.util.Map) json); + } + + /// A JSON number, or a string holding one, as a `double`. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: when the value is neither + public static double toDouble(Object v) { + if (v instanceof Number) { + return ((Number) v).doubleValue(); + } + if (v instanceof String) { + return parseDouble(((String) v).trim()); + } + throw new IllegalArgumentException("expected a number"); + } + + private static double parseDouble(String s) { + try { + return Double.parseDouble(s); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("expected a number, found \"" + s + "\""); + } + } + + /// A JSON date in either shape: epoch milliseconds (a number or a numeric + /// string) or an ISO-8601 string -- a date alone, or a date and time with + /// optional fraction and `Z` or numeric offset. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: when the value is neither + public static Date toDate(Object v) { + if (v instanceof Number) { + return new Date(((Number) v).longValue()); + } + if (v instanceof String) { + String s = ((String) v).trim(); + if (s.length() > 0 && s.indexOf('-', 1) < 0) { + return new Date(toLong(s)); + } + return new Date(parseIso(s)); + } + throw new IllegalArgumentException("expected a date"); + } + + /// Formats `d` as `yyyy-MM-ddTHH:mm:ss.SSSZ` in UTC. + public static String formatIso(Date d) { + long millis = d.getTime(); + long days = floorDiv(millis, 86400000L); + long ms = millis - days * 86400000L; + // Civil-from-days (Howard Hinnant's algorithm), proleptic Gregorian. + long z = days + 719468; + long era = (z >= 0 ? z : z - 146096) / 146097; + long doe = z - era * 146097; + long yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + long y = yoe + era * 400; + long doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + long mp = (5 * doy + 2) / 153; + long day = doy - (153 * mp + 2) / 5 + 1; + long month = mp < 10 ? mp + 3 : mp - 9; + if (month <= 2) { + y++; + } + StringBuilder b = new StringBuilder(24); + pad(b, y, 4); + b.append('-'); + pad(b, month, 2); + b.append('-'); + pad(b, day, 2); + b.append('T'); + pad(b, ms / 3600000L, 2); + b.append(':'); + pad(b, (ms / 60000L) % 60, 2); + b.append(':'); + pad(b, (ms / 1000L) % 60, 2); + b.append('.'); + pad(b, ms % 1000L, 3); + b.append('Z'); + return b.toString(); + } + + private static void pad(StringBuilder b, long v, int width) { + if (v < 0) { + b.append('-'); + v = -v; + } + String s = String.valueOf(v); + for (int i = s.length(); i < width; i++) { + b.append('0'); + } + b.append(s); + } + + private static long floorDiv(long a, long b) { + long q = a / b; + if ((a % b != 0) && ((a < 0) != (b < 0))) { + q--; + } + return q; + } + + /// Days in `month` (1-12) of the proleptic Gregorian `year`. + private static int daysIn(long year, int month) { + if (month == 2) { + boolean leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + return leap ? 29 : 28; + } + return month == 4 || month == 6 || month == 9 || month == 11 ? 30 : 31; + } + + /// Parses an ISO-8601 date or date-time to epoch milliseconds. A value with + /// no offset is read as UTC. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: when `s` is not ISO-8601 + public static long parseIso(String s) { + try { + int len = s.length(); + int p = 0; + boolean negYear = false; + if (len > 0 && (s.charAt(0) == '-' || s.charAt(0) == '+')) { + negYear = s.charAt(0) == '-'; + p = 1; + } + int dash = s.indexOf('-', p); + long year = Long.parseLong(s.substring(p, dash)); + if (negYear) { + year = -year; + } + int month = Integer.parseInt(s.substring(dash + 1, dash + 3)); + int day = Integer.parseInt(s.substring(dash + 4, dash + 6)); + if (s.charAt(dash + 3) != '-' || month < 1 || month > 12 || day < 1 + || day > daysIn(year, month)) { + throw new IllegalArgumentException(); + } + p = dash + 6; + long ms = 0; + long offsetMs = 0; + if (p < len) { + char t = s.charAt(p); + if (t != 'T' && t != 't' && t != ' ') { + throw new IllegalArgumentException(); + } + p++; + int hh = Integer.parseInt(s.substring(p, p + 2)); + if (s.charAt(p + 2) != ':') { + throw new IllegalArgumentException(); + } + int mm = Integer.parseInt(s.substring(p + 3, p + 5)); + p += 5; + int ss = 0; + int frac = 0; + if (p < len && s.charAt(p) == ':') { + ss = Integer.parseInt(s.substring(p + 1, p + 3)); + p += 3; + if (p < len && (s.charAt(p) == '.' || s.charAt(p) == ',')) { + p++; + int digits = 0; + while (p < len && Character.isDigit(s.charAt(p))) { + if (digits < 3) { + frac = frac * 10 + (s.charAt(p) - '0'); + } + digits++; + p++; + } + if (digits == 0) { + throw new IllegalArgumentException(); + } + for (int i = digits; i < 3; i++) { + frac *= 10; + } + } + } + // The same bounds the server's reader holds: no 24:00, no leap second. + if (hh > 23 || mm > 59 || ss > 59) { + throw new IllegalArgumentException(); + } + ms = hh * 3600000L + mm * 60000L + ss * 1000L + frac; + if (p < len) { + char z = s.charAt(p); + if ((z == 'Z' || z == 'z') && p + 1 == len) { + p++; + } else if (z == '+' || z == '-') { + String off = s.substring(p + 1); + int oh; + int om = 0; + if (off.length() == 2) { + oh = Integer.parseInt(off); + } else if (off.length() == 4) { + oh = Integer.parseInt(off.substring(0, 2)); + om = Integer.parseInt(off.substring(2)); + } else if (off.length() == 5 && off.charAt(2) == ':') { + oh = Integer.parseInt(off.substring(0, 2)); + om = Integer.parseInt(off.substring(3)); + } else { + throw new IllegalArgumentException(); + } + if (oh < 0 || oh > 23 || om < 0 || om > 59) { + throw new IllegalArgumentException(); + } + offsetMs = (oh * 60L + om) * 60000L; + if (z == '-') { + offsetMs = -offsetMs; + } + p = len; + } + } + if (p != len) { + throw new IllegalArgumentException(); + } + } + return daysFromCivil(year, month, day) * 86400000L + ms - offsetMs; + } catch (RuntimeException e) { + // NumberFormatException and StringIndexOutOfBoundsException are + // thrown explicitly by the parsers, never by a cast. + throw new IllegalArgumentException("expected an ISO-8601 date, found \"" + s + "\""); + } + } + + private static long daysFromCivil(long y, int m, int d) { + if (m <= 2) { + y--; + } + long era = (y >= 0 ? y : y - 399) / 400; + long yoe = y - era * 400; + long doy = (153L * (m > 2 ? m - 3 : m + 9) + 2) / 5 + d - 1; + long doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + return era * 146097 + doe - 719468; + } + + /// Prefixes a nested failure with the location it happened at. A message + /// that already starts with a `$` path is joined to it, so a failure three + /// levels down reads `$.order.items[2].due: required member is missing`. + public static IllegalArgumentException at(String path, IllegalArgumentException e) { + String msg = e.getMessage(); + if (msg != null && msg.startsWith("$")) { + return new IllegalArgumentException(path + msg.substring(1)); + } + return new IllegalArgumentException(path + ": " + (msg == null ? "invalid value" : msg)); + } +} diff --git a/CodenameOne/src/com/codename1/mapping/Mappers.java b/CodenameOne/src/com/codename1/mapping/Mappers.java index 293b4eb37f7..0af05384128 100644 --- a/CodenameOne/src/com/codename1/mapping/Mappers.java +++ b/CodenameOne/src/com/codename1/mapping/Mappers.java @@ -152,6 +152,9 @@ public static T fromJson(String json, Class type) { } try { JSONParser p = new JSONParser(); + // An explicit null must reach the mapper: a `required` member sent as + // null is present, and only an absent one is refused. + p.setIncludeNullsInstance(true); Map root = p.parseJSON(new StringReader(json)); return m.fromMap(root); } catch (IOException ioe) { @@ -171,6 +174,9 @@ public static T fromJson(Reader json, Class type) { } try { JSONParser p = new JSONParser(); + // An explicit null must reach the mapper: a `required` member sent as + // null is present, and only an absent one is refused. + p.setIncludeNullsInstance(true); Map root = p.parseJSON(json); return m.fromMap(root); } catch (IOException ioe) { diff --git a/docs/developer-guide/Annotation-JSON-XML-Mapping.asciidoc b/docs/developer-guide/Annotation-JSON-XML-Mapping.asciidoc index 46f5e04af83..4dd2f43c551 100644 --- a/docs/developer-guide/Annotation-JSON-XML-Mapping.asciidoc +++ b/docs/developer-guide/Annotation-JSON-XML-Mapping.asciidoc @@ -44,7 +44,21 @@ include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/g | Annotation | Purpose | `@JsonProperty("k")` | Rename the JSON key. Default: the field name. + `aliases` lists other keys it's read from, and + `required = true` refuses JSON that leaves it out. | `@JsonIgnore` | Omit the field from JSON. +| `@JsonOmitNull` | Leave the field out of JSON while it's `null`. + On the class, for every field. +| `@JsonFormat(STRING)` | Write a number in quotes, or a `Date` as an + ISO-8601 instant. Either form is read back. +| `@JsonAdapter(A.class)`| Convert the value with your own + `JsonTypeAdapter`. On a class, wherever the class + appears. +| `@JsonStrict` | On the class: refuse JSON with a member it + doesn't declare. +| `@JsonCreator` | On a constructor or static factory: build an + immutable class from JSON through it, each + parameter named with `@JsonProperty`. | `@XmlElement("name")` | Rename the XML element. Default: the field name. | `@XmlAttribute("name")`| Lift the field into an XML attribute rather than a nested child element. Only valid for @@ -63,10 +77,25 @@ include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/g | `Property` | `T` must be a scalar or another `@Mapped` type. | `ListProperty` | Same rules as `Property`. | `java.util.List` | Read from the field's generic signature. +| Arrays | One dimension, of any type above; a JSON array. + XML leaves them out. +| Generic classes | A field `Page` of a `@Mapped` class + `Page` maps its `T` members as `User`. | Nested `@Mapped` ref. | Recursive serialization through the registered nested mapper. |=== +The server's generated JSON codecs read the same annotations, so a class shared +by the app and its backend has one JSON contract on both sides. JSON that breaks +it, such as a missing required member or a value of the wrong type, is refused +with an `IllegalArgumentException` that names where it was: +`$.items[2].id: required member is missing`. A member that's absent leaves the +field as the constructor set it. An explicit `null` sets the field to `null` in +a `@JsonStrict` class, on a `required` member and through an adapter, which is +handed the `null`, as the server does in every class. Elsewhere the app keeps +reading an explicit `null` as it always has, like an absent member, so a class +that uses none of these options maps exactly as before. + === Round-trip diff --git a/docs/developer-guide/Backend-Web.asciidoc b/docs/developer-guide/Backend-Web.asciidoc index aecf6c60eb2..629f3ea20ea 100644 --- a/docs/developer-guide/Backend-Web.asciidoc +++ b/docs/developer-guide/Backend-Web.asciidoc @@ -176,7 +176,8 @@ app and the server reads the same on both sides: `transient` ones. A public field is used directly; any other through its `getX` (or `isX`) and `setX` methods, and is left out when it has neither. * `@JsonProperty("due_at")` renames a field and `@JsonIgnore` leaves one out, both - from `com.codename1.annotations`. + from `com.codename1.annotations`. The options in <> + shape the rest. * A `record` is written from its accessors and read through its canonical constructor, so it needs no constructor without arguments. A component the body leaves out, or one marked `@JsonIgnore`, is passed its default: `null`, `0` or @@ -191,7 +192,12 @@ app and the server reads the same on both sides: through that codec; a value of any other class of yours is answered 500 rather than written as its `toString()`, so declare the field with its type. * A body's unknown members are ignored and absent ones leave the field as the - constructor set it, as a Spring Boot application does. + constructor set it, as a Spring Boot application does. An explicit `null` sets a + field to `null`, and leaves a primitive at its default. +* A generic class is written and read through the type arguments it's used with: + a method returning `Page` gets a codec that knows `Page`'s `List` + holds orders. +* An array is a JSON array, of any element type a field may have. A body the codec can't read -- a string where a number belongs, an enum name that doesn't exist -- is answered 400 with where it was and what was expected: @@ -203,10 +209,155 @@ $.items[2].due: expected a number or an ISO-8601 date, got true Two objects that refer to each other, such as an order whose lines point back at it, would be written forever, so a response that nests more than 64 objects deep is answered 500 with a message naming the class; mark the field that points back -`@JsonIgnore`. The build refuses what it can't write a codec for, and says why: a -field typed by a type variable, such as `List` in a generic `Page`; a body -class other than a record with no constructor without arguments; an interface; -and an array other than `byte[]`. +`@JsonIgnore`. The same limit refuses a body nested deeper than that with 400. +`cn1.json.maxDepth` in `application.properties` moves it. The build refuses what +it can't write a codec for, and says why: a generic class used without its type +arguments, such as a raw `Page`; a body class other than a record with neither a +constructor without arguments nor a `@JsonCreator`; an interface; and an array +of more than one dimension, which a `List` of arrays replaces. + +[[backend-json-contract]] +==== Shaping the JSON contract + +An API that outlives one release needs more say over its JSON than a field name. +These annotations, from `com.codename1.annotations`, are read at build time by the +server's codecs and by the app's `@Mapped` mappers alike, so a class shared by +both keeps one contract: + +[cols="1,3", options="header"] +|=== +| Annotation | Effect + +| `@JsonProperty(value = "order_id", aliases = {"orderId"})` +| Writes the field as `order_id` and reads it from any of the names. An alias is + the way to rename a member without breaking clients that still send the old + name. + +| `@JsonProperty(required = true)` +| A body that leaves the member out is answered 400: + `$.order_id: required member is missing`. An explicit `null` is present, so it's + accepted for an object and refused for a primitive, which can't hold it. + +| `@JsonStrict` on a class +| A member the class doesn't declare is answered 400 with its path, + `$.lines[0].colour: unknown member`, where the default ignores it. + +| `@JsonOmitNull` on a class or a field +| A `null` member is left out of the JSON instead of written as `null`. + +| `@JsonFormat(JsonFormat.Shape.STRING)` on a field +| A number is written in quotes, which a JavaScript client needs for a `long` + past 2^53. A `Date` is written as an ISO-8601 instant in UTC, + `2025-03-01T09:30:00.000Z`. Reading accepts either form, so a field can change + format without breaking its old clients. + +| `@JsonAdapter(MoneyAdapter.class)` on a field or a class +| Hands the value to your own `JsonTypeAdapter`. On a class, it applies wherever + the class appears. + +| `@JsonCreator` on a constructor or a static factory +| Reads an immutable class through it. Each parameter names its member with + `@JsonProperty`, because a compiled class doesn't keep parameter names. +|=== + +An immutable class keeps its fields `final` and is read through its creator: + +[source,java,role=cn1-no-try] +---- +@JsonStrict +public final class Point { + private final int x; + private final int y; + + @JsonCreator + public Point(@JsonProperty(value = "x", required = true) int x, + @JsonProperty("y") int y) { + this.x = x; + this.y = y; + } + + public int getX() { return x; } + public int getY() { return y; } +} +---- + +An adapter converts between a type and the parsed form of JSON: `null`, a +`String`, a `Boolean`, a `Number`, a `Map` for an object and a `List` for an +array. It's for the types the codecs don't map themselves, such as an amount kept +in cents and sent as a decimal string: + +[source,java,role=cn1-no-try] +---- +public final class MoneyAdapter implements JsonTypeAdapter { + public Object toJson(Money m) { + return m == null ? null : m.toDecimalString(); + } + + public Money fromJson(Object json) { + if (!(json instanceof String)) { + throw new IllegalArgumentException("expected an amount"); + } + return Money.parse((String) json); + } +} +---- + +The adapter needs a public constructor without arguments. The build creates one +instance of it and calls it directly, so nothing is looked up by name at run time +and a native build keeps the class. An `IllegalArgumentException` it throws is +answered 400, with the member's path in front of its message. + +==== Coming from Jackson + +A Spring controller maps JSON through Jackson's `ObjectMapper` at run time. The +backend does the same work at build time, so most of what an `ObjectMapper` call +did becomes a declaration: + +[cols="1,2", options="header"] +|=== +| Jackson | Codename One backend + +| `mapper.readValue(body, Order.class)` in a handler +| Declare the parameter `@RequestBody Order order`. + +| `mapper.writeValueAsString(order)` for a response +| Return the `Order`. + +| `@JsonProperty`, `@JsonIgnore`, `@JsonCreator` +| The annotations of the same names in `com.codename1.annotations`. + +| `@JsonAlias` +| `@JsonProperty(aliases = ...)`. + +| `@JsonInclude(Include.NON_NULL)` +| `@JsonOmitNull`. + +| `FAIL_ON_UNKNOWN_PROPERTIES` +| `@JsonStrict` on the classes that want it. Unknown members are ignored by + default, as Spring Boot configures Jackson. + +| `@JsonFormat(shape = STRING)` +| `@JsonFormat(JsonFormat.Shape.STRING)`. Dates are ISO-8601, never a pattern. + +| `@JsonSerialize` and `@JsonDeserialize` with custom serializers +| `@JsonAdapter` and a `JsonTypeAdapter`. + +| `JsonNode`, `mapper.readTree(text)` +| `Json.parse(text)`, which answers a `Map`, a `List`, a `String`, a `Long`, a + `Double`, a `Boolean` or `null`, nested as deeply as the document is. + +| `ObjectNode` built by hand for a response +| A `Map` or `List` returned from the method, or written with `Json.write(value)`. + +| `@JsonTypeInfo` and `@JsonSubTypes` +| No equivalent. A subclass is written with its own fields, but a body is read + as the declared class. Read a polymorphic body as a `Map` and choose the class + yourself. +|=== + +A field whose type has no codec, such as a JDK class the server doesn't map, +fails the build and names the field. Give it a `@JsonAdapter`, or declare it as +the value it travels as. A method that throws is answered 500 with the body `internal error`, and the exception is logged. The message isn't sent to the client, because an exception diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendJsonCodecs.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendJsonCodecs.java index 61cd67332dc..fe1ff0045e6 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendJsonCodecs.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendJsonCodecs.java @@ -56,6 +56,12 @@ final class BackendJsonCodecs { static final String JSON_PROPERTY = "Lcom/codename1/annotations/JsonProperty;"; static final String JSON_IGNORE = "Lcom/codename1/annotations/JsonIgnore;"; + static final String JSON_CREATOR = "Lcom/codename1/annotations/JsonCreator;"; + static final String JSON_STRICT = "Lcom/codename1/annotations/JsonStrict;"; + static final String JSON_OMIT_NULL = "Lcom/codename1/annotations/JsonOmitNull;"; + static final String JSON_FORMAT = "Lcom/codename1/annotations/JsonFormat;"; + static final String JSON_ADAPTER = "Lcom/codename1/annotations/JsonAdapter;"; + private static final String TYPE_ADAPTER = "com/codename1/annotations/JsonTypeAdapter"; private static final String WRITABLE = "com/codename1/backend/Json$Writable"; private static final String JSON = "com.codename1.backend.Json"; private static final String CODEC = "com.codename1.impl.backend.JsonCodec"; @@ -87,7 +93,7 @@ final class BackendJsonCodecs { } private enum Kind { PRIMITIVE, BOX, STRING, DATE, BYTES, ENUM, ANY, RAW_MAP, RAW_LIST, - COLLECTION, MAP, DTO } + COLLECTION, MAP, DTO, ARRAY, ADAPTED } /// One property of a class: its JSON name, declared type, and how to reach it. static final class Prop { @@ -101,12 +107,39 @@ static final class Prop { String setter; /// A record component: the reader passes it to the canonical constructor. boolean component; + /// Further names a body may carry it under (`@JsonProperty(aliases)`). + final List aliases = new ArrayList(); + /// `@JsonProperty(required = true)`: a body without it is refused. + boolean required; + /// `@JsonOmitNull`, on the field or its class: null is left out. + boolean omitNull; + /// `@JsonFormat(STRING)`: a number in quotes, a date in ISO-8601. + boolean asString; + /// The field's own `@JsonAdapter`, by binary name, or null. + String adapter; + } + + /// The `@JsonCreator` constructor or factory of a class, or a record's + /// canonical constructor: what a body is read into all at once. + static final class Creator { + /// ``, or the static factory's name. + String method; + /// Its parameters, in order, each read from the member it names. + final List params = new ArrayList(); } /// A class that gets a codec. static final class Dto { + /// The class, or a parameterized use of a generic one (`a.Page`), + /// which gets a codec of its own. + final String key; final String binary; final AnnotatedClass cls; + /// The class's type variables, as the key's arguments bind them. + final Map vars = new LinkedHashMap(); + Creator creator; + /// `@JsonStrict`: an unknown member is refused. + boolean strict; final List props = new ArrayList(); final List subclasses = new ArrayList(); String problem; @@ -115,8 +148,9 @@ static final class Dto { boolean write; boolean read; - Dto(String binary, AnnotatedClass cls) { - this.binary = binary; + Dto(String key, AnnotatedClass cls) { + this.key = key; + this.binary = raw(key); this.cls = cls; } } @@ -130,6 +164,12 @@ static final class Dto { private int counter; /// Whether any codec writes a value through [#VALUES]. private boolean valuesUsed; + /// The application's `JsonTypeAdapter` classes the codecs call, each by the + /// field of [#ADAPTERS] that holds its one instance. + private final Map adapters = new LinkedHashMap(); + + /// The generated class that holds one instance of each `JsonTypeAdapter`. + static final String ADAPTERS = "cn1app.JsonAdapters"; BackendJsonCodecs(ProcessorContext ctx) { this.ctx = ctx; @@ -155,12 +195,13 @@ static BackendJsonCodecs of(ProcessorContext ctx) { String schemaType(String type) { String t = strip(type); Kind kind = t == null ? null : kindOf(t); - if (kind == null || kind == Kind.ANY) { - return ""; + if (kind == null || kind == Kind.ANY || kind == Kind.ADAPTED) { + return ""; // an adapter's form is whatever it returns } switch (kind) { case COLLECTION: case RAW_LIST: + case ARRAY: return "array"; case MAP: case RAW_MAP: @@ -243,7 +284,21 @@ private String check(String type, boolean read, Set visiting) { return check(args.get(1), read, visiting); } case DTO: - return checkDto(raw(type), read, visiting); + return checkDto(dtoKey(type), read, visiting); + case ARRAY: { + String element = type.substring(0, type.length() - 2); + if (element.endsWith("[]") && !"byte[]".equals(element)) { + return "the array " + type + ", which has more than one dimension; use a " + + "List of arrays"; + } + if (element.indexOf('<') >= 0) { + return "an array of " + element + ", which Java cannot create for a body " + + "to fill; use a List"; + } + return check(element, read, visiting); + } + case ADAPTED: + return adapterProblem(classAdapter(type), type); default: // Including Object and a raw Map or List, which are accepted on // purpose although their contents are only known at run time. @@ -282,11 +337,21 @@ private String checkDto(String binary, boolean read, Set visiting) { if (read ? p.field == null && p.setter == null && !p.component : p.get == null) { continue; } - String why = check(p.type, read, visiting); + String why = p.adapter != null ? adapterProblem(p.adapter, p.type) + : check(p.type, read, visiting); if (why != null) { return "field " + p.json + " of " + binary + " is " + why; } } + if (read && dto.creator != null) { + for (Prop p : dto.creator.params) { + String why = check(p.type, true, visiting); + if (why != null) { + return "parameter " + p.json + " of the @JsonCreator of " + binary + " is " + + why; + } + } + } if (!read) { for (String sub : dto.subclasses) { String why = checkDto(sub, false, visiting); @@ -298,15 +363,47 @@ private String checkDto(String binary, boolean read, Set visiting) { return null; } - private Dto dto(String binary) { - Dto existing = dtos.get(binary); + /// What a DTO type is keyed by: the raw class, or for a generic class used + /// with its type arguments the whole type, so `Page` and `Page` + /// each get a codec that knows what their items are. + private String dtoKey(String type) { + String raw = raw(type); + List args = args(type); + if (args.isEmpty()) { + return raw; + } + AnnotatedClass cls = RestControllerAnnotationProcessor.resolveClass(ctx, + raw.replace('.', '/')); + if (cls == null || typeParameters(cls.getSignature()).size() != args.size()) { + return raw; + } + StringBuilder key = new StringBuilder(raw).append('<'); + for (int i = 0; i < args.size(); i++) { + String a = strip(args.get(i)); + key.append(i == 0 ? "" : ", ").append(a == null ? "?" : a); + } + return key.append('>').toString(); + } + + private Dto dto(String key) { + Dto existing = dtos.get(key); if (existing != null) { return existing; } + String binary = raw(key); AnnotatedClass cls = RestControllerAnnotationProcessor.resolveClass(ctx, binary.replace('.', '/')); - Dto dto = new Dto(binary, cls); - dtos.put(binary, dto); + Dto dto = new Dto(key, cls); + dtos.put(key, dto); + if (cls != null) { + List params = typeParameters(cls.getSignature()); + List args = args(key); + for (int i = 0; i < params.size() && i < args.size(); i++) { + if (!"?".equals(args.get(i))) { + dto.vars.put(params.get(i), args.get(i)); + } + } + } if (cls == null) { dto.problem = binary + ", which the build cannot find on its class path to read " + "its fields"; @@ -323,21 +420,125 @@ private Dto dto(String binary) { return dto; } dto.ownWriter = implementsWritable(cls, new HashSet()); + dto.strict = cls.getClassAnnotation(JSON_STRICT) != null; collectProps(dto, cls); if (dto.problem != null) { return dto; } - if (cls.isAbstract()) { + if (!cls.isRecord()) { + collectCreator(dto, cls); + } + // A refused creator is a read problem of its own; writing is unaffected. + if (dto.readProblem == null && cls.isAbstract()) { dto.readProblem = "the abstract class " + binary + ", which a body cannot " + "create; accept a concrete subclass"; - } else if (!cls.isRecord() && !hasNoArgConstructor(cls)) { + } else if (dto.readProblem == null && !cls.isRecord() && dto.creator == null + && !hasNoArgConstructor(cls)) { dto.readProblem = binary + ", which has no constructor without arguments for a " - + "body to create it with; add one"; + + "body to create it with; add one, or mark the constructor to read it " + + "with @JsonCreator"; + } + for (String sub : subclassesOf(binary)) { + // A generic subclass would need its own type arguments, which a value + // declared as this class does not give; one that binds them all is + // concrete and is written as itself. + AnnotatedClass c = RestControllerAnnotationProcessor.resolveClass(ctx, + sub.replace('.', '/')); + if (dto.vars.isEmpty() || c == null || typeParameters(c.getSignature()).isEmpty()) { + dto.subclasses.add(sub); + } } - dto.subclasses.addAll(subclassesOf(binary)); return dto; } + /// The `@JsonCreator` of `cls`, if it has one: a non-private constructor, or + /// a static factory returning the class, whose every parameter names its + /// member with `@JsonProperty`. + private void collectCreator(Dto dto, AnnotatedClass cls) { + MethodInfo found = null; + for (MethodInfo m : cls.getMethods()) { + if (m.getAnnotation(JSON_CREATOR) == null) { + continue; + } + if (found != null) { + dto.readProblem = dto.binary + ", which marks two members @JsonCreator; a body " + + "is read through one"; + return; + } + found = m; + } + if (found == null) { + return; + } + String where = "the @JsonCreator " + (found.isConstructor() ? "constructor" + : "method " + found.getName()) + " of " + dto.binary; + if (found.isPrivate()) { + dto.readProblem = where + ", which is private; the generated codec cannot call it"; + return; + } + if (!found.isConstructor() && (!found.isStatic() || !Type.getReturnType( + found.getDescriptor()).getClassName().equals(dto.binary))) { + dto.readProblem = where + ", which has to be a constructor or a static method " + + "returning " + source(dto.binary); + return; + } + if (found.isConstructor() && cls.isAbstract()) { + return; // refused below as abstract + } + Type[] types = Type.getArgumentTypes(found.getDescriptor()); + List generic = parameterTypes(found.getSignature(), dto.vars); + List> annotations = found.getParameterAnnotations(); + Creator creator = new Creator(); + creator.method = found.getName(); + Set names = new HashSet(); + for (int i = 0; i < types.length; i++) { + AnnotationValues named = annotations == null || i >= annotations.size() + || annotations.get(i) == null ? null : annotations.get(i).get(JSON_PROPERTY); + String json = named == null ? null : named.getString("value"); + if (json == null || json.length() == 0) { + dto.readProblem = where + ": parameter " + (i + 1) + " has no @JsonProperty " + + "naming the member it is read from, and a compiled class keeps no " + + "parameter names to match it by"; + return; + } + Prop p = new Prop(); + p.json = json; + readOptions(p, named); + if (generic == null) { + p.type = RestClientAnnotationProcessor.javaTypeFor(types[i], null); + } else if (i < generic.size() && generic.get(i) != null) { + p.type = generic.get(i); + } else { + dto.readProblem = where + ": parameter " + json + " has a type variable for its " + + "type, so the build cannot know what it holds"; + return; + } + if (!names.add(json)) { + dto.readProblem = where + " reads \"" + json + "\" into two parameters"; + return; + } + creator.params.add(p); + } + dto.creator = creator; + } + + /// `aliases` and `required` of a `@JsonProperty`, onto `p`. + private static void readOptions(Prop p, AnnotationValues named) { + if (named == null) { + return; + } + Object aliases = named.get("aliases"); + if (aliases instanceof List) { + for (Object a : (List) aliases) { + if (a instanceof String && ((String) a).length() > 0 + && !a.equals(p.json) && !p.aliases.contains(a)) { + p.aliases.add((String) a); + } + } + } + p.required = named.getBoolOrDefault("required", false); + } + private void collectProps(Dto dto, AnnotatedClass cls) { List chain = new ArrayList(); for (AnnotatedClass c = cls; c != null; ) { @@ -348,7 +549,18 @@ private void collectProps(Dto dto, AnnotatedClass cls) { } c = RestControllerAnnotationProcessor.resolveClass(ctx, parent); } + // Each class's type variables as the one below it binds them, from the + // DTO's own arguments up: Page extends Base makes Base's T User. + List> vars = new ArrayList>(); + for (int i = 0; i < chain.size(); i++) { + vars.add(null); + } + vars.set(chain.size() - 1, dto.vars); + for (int i = chain.size() - 1; i > 0; i--) { + vars.set(i - 1, superVars(chain.get(i), vars.get(i), chain.get(i - 1))); + } String pkg = packageOf(dto.binary); + boolean omitAll = cls.getClassAnnotation(JSON_OMIT_NULL) != null; Set names = new HashSet(); // A record's state is its components: private final fields, in // declaration order, read through the public accessor of the same name @@ -356,7 +568,8 @@ private void collectProps(Dto dto, AnnotatedClass cls) { // carry no JavaBeans getters or setters, so the rules below would find // nothing and the record would go over the wire as {}. boolean record = cls.isRecord(); - for (AnnotatedClass c : chain) { + for (int level = 0; level < chain.size(); level++) { + AnnotatedClass c = chain.get(level); for (FieldInfo f : c.getFields()) { int access = f.getAccess(); boolean ignored = f.getAnnotation(JSON_IGNORE) != null; @@ -369,14 +582,28 @@ boolean record = cls.isRecord(); AnnotationValues renamed = f.getAnnotation(JSON_PROPERTY); String json = renamed == null ? null : renamed.getString("value"); p.json = json == null || json.length() == 0 ? f.getName() : json; + readOptions(p, renamed); if (hasTypeVariable(f.getSignature())) { - dto.problem = "a class whose field " + f.getName() + " has a type variable " - + "for its type (" + c.getBinaryName() + "), so the build cannot " - + "know what it holds; declare a concrete subclass"; + p.type = resolve(f.getSignature(), vars.get(level)); + if (p.type == null) { + dto.problem = "a class whose field " + f.getName() + " has a type " + + "variable for its type (" + c.getBinaryName() + "), so the " + + "build cannot know what it holds; declare it with its type " + + "arguments, as in Page, or declare a concrete subclass"; + return; + } + } else { + p.type = RestClientAnnotationProcessor.javaTypeFor( + Type.getType(f.getDescriptor()), f.getSignature()); + } + p.omitNull = (omitAll || f.getAnnotation(JSON_OMIT_NULL) != null) + && !PRIMITIVES.contains(p.type); + String option = fieldOptions(p, f); + if (option != null) { + dto.problem = "a class whose field " + f.getName() + " of " + + c.getBinaryName() + " " + option; return; } - p.type = RestClientAnnotationProcessor.javaTypeFor( - Type.getType(f.getDescriptor()), f.getSignature()); // The codec lives in the class's own package, so a field it can // name is read and written directly; any other goes through the // JavaBeans accessors, as it does for the app's mapper. @@ -400,6 +627,14 @@ boolean record = cls.isRecord(); + "\" in JSON (" + dto.binary + "); rename one with @JsonProperty"; return; } + for (String alias : p.aliases) { + if (!names.add(alias)) { + dto.problem = "a record whose alias \"" + alias + "\" of component " + + f.getName() + " is already the name of another member (" + + dto.binary + ")"; + return; + } + } dto.props.add(p); continue; } @@ -425,6 +660,14 @@ boolean record = cls.isRecord(); + dto.binary + "); rename one with @JsonProperty"; return; } + for (String alias : p.aliases) { + if (!names.add(alias)) { + dto.problem = "a class whose alias \"" + alias + "\" of field " + + f.getName() + " is already the name of another member (" + + dto.binary + ")"; + return; + } + } dto.props.add(p); } } @@ -559,6 +802,329 @@ static boolean hasTypeVariable(String signature) { return false; } + /// The `@JsonFormat` and `@JsonAdapter` of field `f` onto `p`, or why the + /// field cannot have them. + private static String fieldOptions(Prop p, FieldInfo f) { + AnnotationValues adapter = f.getAnnotation(JSON_ADAPTER); + if (adapter != null) { + Object value = adapter.get("value"); + if (!(value instanceof Type)) { + return "has a @JsonAdapter that names no class"; + } + p.adapter = ((Type) value).getClassName(); + if (PRIMITIVES.contains(p.type)) { + return "is a " + p.type + " with a @JsonAdapter, which can hand back null; " + + "declare it with its box type"; + } + } + AnnotationValues format = f.getAnnotation(JSON_FORMAT); + Object shape = format == null ? null : format.get("value"); + String name = shape instanceof String[] && ((String[]) shape).length == 2 + ? ((String[]) shape)[1] : null; + if (name == null || "DEFAULT".equals(name)) { + return null; + } + if (p.adapter != null) { + return "has both @JsonAdapter and @JsonFormat; its adapter decides its form"; + } + boolean date = "java.util.Date".equals(p.type); + boolean number = (PRIMITIVES.contains(p.type) || BOXES.contains(p.type)) + && !"boolean".equals(p.type) && !"char".equals(p.type) + && !"java.lang.Boolean".equals(p.type) && !"java.lang.Character".equals(p.type); + if ("STRING".equals(name) && (date || number)) { + p.asString = true; + return null; + } + if ("NUMBER".equals(name) && date) { + return null; // a date's default form + } + return "is marked @JsonFormat(" + name + "), which a " + p.type + " has no form of; " + + "STRING applies to numbers and dates, NUMBER to dates"; + } + + /// The `@JsonAdapter` of the class `type` names, by binary name. + private String classAdapter(String type) { + AnnotatedClass cls = RestControllerAnnotationProcessor.resolveClass(ctx, + raw(type).replace('.', '/')); + AnnotationValues adapter = cls == null ? null : cls.getClassAnnotation(JSON_ADAPTER); + Object value = adapter == null ? null : adapter.get("value"); + return value instanceof Type ? ((Type) value).getClassName() : null; + } + + /// Null when the adapter class `adapter` can convert a `type` for a + /// generated codec, or why not. Registers it in [#ADAPTERS] when it can. + private String adapterProblem(String adapter, String type) { + if (adapter == null) { + return "a class whose @JsonAdapter names no class"; + } + AnnotatedClass cls = RestControllerAnnotationProcessor.resolveClass(ctx, + adapter.replace('.', '/')); + String what = "converted by the JSON adapter " + source(adapter) + ", "; + if (cls == null) { + return what + "which the build cannot find on its class path"; + } + if (cls.isInterface() || cls.isAbstract() || isInnerClass(cls) + || !cls.isAccessibleFromAnywhere() || !hasPublicNoArgConstructor(cls)) { + return what + "which the generated code cannot create; make it a public, " + + "concrete, top-level or static class with a public constructor without " + + "arguments"; + } + String converts = adaptedType(cls, Collections.emptyMap(), + new HashSet()); + if (converts == null) { + return what + "which does not implement JsonTypeAdapter with a type argument; " + + "declare it as implements JsonTypeAdapter<" + source(raw(type)) + ">"; + } + if (!raw(converts).equals(raw(type))) { + return what + "which converts " + source(converts) + ", not " + source(type); + } + adapterField(adapter); + return null; + } + + /// The `T` of `JsonTypeAdapter` as `cls`, or a superclass, binds it. + private String adaptedType(AnnotatedClass cls, Map vars, Set seen) { + if (cls == null || !seen.add(cls.getInternalName())) { + return null; + } + List parts = classSignatureParts(cls.getSignature()); + for (int i = 1; i < parts.size(); i++) { + String itf = resolve(parts.get(i), vars); + if (itf != null && ("com.codename1.annotations.JsonTypeAdapter").equals(raw(itf))) { + List a = args(itf); + return a.size() == 1 ? strip(a.get(0)) : null; + } + } + String parent = cls.getSuperInternalName(); + if (parent == null || parent.startsWith("java/")) { + return null; + } + AnnotatedClass up = RestControllerAnnotationProcessor.resolveClass(ctx, parent); + return up == null ? null : adaptedType(up, superVars(cls, vars, up), seen); + } + + /// The field of [#ADAPTERS] holding the one instance of `adapter`. + private String adapterField(String adapter) { + String field = adapters.get(adapter); + if (field == null) { + field = "a" + adapters.size(); + adapters.put(adapter, field); + } + return field; + } + + private static boolean hasPublicNoArgConstructor(AnnotatedClass cls) { + for (MethodInfo m : cls.getMethods()) { + if (m.isConstructor() && "()V".equals(m.getDescriptor()) && m.isPublic()) { + return true; + } + } + return false; + } + + // ------------------------------------------------------------------ + // Generic signatures + // ------------------------------------------------------------------ + + /// The names of the type parameters a class or method signature declares. + static List typeParameters(String signature) { + List out = new ArrayList(); + if (signature == null || !signature.startsWith("<")) { + return out; + } + int i = 1; + while (signature.charAt(i) != '>') { + int colon = signature.indexOf(':', i); + out.add(signature.substring(i, colon)); + i = colon; + while (signature.charAt(i) == ':') { + i++; + if (signature.charAt(i) != ':') { + i = skipType(signature, i); + } + } + } + return out; + } + + /// A class signature's superclass and interfaces, each as a signature. + static List classSignatureParts(String signature) { + List out = new ArrayList(); + if (signature == null) { + return out; + } + int i = afterTypeParameters(signature); + while (i < signature.length()) { + int end = skipType(signature, i); + out.add(signature.substring(i, end)); + i = end; + } + return out; + } + + /// Each parameter of a method signature as a Java type, its type variables + /// replaced from `vars` (null for one that is not there); null when the + /// method has no generic signature. + static List parameterTypes(String signature, Map vars) { + if (signature == null) { + return null; + } + List out = new ArrayList(); + int i = afterTypeParameters(signature) + 1; // past '(' + while (signature.charAt(i) != ')') { + int end = skipType(signature, i); + out.add(resolve(signature.substring(i, end), vars)); + i = end; + } + return out; + } + + /// The type variables of `parent` as `child`, whose own are `vars`, binds + /// them in its extends clause. + private static Map superVars(AnnotatedClass child, Map vars, + AnnotatedClass parent) { + Map out = new LinkedHashMap(); + List parts = classSignatureParts(child.getSignature()); + String superType = parts.isEmpty() ? null : resolve(parts.get(0), vars); + if (superType == null) { + return out; + } + List params = typeParameters(parent.getSignature()); + List args = args(superType); + for (int i = 0; i < params.size() && i < args.size(); i++) { + String a = strip(args.get(i)); + if (a != null) { + out.put(params.get(i), a); + } + } + return out; + } + + private static int afterTypeParameters(String signature) { + if (!signature.startsWith("<")) { + return 0; + } + int i = 1; + while (signature.charAt(i) != '>') { + i = signature.indexOf(':', i); + while (signature.charAt(i) == ':') { + i++; + if (signature.charAt(i) != ':') { + i = skipType(signature, i); + } + } + } + return i + 1; + } + + /// The index just past the type signature starting at `i`. + private static int skipType(String s, int i) { + char c = s.charAt(i); + switch (c) { + case '[': + case '+': + case '-': + return skipType(s, i + 1); + case 'T': + return s.indexOf(';', i) + 1; + case 'L': { + int depth = 0; + for (int j = i; j < s.length(); j++) { + char ch = s.charAt(j); + if (ch == '<') { + depth++; + } else if (ch == '>') { + depth--; + } else if (ch == ';' && depth == 0) { + return j + 1; + } + } + return s.length(); + } + default: + return i + 1; // a primitive, or the wildcard '*' + } + } + + /// A type signature as a Java type -- in the form the rest of this class + /// reads, `a.b.C` -- with its type variables replaced + /// from `vars`; null when one of them is not there. + static String resolve(String signature, Map vars) { + if (signature == null) { + return null; + } + int[] at = {0}; + boolean[] ok = {true}; + String type = resolve(signature, at, vars, ok); + return ok[0] ? type : null; + } + + private static String resolve(String s, int[] at, Map vars, boolean[] ok) { + char c = s.charAt(at[0]++); + switch (c) { + case 'B': return "byte"; + case 'C': return "char"; + case 'D': return "double"; + case 'F': return "float"; + case 'I': return "int"; + case 'J': return "long"; + case 'S': return "short"; + case 'Z': return "boolean"; + case 'V': return "void"; + case '[': + return resolve(s, at, vars, ok) + "[]"; + case 'T': { + int semi = s.indexOf(';', at[0]); + String bound = vars == null ? null : vars.get(s.substring(at[0], semi)); + at[0] = semi + 1; + if (bound == null) { + ok[0] = false; + return "java.lang.Object"; + } + return bound; + } + case 'L': { + StringBuilder out = new StringBuilder(); + while (true) { + char ch = s.charAt(at[0]++); + if (ch == ';') { + return out.toString(); + } + if (ch == '/') { + out.append('.'); + } else if (ch == '.') { + out.append('$'); + } else if (ch == '<') { + out.append('<'); + boolean first = true; + while (s.charAt(at[0]) != '>') { + out.append(first ? "" : ", "); + first = false; + char w = s.charAt(at[0]); + if (w == '*') { + at[0]++; + out.append('?'); + } else if (w == '+' || w == '-') { + at[0]++; + out.append(w == '+' ? "? extends " : "? super ") + .append(resolve(s, at, vars, ok)); + } else { + out.append(resolve(s, at, vars, ok)); + } + } + at[0]++; + out.append('>'); + } else { + out.append(ch); + } + } + } + default: + ok[0] = false; + return "java.lang.Object"; + } + } + private Kind kindOf(String type) { if (PRIMITIVES.contains(type)) { return Kind.PRIMITIVE; @@ -579,7 +1145,7 @@ private Kind kindOf(String type) { return Kind.ANY; } if (type.endsWith("[]")) { - return null; + return Kind.ARRAY; } String raw = raw(type); boolean generic = type.indexOf('<') >= 0; @@ -603,6 +1169,9 @@ private Kind kindOf(String type) { } AnnotatedClass cls = RestControllerAnnotationProcessor.resolveClass(ctx, raw.replace('.', '/')); + if (cls != null && cls.getClassAnnotation(JSON_ADAPTER) != null) { + return Kind.ADAPTED; + } if (cls != null && cls.isEnum()) { return Kind.ENUM; } @@ -610,9 +1179,6 @@ private Kind kindOf(String type) { } private static String whyNot(String type) { - if (type.endsWith("[]")) { - return "an array, which has no JSON form here other than byte[]; use a List"; - } if (type.startsWith("com.codename1.backend.")) { return "the runtime type " + type + ", which is not data to write as JSON"; } @@ -703,9 +1269,33 @@ private void write(StringBuilder sb, String type, String expr, String depth, Str return; } case DTO: - sb.append(ind).append(codecSource(raw(type))).append(".write(").append(expr) + sb.append(ind).append(codecBinary(dtoKey(type))).append(".write(").append(expr) .append(", out, ").append(depth).append(" + 1);\n"); return; + case ADAPTED: + writeAdapted(sb, classAdapter(type), expr, depth, ind); + return; + case ARRAY: { + String element = type.substring(0, type.length() - 2); + String a = fresh("a"); + String i = fresh("i"); + sb.append(ind).append(source(type)).append(' ').append(a).append(" = ") + .append(expr).append(";\n"); + sb.append(ind).append("if (").append(a).append(" == null) {\n"); + sb.append(ind).append(" out.putAscii(\"null\");\n"); + sb.append(ind).append("} else {\n"); + sb.append(ind).append(" out.put('[');\n"); + sb.append(ind).append(" for (int ").append(i).append(" = 0; ").append(i) + .append(" < ").append(a).append(".length; ").append(i).append("++) {\n"); + sb.append(ind).append(" if (").append(i).append(" > 0) {\n"); + sb.append(ind).append(" out.put(',');\n"); + sb.append(ind).append(" }\n"); + write(sb, element, a + "[" + i + "]", depth, ind + " "); + sb.append(ind).append(" }\n"); + sb.append(ind).append(" out.put(']');\n"); + sb.append(ind).append("}\n"); + return; + } case COLLECTION: { String element = elementType(type, 0); String c = fresh("c"); @@ -846,10 +1436,46 @@ private void read(StringBuilder sb, String type, String json, String at, String return; } case DTO: - sb.append(ind).append(target).append(" = ").append(codecSource(raw(type))) + sb.append(ind).append(target).append(" = ").append(codecBinary(dtoKey(type))) .append(".read(").append(json).append(", ").append(where).append(", ") .append(depth).append(" + 1);\n"); return; + case ADAPTED: + readAdapted(sb, classAdapter(type), json, where, target, ind); + return; + case ARRAY: { + String element = type.substring(0, type.length() - 2); + String src = fresh("l"); + String path = fresh("p"); + String out = fresh("c"); + String loop = fresh("i"); + String value = fresh("v"); + sb.append(ind).append("if (").append(json).append(" == null) {\n"); + sb.append(ind).append(" ").append(target).append(" = null;\n"); + sb.append(ind).append("} else {\n"); + sb.append(ind).append(" if (!(").append(json).append(" instanceof java.util.List)) {\n"); + sb.append(ind).append(" throw ").append(CODEC).append(".mismatch(") + .append(where).append(", \"an array\", ").append(json).append(");\n"); + sb.append(ind).append(" }\n"); + sb.append(ind).append(" java.util.List ").append(src).append(" = (java.util.List) ") + .append(json).append(";\n"); + sb.append(ind).append(" ").append(CODEC).append(".Path ").append(path) + .append(" = ").append(CODEC).append(".enter(").append(where).append(");\n"); + sb.append(ind).append(" ").append(source(type)).append(' ').append(out) + .append(" = ").append(newArray(element, src + ".size()")).append(";\n"); + sb.append(ind).append(" for (int ").append(loop).append(" = 0; ").append(loop) + .append(" < ").append(out).append(".length; ").append(loop).append("++) {\n"); + sb.append(ind).append(" ").append(source(element)).append(' ').append(value) + .append(" = ").append(defaultOf(element)).append(";\n"); + read(sb, element, src + ".get(" + loop + ")", path, "null", loop, depth, value, + ind + " "); + sb.append(ind).append(" ").append(out).append('[').append(loop) + .append("] = ").append(value).append(";\n"); + sb.append(ind).append(" }\n"); + sb.append(ind).append(" ").append(target).append(" = ").append(out).append(";\n"); + sb.append(ind).append("}\n"); + return; + } case COLLECTION: case MAP: { boolean map = kind == Kind.MAP; @@ -917,6 +1543,42 @@ private void read(StringBuilder sb, String type, String json, String at, String } } + /// Writes `expr` through the application's adapter: its tree form, written + /// as any free-form value is. + private void writeAdapted(StringBuilder sb, String adapter, String expr, String depth, + String ind) { + valuesUsed = true; + sb.append(ind).append(VALUES).append(".write(").append(ADAPTERS).append('.') + .append(adapterField(adapter)).append(".toJson(").append(expr).append("), out, ") + .append(depth).append(");\n"); + } + + /// Reads `json` through the application's adapter, adding where it was to a + /// value the adapter refuses. + private void readAdapted(StringBuilder sb, String adapter, String json, String where, + String target, String ind) { + String x = fresh("x"); + sb.append(ind).append("try {\n"); + sb.append(ind).append(" ").append(target).append(" = ").append(ADAPTERS).append('.') + .append(adapterField(adapter)).append(".fromJson(").append(json).append(");\n"); + sb.append(ind).append("} catch (IllegalArgumentException ").append(x).append(") {\n"); + sb.append(ind).append(" throw ").append(CODEC).append(".refused(").append(where) + .append(", ").append(x).append(");\n"); + sb.append(ind).append("}\n"); + } + + /// `new E[length]`, with the brackets where Java wants them for an element + /// that is itself an array. + private static String newArray(String element, String length) { + String base = element; + StringBuilder dims = new StringBuilder(); + while (base.endsWith("[]")) { + base = base.substring(0, base.length() - 2); + dims.append("[]"); + } + return "new " + source(base) + "[" + length + "]" + dims; + } + private static String scalar(String prim, String json, String where) { String read = CODEC + ".readLong(" + json + ", " + where + ", "; if ("int".equals(prim)) { @@ -979,7 +1641,10 @@ private String cast(String type, String var) { case BYTES: case ENUM: case DTO: + case ADAPTED: return "((" + source(raw(type)) + ") " + var + ")"; + case ARRAY: + return "((" + source(type) + ") " + var + ")"; case COLLECTION: return "((java.util.Collection) " + var + ")"; case MAP: @@ -1002,21 +1667,26 @@ Map sources() { if (dto.problem != null || (!dto.write && !dto.read)) { continue; } - String codec = codecBinary(dto.binary); + String codec = codecBinary(dto.key); String other = owners.get(codec); if (other != null) { // Outer_Inner and Outer.Inner in one package fold to one codec - // name; generating both would let one replace the other. - ctx.error(dto.cls, source(dto.binary) + " and " + source(other) + " would both " + // name, as do Page and Page, or Page + // and a class a.Page_a_User; generating both would let one + // replace the other. + ctx.error(dto.cls, source(dto.key) + " and " + source(other) + " would both " + "get the JSON codec " + codec + ". Rename one of them."); continue; } - owners.put(codec, dto.binary); + owners.put(codec, dto.key); out.put(codec, codecSource(dto)); } if (valuesUsed) { out.put(VALUES, valuesSource()); } + if (!adapters.isEmpty()) { + out.put(ADAPTERS, adaptersSource()); + } return out; } @@ -1038,11 +1708,13 @@ private String valuesSource() { sb.append(" out.putAscii(\"null\");\n"); sb.append(" return;\n"); sb.append(" }\n"); - sb.append(" if (depth > ").append(CODEC).append(".MAX_DEPTH) {\n"); + sb.append(" if (depth > ").append(CODEC).append(".maxDepth()) {\n"); sb.append(" throw ").append(CODEC).append(".tooDeep(\"value\");\n"); sb.append(" }\n"); for (Dto dto : dtos.values()) { - if (dto.problem != null || !dto.write) { + // A generic class used with its arguments has a codec per use, and a + // value whose declared type says nothing does not say which. + if (dto.problem != null || !dto.write || !dto.key.equals(dto.binary)) { continue; } // Each codec dispatches to its own subclasses, so the order here does @@ -1110,10 +1782,28 @@ private String valuesSource() { return sb.toString(); } + /// `cn1app.JsonAdapters`: one instance of each application `JsonTypeAdapter` + /// a codec calls, created directly, so the class is kept and found by no name. + private String adaptersSource() { + StringBuilder sb = new StringBuilder(); + sb.append("package cn1app;\n\n"); + sb.append("// Generated: the JSON adapters the codecs call. Do not edit.\n"); + sb.append("@com.codename1.backend.annotations.Generated\n"); + sb.append("public final class JsonAdapters {\n"); + for (Map.Entry e : adapters.entrySet()) { + String type = source(e.getKey()); + sb.append(" public static final ").append(type).append(' ').append(e.getValue()) + .append(" = new ").append(type).append("();\n"); + } + sb.append("\n private JsonAdapters() {\n }\n"); + sb.append("}\n"); + return sb.toString(); + } + private String codecSource(Dto dto) { String pkg = packageOf(dto.binary); - String simple = simpleOf(codecBinary(dto.binary)); - String type = source(dto.binary); + String simple = simpleOf(codecBinary(dto.key)); + String type = source(dto.key); StringBuilder sb = new StringBuilder(); if (pkg.length() > 0) { sb.append("package ").append(pkg).append(";\n\n"); @@ -1123,128 +1813,288 @@ private String codecSource(Dto dto) { sb.append("public final class ").append(simple).append(" {\n"); sb.append(" private ").append(simple).append("() {\n }\n"); if (dto.write) { - sb.append("\n public static void write(").append(type).append(" v, ").append(SINK) - .append(" out, int depth) {\n"); - sb.append(" if (v == null) {\n"); - sb.append(" out.putAscii(\"null\");\n"); + writerSource(sb, dto, type); + } + if (dto.read && dto.readProblem == null) { + readerSource(sb, dto, type); + } + sb.append("}\n"); + return sb.toString(); + } + + private void writerSource(StringBuilder sb, Dto dto, String type) { + sb.append("\n public static void write(").append(type).append(" v, ").append(SINK) + .append(" out, int depth) {\n"); + sb.append(" if (v == null) {\n"); + sb.append(" out.putAscii(\"null\");\n"); + sb.append(" return;\n"); + sb.append(" }\n"); + sb.append(" if (depth > ").append(CODEC).append(".maxDepth()) {\n"); + sb.append(" throw ").append(CODEC).append(".tooDeep(") + .append(quote(simpleOf(dto.binary))).append(");\n"); + sb.append(" }\n"); + for (String sub : dto.subclasses) { + String subSource = source(sub); + sb.append(" if (v instanceof ").append(subSource).append(") {\n"); + sb.append(" ").append(codecBinary(sub)).append(".write((") + .append(subSource).append(") v, out, depth);\n"); sb.append(" return;\n"); sb.append(" }\n"); - sb.append(" if (depth > ").append(CODEC).append(".MAX_DEPTH) {\n"); - sb.append(" throw ").append(CODEC).append(".tooDeep(") - .append(quote(simpleOf(dto.binary))).append(");\n"); - sb.append(" }\n"); - for (String sub : dto.subclasses) { - String subSource = source(sub); - sb.append(" if (v instanceof ").append(subSource).append(") {\n"); - sb.append(" ").append(codecSource(sub)).append(".write((") - .append(subSource).append(") v, out, depth);\n"); - sb.append(" return;\n"); - sb.append(" }\n"); + } + if (dto.ownWriter) { + sb.append(" v.writeTo(out);\n"); + sb.append(" }\n"); + return; + } + // A member left out when null makes whether a comma comes before the + // next one a run-time question; a class without one keeps it static. + boolean dynamic = false; + for (Prop p : dto.props) { + dynamic |= p.get != null && p.omitNull; + } + sb.append(" out.put('{');\n"); + if (dynamic) { + sb.append(" boolean more = false;\n"); + } + boolean first = true; + for (Prop p : dto.props) { + String get = p.get; + if (get == null) { + continue; + } + String ind = " "; + if (p.omitNull) { + String t = fresh("o"); + sb.append(ind).append(source(strip(p.type))).append(' ').append(t).append(" = ") + .append(get).append(";\n"); + sb.append(ind).append("if (").append(t).append(" != null) {\n"); + get = t; + ind = " "; } - if (dto.ownWriter) { - sb.append(" v.writeTo(out);\n"); + if (dynamic) { + sb.append(ind).append("if (more) {\n"); + sb.append(ind).append(" out.put(',');\n"); + sb.append(ind).append("}\n"); + sb.append(ind).append("more = true;\n"); + } + boolean comma = !dynamic && !first; + sb.append(ind); + if (plainName(p.json)) { + sb.append("out.putAscii(\"").append(comma ? "," : "").append("\\\"") + .append(p.json).append("\\\":\");\n"); } else { - sb.append(" out.put('{');\n"); - boolean first = true; - for (Prop p : dto.props) { - String get = p.get; - if (get == null) { - continue; - } - sb.append(" "); - if (plainName(p.json)) { - sb.append("out.putAscii(\"").append(first ? "" : ",").append("\\\"") - .append(p.json).append("\\\":\");\n"); - } else { - if (!first) { - sb.append("out.put(',');\n "); - } - sb.append(JSON).append(".writeString(").append(quote(p.json)) - .append(", out);\n out.put(':');\n"); - } - write(sb, strip(p.type), get, "depth", " "); - first = false; + if (comma) { + sb.append("out.put(',');\n").append(ind); } - sb.append(" out.put('}');\n"); + sb.append(JSON).append(".writeString(").append(quote(p.json)) + .append(", out);\n").append(ind).append("out.put(':');\n"); } - sb.append(" }\n"); + writeProp(sb, p, get, ind); + if (p.omitNull) { + sb.append(" }\n"); + } + first = false; } - if (dto.read && dto.readProblem == null) { - sb.append("\n public static ").append(type).append(" read(Object json, ") - .append(CODEC).append(".Path at, String name, int index, int depth) {\n"); - sb.append(" if (json == null) {\n"); - sb.append(" return null;\n"); - sb.append(" }\n"); - sb.append(" if (!(json instanceof java.util.Map) || depth > ").append(CODEC) - .append(".MAX_DEPTH) {\n"); - sb.append(" throw ").append(CODEC) - .append(".mismatch(at, name, index, \"an object\", json);\n"); - sb.append(" }\n"); - sb.append(" java.util.Map m = (java.util.Map) json;\n"); - sb.append(" ").append(CODEC).append(".Path here = ").append(CODEC) - .append(".enter(at, name, index);\n"); - boolean record = dto.cls.isRecord(); - // A record is built last, from one local per component; an absent - // member leaves its local at the type's default, as it leaves a - // field of a class. - List args = new ArrayList(); - if (!record) { - sb.append(" ").append(type).append(" v = new ").append(type).append("();\n"); - } - for (Prop p : dto.props) { - boolean direct = p.field != null; - String local = null; - if (record) { - local = fresh("c"); - args.add(local); - sb.append(" ").append(source(strip(p.type))).append(' ').append(local) - .append(" = ").append(defaultOf(strip(p.type))).append(";\n"); - if (!p.component) { - continue; // @JsonIgnore: the default, never the body's value - } - } else if (!direct && p.setter == null) { - continue; - } + sb.append(" out.put('}');\n"); + sb.append(" }\n"); + } + + /// The value of property `p`, read off `v` by `get`, with the field's own + /// adapter or format when it has one. + private void writeProp(StringBuilder sb, Prop p, String get, String ind) { + String t = strip(p.type); + if (p.adapter != null) { + writeAdapted(sb, p.adapter, get, "depth", ind); + } else if (p.asString && "java.util.Date".equals(t)) { + sb.append(ind).append(CODEC).append(".writeIsoDate(").append(get).append(", out);\n"); + } else if (p.asString && PRIMITIVES.contains(t)) { + sb.append(ind).append(JSON).append(".writeString(String.valueOf(").append(get) + .append("), out);\n"); + } else if (p.asString) { + String b = fresh("n"); + sb.append(ind).append(t).append(' ').append(b).append(" = ").append(get).append(";\n"); + sb.append(ind).append("if (").append(b).append(" == null) {\n"); + sb.append(ind).append(" out.putAscii(\"null\");\n"); + sb.append(ind).append("} else {\n"); + sb.append(ind).append(" ").append(JSON).append(".writeString(").append(b) + .append(".toString(), out);\n"); + sb.append(ind).append("}\n"); + } else { + write(sb, t, get, "depth", ind); + } + } + + private void readerSource(StringBuilder sb, Dto dto, String type) { + sb.append("\n public static ").append(type).append(" read(Object json, ") + .append(CODEC).append(".Path at, String name, int index, int depth) {\n"); + sb.append(" if (json == null) {\n"); + sb.append(" return null;\n"); + sb.append(" }\n"); + sb.append(" if (!(json instanceof java.util.Map) || depth > ").append(CODEC) + .append(".maxDepth()) {\n"); + sb.append(" throw ").append(CODEC) + .append(".mismatch(at, name, index, \"an object\", json);\n"); + sb.append(" }\n"); + sb.append(" java.util.Map m = (java.util.Map) json;\n"); + sb.append(" ").append(CODEC).append(".Path here = ").append(CODEC) + .append(".enter(at, name, index);\n"); + if (dto.strict) { + strictSource(sb, dto); + } + boolean record = dto.cls.isRecord(); + // A record, or a class read through its @JsonCreator, is built from one + // local per member; an absent member leaves its local at the type's + // default, as it leaves a field of a class. + List args = new ArrayList(); + Set created = new HashSet(); + if (dto.creator != null) { + for (Prop p : dto.creator.params) { + String local = fresh("c"); + args.add(local); + created.add(p.json); String t = strip(p.type); - String j = fresh("j"); - String tmp = fresh("t"); - boolean primitive = PRIMITIVES.contains(t); - sb.append(" Object ").append(j).append(" = m.get(").append(quote(p.json)) - .append(");\n"); - if (primitive) { - // null, like an absent member, leaves the field's default -- - // Jackson's answer for a primitive too. - String value = scalar(t, j, "here, " + quote(p.json) + ", -1"); - sb.append(" if (").append(j).append(" != null) {\n"); - sb.append(" ").append(local != null ? local + " = " + value + ";" - : direct ? "v." + p.field + " = " + value + ";" - : "v." + p.setter + "(" + value + ");").append("\n"); - sb.append(" }\n"); - continue; - } - sb.append(" if (").append(j).append(" != null || m.containsKey(") - .append(quote(p.json)).append(")) {\n"); - sb.append(" ").append(source(t)).append(' ').append(tmp).append(" = ") - .append(defaultOf(t)).append(";\n"); - read(sb, t, j, "here", quote(p.json), "-1", "depth", tmp, " "); - sb.append(" ").append(local != null ? local + " = " + tmp + ";" - : direct ? "v." + p.field + " = " + tmp + ";" - : "v." + p.setter + "(" + tmp + ");").append("\n"); - sb.append(" }\n"); + sb.append(" ").append(source(t)).append(' ').append(local) + .append(" = ").append(defaultOf(t)).append(";\n"); + readProp(sb, p, local + " = ", ";"); } + sb.append(" ").append(type).append(" v = "); + if ("".equals(dto.creator.method)) { + sb.append("new ").append(type); + } else { + sb.append(source(dto.binary)).append('.').append(dto.creator.method); + } + sb.append('(').append(String.join(", ", args)).append(");\n"); + } else if (!record) { + sb.append(" ").append(type).append(" v = new ").append(type).append("();\n"); + } + for (Prop p : dto.props) { + boolean direct = p.field != null; + String local = null; if (record) { - sb.append(" return new ").append(type).append('('); - for (int i = 0; i < args.size(); i++) { - sb.append(i == 0 ? "" : ", ").append(args.get(i)); + local = fresh("c"); + args.add(local); + sb.append(" ").append(source(strip(p.type))).append(' ').append(local) + .append(" = ").append(defaultOf(strip(p.type))).append(";\n"); + if (!p.component) { + continue; // @JsonIgnore: the default, never the body's value } - sb.append(");\n"); - return sb.append(" }\n}\n").toString(); + } else if (!direct && p.setter == null || created.contains(p.json)) { + continue; } + readProp(sb, p, local != null ? local + " = " : direct ? "v." + p.field + " = " + : "v." + p.setter + "(", local != null || direct ? ";" : ");"); + } + if (record) { + sb.append(" return new ").append(type).append('(') + .append(String.join(", ", args)).append(");\n"); + } else { sb.append(" return v;\n"); - sb.append(" }\n"); } - sb.append("}\n"); - return sb.toString(); + sb.append(" }\n"); + } + + /// Statements reading member `p` of `m` and storing it with `assign` and + /// `end` around the value: its aliases, `required`, format and adapter. + private void readProp(StringBuilder sb, Prop p, String assign, String end) { + String t = strip(p.type); + String j = fresh("j"); + String has = fresh("h"); + String key = quote(p.json); + sb.append(" Object ").append(j).append(" = m.get(").append(key).append(");\n"); + boolean primitive = PRIMITIVES.contains(t); + boolean track = !p.aliases.isEmpty() || p.required || !primitive; + if (track) { + sb.append(" boolean ").append(has).append(" = ").append(j) + .append(" != null || m.containsKey(").append(key).append(");\n"); + String primary = key; + if (!p.aliases.isEmpty()) { + // Diagnostics name the member the body actually carried. + key = fresh("k"); + sb.append(" String ").append(key).append(" = ").append(primary) + .append(";\n"); + } + for (String alias : p.aliases) { + String a = quote(alias); + sb.append(" if (!").append(has).append(") {\n"); + sb.append(" ").append(key).append(" = ").append(a).append(";\n"); + sb.append(" ").append(j).append(" = m.get(").append(a).append(");\n"); + sb.append(" ").append(has).append(" = ").append(j) + .append(" != null || m.containsKey(").append(a).append(");\n"); + sb.append(" }\n"); + } + if (p.required) { + sb.append(" if (!").append(has).append(") {\n"); + sb.append(" throw ").append(CODEC).append(".missing(here, ").append(primary) + .append(");\n"); + sb.append(" }\n"); + } + } + if (p.asString && !"java.util.Date".equals(t)) { + sb.append(" ").append(j).append(" = ").append(CODEC).append(".numberText(") + .append(j).append(");\n"); + } + if (primitive) { + // null, like an absent member, leaves the field's default -- + // Jackson's answer for a primitive too -- unless the member is + // required, when an explicit null is a value it cannot hold. + String value = scalar(t, j, "here, " + key + ", -1"); + if (p.required) { + sb.append(" if (").append(j).append(" == null) {\n"); + sb.append(" throw ").append(CODEC).append(".mismatch(here, ").append(key) + .append(", -1, \"a value\", null);\n"); + sb.append(" }\n"); + } + sb.append(" if (").append(j).append(" != null) {\n"); + sb.append(" ").append(assign).append(value).append(end).append("\n"); + sb.append(" }\n"); + return; + } + String tmp = fresh("t"); + sb.append(" if (").append(has).append(") {\n"); + sb.append(" ").append(source(t)).append(' ').append(tmp).append(" = ") + .append(defaultOf(t)).append(";\n"); + if (p.adapter != null) { + readAdapted(sb, p.adapter, j, "here, " + key + ", -1", tmp, " "); + } else { + read(sb, t, j, "here", key, "-1", "depth", tmp, " "); + } + sb.append(" ").append(assign).append(tmp).append(end).append("\n"); + sb.append(" }\n"); + } + + /// Refuses a member of `m` that `dto` neither names nor aliases. + private void strictSource(StringBuilder sb, Dto dto) { + Set known = new java.util.TreeSet(); + for (Prop p : dto.props) { + if (p.json != null) { + known.add(p.json); + known.addAll(p.aliases); + } + } + if (dto.creator != null) { + for (Prop p : dto.creator.params) { + if (p.json == null) { + continue; + } + known.add(p.json); + known.addAll(p.aliases); + } + } + sb.append(" for (java.util.Iterator k = m.keySet().iterator(); k.hasNext();) {\n"); + sb.append(" String n = String.valueOf(k.next());\n"); + sb.append(" if ("); + if (known.isEmpty()) { + sb.append("true"); + } + String sep = ""; + for (String n : known) { + sb.append(sep).append("!n.equals(").append(quote(n)).append(')'); + sep = "\n && "; + } + sb.append(") {\n"); + sb.append(" throw ").append(CODEC).append(".unknown(here, n);\n"); + sb.append(" }\n"); + sb.append(" }\n"); } private static boolean plainName(String name) { @@ -1271,15 +2121,27 @@ private static String defaultOf(String type) { return PRIMITIVES.contains(type) ? "0" : "null"; } - private static String codecBinary(String binary) { + /// The codec of a DTO key: `a.b.Outer$InnerCn1Json` is `a.b.Outer_InnerCn1Json`, + /// and a generic class used with arguments names them, `a.Page` + /// becoming `a.Page_a_UserCn1Json`. + private static String codecBinary(String key) { + String binary = raw(key); String pkg = packageOf(binary); - String simple = binary.substring(pkg.length() == 0 ? 0 : pkg.length() + 1) - .replace('$', '_') + "Cn1Json"; - return pkg.length() == 0 ? simple : pkg + "." + simple; - } - - private static String codecSource(String binary) { - return codecBinary(binary); + StringBuilder simple = new StringBuilder(binary.substring( + pkg.length() == 0 ? 0 : pkg.length() + 1).replace('$', '_')); + if (binary.length() < key.length()) { + for (char c : key.substring(binary.length()).toCharArray()) { + if (Character.isLetterOrDigit(c)) { + simple.append(c); + } else if (c == '<' || c == ',' || c == '.' || c == '$') { + simple.append('_'); + } else if (c == '[') { + simple.append("Array"); + } + } + } + simple.append("Cn1Json"); + return pkg.length() == 0 ? simple.toString() : pkg + "." + simple; } private String fresh(String prefix) { diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java index 0c689156a66..17648252dce 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java @@ -79,6 +79,11 @@ public boolean acceptsDependencyClasses(ProcessorContext ctx) { public static final String XML_ELEMENT_DESC = "Lcom/codename1/annotations/XmlElement;"; public static final String XML_ATTRIBUTE_DESC = "Lcom/codename1/annotations/XmlAttribute;"; public static final String XML_TRANSIENT_DESC = "Lcom/codename1/annotations/XmlTransient;"; + public static final String JSON_CREATOR_DESC = "Lcom/codename1/annotations/JsonCreator;"; + public static final String JSON_STRICT_DESC = "Lcom/codename1/annotations/JsonStrict;"; + public static final String JSON_OMIT_NULL_DESC = "Lcom/codename1/annotations/JsonOmitNull;"; + public static final String JSON_FORMAT_DESC = "Lcom/codename1/annotations/JsonFormat;"; + public static final String JSON_ADAPTER_DESC = "Lcom/codename1/annotations/JsonAdapter;"; static final String BOOTSTRAP_BINARY = "cn1app.MapperBootstrap"; static final String BOOTSTRAP_SIMPLE = "MapperBootstrap"; @@ -125,9 +130,25 @@ public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws Proces + cls.getBinaryName() + " is abstract or an interface"); return; } - if (!isRecord && !hasPublicNoArgConstructor(cls)) { + MethodInfo creator = null; + for (MethodInfo m : cls.getMethods()) { + if (m.getAnnotation(JSON_CREATOR_DESC) == null) continue; + if (creator != null) { + ctx.error(cls, "@Mapped class " + cls.getBinaryName() + + " declares more than one @JsonCreator (" + creator.getName() + + " and " + m.getName() + "); keep exactly one"); + return; + } + creator = m; + } + if (creator != null && isRecord) { + ctx.error(cls, "@JsonCreator on record " + cls.getBinaryName() + + " is not supported; a record is read through its canonical constructor"); + return; + } + if (!isRecord && creator == null && !hasPublicNoArgConstructor(cls)) { ctx.error(cls, "@Mapped class " + cls.getBinaryName() - + " must declare a public no-arg constructor for fromJson / fromXml"); + + " must declare a public no-arg constructor (or a @JsonCreator) for fromJson / fromXml"); return; } @@ -140,6 +161,14 @@ public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws Proces ? mc.mapperSimpleName : mc.packageName + "." + mc.mapperSimpleName; mc.isRecord = isRecord; + mc.strict = cls.getClassAnnotation(JSON_STRICT_DESC) != null; + boolean classOmitNull = cls.getClassAnnotation(JSON_OMIT_NULL_DESC) != null; + mc.typeParams = classTypeParams(cls.getSignature()); + String selfAdapter = adapterOf(cls.getClassAnnotation(JSON_ADAPTER_DESC)); + if (selfAdapter != null) { + if (!validAdapter(cls, mc.binaryName, null, selfAdapter, ctx)) return; + mc.classAdapter = selfAdapter; + } AnnotationValues xmlRoot = cls.getClassAnnotation(XML_ROOT_DESC); if (xmlRoot != null) { @@ -174,7 +203,10 @@ public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws Proces boolean needsSetter = !(probeKind.kind == PropertyTypeKind.Kind.PROPERTY || probeKind.kind == PropertyTypeKind.Kind.LIST_PROPERTY); String setter = needsSetter ? findSetter(cls, f) : null; - if (needsSetter && setter == null) { + // A @JsonCreator class sets what it needs through the creator, so a + // getter alone is enough: the field is written, and read only if the + // creator takes it. + if (needsSetter && setter == null && creator == null) { ctx.getLog().debug("cn1: @Mapped skipping private field " + f.getName() + " on " + cls.getBinaryName() + " (no matching getter/setter)"); @@ -186,6 +218,7 @@ public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws Proces } MappedField mf = new MappedField(); mf.name = f.getName(); + mf.helper = mf.name; mf.kind = PropertyTypeKind.of(f); // The base classifier never reflects, so an enum-typed field // arrives here as REFERENCE (and a List as a LIST whose @@ -201,6 +234,34 @@ && isEnumType(mf.kind.elementBinaryName, ctx)) { mf.useAccessor = useAccessor; mf.getterName = getterName; mf.setterName = setterName; + mf.descriptor = f.getDescriptor(); + if (creator != null && ((useAccessor && setterName == null + && mf.kind.kind != PropertyTypeKind.Kind.PROPERTY + && mf.kind.kind != PropertyTypeKind.Kind.LIST_PROPERTY) + || (!useAccessor && f.isFinal()))) { + mf.readOnly = true; + } + String desc = f.getDescriptor(); + if (desc.startsWith("[") && !"[B".equals(desc)) { + if (desc.startsWith("[[")) { + ctx.error(cls, "@Mapped field " + mf.name + " on " + mc.binaryName + + " is a multi-dimensional array (" + desc + + "); only one-dimensional arrays map to JSON"); + continue; + } + PropertyTypeKind ek = kindForDescriptor(desc.substring(1), ctx); + if (ek.kind == PropertyTypeKind.Kind.UNSUPPORTED + || ek.kind == PropertyTypeKind.Kind.BYTE_ARRAY) { + ctx.error(cls, "@Mapped field " + mf.name + " on " + mc.binaryName + + " is an array of an unsupported element type (" + desc + ")"); + continue; + } + mf.kind = ek; + mf.isArray = true; + } + if (f.getSignature() != null) { + mf.typeRef = parseTypeSignature(f.getSignature()); + } if (isRecord) { if (mf.kind.kind == PropertyTypeKind.Kind.PROPERTY || mf.kind.kind == PropertyTypeKind.Kind.LIST_PROPERTY) { @@ -220,10 +281,21 @@ && isEnumType(mf.kind.elementBinaryName, ctx)) { if (jp != null) { String v = jp.getString("value"); if (v != null && v.length() > 0) mf.jsonName = v; + mf.aliases = stringList(jp.get("aliases")); + mf.required = jp.getBoolOrDefault("required", false); } if (f.getAnnotation(JSON_IGNORE_DESC) != null) { mf.includeInJson = false; } + mf.omitNull = classOmitNull || f.getAnnotation(JSON_OMIT_NULL_DESC) != null; + if (mf.isArray && mf.includeInXml) { + // The XML form has no representation of an array yet; the field is + // JSON only rather than written as something unreadable. + ctx.getLog().warn("cn1: @Mapped array field " + mf.name + " on " + + mc.binaryName + " is mapped to JSON only, not XML"); + mf.includeInXml = false; + } + if (!checkJsonOptions(cls, mc, mf, f, ctx)) continue; AnnotationValues xe = f.getAnnotation(XML_ELEMENT_DESC); if (xe != null) { String v = xe.getString("value"); @@ -251,11 +323,18 @@ && isEnumType(mf.kind.elementBinaryName, ctx)) { mc.fields.add(mf); } + if (creator != null && !bindCreator(cls, mc, creator, ctx)) return; accepted.put(mc.binaryName, mc); } @Override public void finish(ProcessorContext ctx) throws ProcessingException { + for (MappedClass mc : accepted.values()) { + mc.specs.clear(); + } + for (MappedClass mc : accepted.values()) { + assignSpecs(mc, mc, ctx, 0); + } if (ctx.hasErrors()) return; if (accepted.isEmpty()) return; @@ -303,6 +382,9 @@ private static String readExpr(MappedField f, boolean isRecord) { /// assign the field directly. private static String writeStmt(MappedField f, boolean isRecord, String rhsExpr) { if (isRecord) return "_" + f.name + " = " + rhsExpr + ";"; + // `_p_`, not `_`: a creator parameter called `v` would otherwise be the + // `_v` every read already declares. + if (f.viaLocal) return "_p_" + f.name + " = " + rhsExpr + ";"; if (f.useAccessor) return "o." + f.setterName + "(" + rhsExpr + ");"; return "o." + f.name + " = " + rhsExpr + ";"; } @@ -325,6 +407,7 @@ private static String defaultLiteral(PropertyTypeKind k) { /// the field's value (records-only: the locals are later fed to the /// canonical constructor in declaration order). private static String fieldType(MappedField f) { + if (f.isArray) return descriptorToJava(f.descriptor); switch (f.kind.kind) { case STRING: return "String"; case INT: return "int"; @@ -360,7 +443,7 @@ private static String generateMapperSource(MappedClass mc) { sb.append("@SuppressWarnings({\"all\"})\n"); sb.append("public final class ").append(mc.mapperSimpleName) .append(" implements com.codename1.mapping.Mapper<").append(mc.binaryName).append(">"); - boolean direct = canWriteDirectly(mc); + boolean direct = mc.classAdapter != null || canWriteDirectly(mc); if (direct) { sb.append(", com.codename1.mapping.Mapper.Direct<").append(mc.binaryName).append(">"); } @@ -389,16 +472,29 @@ private static String generateMapperSource(MappedClass mc) { boolean isRecord = mc.isRecord; + // One instance per adapter class, called directly: no reflection, and + // a direct reference keeps the adapter alive through ParparVM and R8. + java.util.TreeSet adapters = new java.util.TreeSet(); + collectAdapters(mc, adapters); + for (MappedClass sp : mc.specs) collectAdapters(sp, adapters); + for (String a : adapters) { + sb.append(" private static final ").append(a).append(' ').append(adapterConst(a)) + .append(" = new ").append(a).append("();\n\n"); + } + // toMap() -- reads are identical in shape regardless of record-ness; // `readExpr` picks between `o.name` (POJO) and `o.name()` (record). sb.append(" public java.util.Map toMap(").append(mc.binaryName).append(" o) {\n"); - sb.append(" java.util.LinkedHashMap m = new java.util.LinkedHashMap();\n"); - sb.append(" if (o == null) return m;\n"); - for (MappedField f : mc.fields) { - if (!f.includeInJson) continue; - emitFieldToMap(sb, f, isRecord); + if (mc.classAdapter != null) { + sb.append(" if (o == null) return new java.util.LinkedHashMap();\n"); + sb.append(" Object _t = ").append(adapterConst(mc.classAdapter)).append(".toJson(o);\n"); + sb.append(" if (_t instanceof java.util.Map) return (java.util.Map) _t;\n"); + sb.append(" throw new IllegalStateException(\"@JsonAdapter ").append(escape(mc.classAdapter)) + .append(" writes ").append(escape(mc.binaryName)) + .append(" as a JSON value that is not an object; serialise it with Mappers.toJson\");\n"); + } else { + emitToMapBody(sb, mc); } - sb.append(" return m;\n"); sb.append(" }\n\n"); // toJson() -- the same properties, appended in order instead of going @@ -410,54 +506,63 @@ private static String generateMapperSource(MappedClass mc) { sb.append(" public void toJson(").append(mc.binaryName) .append(" o, StringBuilder out) {\n"); sb.append(" if (o == null) { out.append(\"null\"); return; }\n"); - sb.append(" out.append('{');\n"); - boolean firstProp = true; - for (MappedField f : mc.fields) { - if (!f.includeInJson) continue; - // jsonEscape THEN escape: the inner one makes the key valid JSON, the - // outer one makes it a valid Java literal. escape() alone only did the - // second, so a @JsonProperty containing a quote compiled fine and then - // emitted "a"b" -- unparseable, where the map path escapes it properly - // because JSONWriter writes the key through writeString. - sb.append(" out.append(\"").append(firstProp ? "" : ",") - .append("\\\"").append(escape(jsonEscape(f.jsonName))).append("\\\":\");\n"); - emitFieldToJson(sb, f, isRecord); - firstProp = false; + if (mc.classAdapter != null) { + sb.append(" com.codename1.mapping.Mappers.appendJsonRaw(out, ") + .append(adapterConst(mc.classAdapter)).append(".toJson(o));\n"); + } else { + // A property that may be left out (@JsonOmitNull) makes the comma a + // run-time decision; without one, the separators stay literals and + // the output is exactly what it always was. + boolean runtimeComma = false; + for (MappedField f : mc.fields) { + if (f.includeInJson && f.omitNull + && (isSpecial(f) || omitCond(f, isRecord) != null)) { + runtimeComma = true; + } + } + if (runtimeComma) sb.append(" boolean _first = true;\n"); + sb.append(" out.append('{');\n"); + boolean firstProp = true; + for (MappedField f : mc.fields) { + if (!f.includeInJson) continue; + emitDirectProperty(sb, f, isRecord, firstProp, runtimeComma); + firstProp = false; + } + sb.append(" out.append('}');\n"); } - sb.append(" out.append('}');\n"); sb.append(" }\n\n"); } // fromMap() -- POJO mutates an instance in-place; record accumulates - // per-component locals and feeds them to the canonical constructor. + // per-component locals and feeds them to the canonical constructor; + // a @JsonCreator class collects its parameters the same way. sb.append(" public ").append(mc.binaryName) .append(" fromMap(java.util.Map m) {\n"); - if (isRecord) { - for (MappedField f : mc.fields) { - sb.append(" ").append(fieldType(f)).append(" _") - .append(f.name).append(" = ").append(defaultLiteral(f.kind)).append(";\n"); - } - sb.append(" if (m == null) return "); - appendRecordCtorCall(sb, mc); - sb.append(";\n"); - for (MappedField f : mc.fields) { - if (!f.includeInJson) continue; - emitFieldFromMap(sb, f, true); - } - sb.append(" return "); - appendRecordCtorCall(sb, mc); - sb.append(";\n"); + if (mc.classAdapter != null) { + sb.append(" if (m == null) return null;\n"); + sb.append(" try { return (").append(mc.binaryName).append(") ") + .append(adapterConst(mc.classAdapter)).append(".fromJson(m); }\n"); + sb.append(" catch (IllegalArgumentException _x) { throw com.codename1.mapping.JsonValues.at(\"$\", _x); }\n"); } else { - sb.append(" ").append(mc.binaryName).append(" o = new ").append(mc.binaryName).append("();\n"); - sb.append(" if (m == null) return o;\n"); - for (MappedField f : mc.fields) { - if (!f.includeInJson) continue; - emitFieldFromMap(sb, f, false); - } - sb.append(" return o;\n"); + emitReadBody(sb, mc, true); } sb.append(" }\n\n"); + // Specialisations of generic classes used with concrete type arguments. + for (int i = 0; i < mc.specs.size(); i++) { + MappedClass sp = mc.specs.get(i); + sb.append(" private static java.util.Map _sToMap_").append(i) + .append('(').append(sp.binaryName).append(" o) {\n"); + emitToMapBody(sb, sp); + sb.append(" }\n\n"); + sb.append(" private static ").append(sp.binaryName).append(" _sFromMap_").append(i) + .append("(java.util.Map m) {\n"); + emitReadBody(sb, sp, true); + sb.append(" }\n\n"); + emitClassHelpers(sb, sp); + } + emitClassHelpers(sb, mc); + // writeXml() sb.append(" public void writeXml(").append(mc.binaryName) .append(" o, com.codename1.xml.Element root) {\n"); @@ -471,30 +576,7 @@ private static String generateMapperSource(MappedClass mc) { // readXml() -- same fork as fromMap. sb.append(" public ").append(mc.binaryName) .append(" readXml(com.codename1.xml.Element root) {\n"); - if (isRecord) { - for (MappedField f : mc.fields) { - sb.append(" ").append(fieldType(f)).append(" _") - .append(f.name).append(" = ").append(defaultLiteral(f.kind)).append(";\n"); - } - sb.append(" if (root == null) return "); - appendRecordCtorCall(sb, mc); - sb.append(";\n"); - for (MappedField f : mc.fields) { - if (!f.includeInXml) continue; - emitFieldFromXml(sb, f, true); - } - sb.append(" return "); - appendRecordCtorCall(sb, mc); - sb.append(";\n"); - } else { - sb.append(" ").append(mc.binaryName).append(" o = new ").append(mc.binaryName).append("();\n"); - sb.append(" if (root == null) return o;\n"); - for (MappedField f : mc.fields) { - if (!f.includeInXml) continue; - emitFieldFromXml(sb, f, false); - } - sb.append(" return o;\n"); - } + emitReadBody(sb, mc, false); sb.append(" }\n\n"); // textOf helper -- inlined per-mapper to keep each generated class @@ -574,6 +656,7 @@ private static String packageOf(String binary) { private static boolean canWriteDirectly(MappedClass mc) { for (MappedField f : mc.fields) { if (!f.includeInJson) continue; + if (isSpecial(f)) continue; switch (f.kind.kind) { case STRING: case INT: case LONG: case SHORT: case BYTE: case CHAR: case DOUBLE: case FLOAT: case BOOLEAN: case ENUM: case DATE: @@ -803,10 +886,25 @@ private static void emitFieldToMap(StringBuilder sb, MappedField f, boolean isRe // --------------------------------------------------------------- private static void emitFieldFromMap(StringBuilder sb, MappedField f, boolean isRecord) { - String key = "\"" + escape(f.jsonName) + "\""; sb.append(" {\n"); - sb.append(" Object _v = m.get(").append(key).append(");\n"); + sb.append(" Object _v = ").append(lookupExpr(f)).append(";\n"); + if (f.required) { + // Present under any of its names, even as an explicit null, is + // enough: only an absent member is refused. + sb.append(" if (!(").append(presentExpr(f)) + .append(")) throw new IllegalArgumentException(\"$.").append(escape(f.jsonName)) + .append(": required member is missing\");\n"); + } sb.append(" if (_v != null) {\n"); + if (isSpecial(f)) { + sb.append(" try { ").append(writeStmt(f, isRecord, "_r_" + f.helper + "(_v)")).append(" }\n"); + sb.append(" catch (IllegalArgumentException _x) { throw com.codename1.mapping.JsonValues.at(") + .append(pathLiteral(f)).append(", _x); }\n"); + sb.append(" }\n"); + emitExplicitNull(sb, f, isRecord); + sb.append(" }\n"); + return; + } switch (f.kind.kind) { case STRING: sb.append(" ").append(writeStmt(f, isRecord, "_v.toString()")).append("\n"); @@ -836,7 +934,10 @@ private static void emitFieldFromMap(StringBuilder sb, MappedField f, boolean is sb.append(" ").append(writeStmt(f, isRecord, "(_v instanceof Boolean) ? ((Boolean) _v).booleanValue() : Boolean.parseBoolean(_v.toString())")).append("\n"); break; case DATE: - sb.append(" ").append(writeStmt(f, isRecord, "new java.util.Date(((Number) _v).longValue())")).append("\n"); + // Either shape: epoch millis, or an ISO-8601 string. + sb.append(" try { ").append(writeStmt(f, isRecord, "com.codename1.mapping.JsonValues.toDate(_v)")).append(" }\n"); + sb.append(" catch (IllegalArgumentException _x) { throw com.codename1.mapping.JsonValues.at(") + .append(pathLiteral(f)).append(", _x); }\n"); break; case BYTE_ARRAY: sb.append(" ").append(writeStmt(f, isRecord, "com.codename1.util.Base64.decode(_v.toString().getBytes())")).append("\n"); @@ -857,7 +958,9 @@ private static void emitFieldFromMap(StringBuilder sb, MappedField f, boolean is case REFERENCE: sb.append(" com.codename1.mapping.Mapper _nm = com.codename1.mapping.Mappers.get(").append(f.kind.binaryName).append(".class);\n"); sb.append(" if (_nm != null && _v instanceof java.util.Map) {\n"); - sb.append(" ").append(writeStmt(f, isRecord, "(" + f.kind.binaryName + ") _nm.fromMap((java.util.Map) _v)")).append("\n"); + sb.append(" try { ").append(writeStmt(f, isRecord, "(" + f.kind.binaryName + ") _nm.fromMap((java.util.Map) _v)")).append(" }\n"); + sb.append(" catch (IllegalArgumentException _x) { throw com.codename1.mapping.JsonValues.at(") + .append(pathLiteral(f)).append(", _x); }\n"); sb.append(" }\n"); break; case LIST: case LIST_PROPERTY: @@ -898,8 +1001,14 @@ private static void emitFieldFromMap(StringBuilder sb, MappedField f, boolean is sb.append(" com.codename1.mapping.Mapper _nm = com.codename1.mapping.Mappers.get(").append(f.kind.elementBinaryName).append(".class);\n"); if (f.kind.kind == PropertyTypeKind.Kind.LIST) { sb.append(" java.util.ArrayList<").append(f.kind.elementBinaryName).append("> _l = new java.util.ArrayList<").append(f.kind.elementBinaryName).append(">();\n"); - sb.append(" for (Object _e : (java.util.List) _v) {\n"); - sb.append(" if (_nm != null && _e instanceof java.util.Map) { _l.add((").append(f.kind.elementBinaryName).append(") _nm.fromMap((java.util.Map) _e)); }\n"); + sb.append(" java.util.List _src = (java.util.List) _v;\n"); + sb.append(" for (int _i = 0; _i < _src.size(); _i++) {\n"); + sb.append(" Object _e = _src.get(_i);\n"); + sb.append(" if (_nm != null && _e instanceof java.util.Map) {\n"); + sb.append(" try { _l.add((").append(f.kind.elementBinaryName).append(") _nm.fromMap((java.util.Map) _e)); }\n"); + sb.append(" catch (IllegalArgumentException _x) { throw com.codename1.mapping.JsonValues.at(\"$.") + .append(escape(f.jsonName)).append("[\" + _i + \"]\", _x); }\n"); + sb.append(" }\n"); sb.append(" }\n"); sb.append(" ").append(writeStmt(f, isRecord, "_l")).append("\n"); } else { @@ -915,9 +1024,36 @@ private static void emitFieldFromMap(StringBuilder sb, MappedField f, boolean is break; } sb.append(" }\n"); + emitExplicitNull(sb, f, isRecord); sb.append(" }\n"); } + /// A member present as an explicit null, matching the backend codecs: a + /// reference field becomes null (an absent member keeps what the + /// constructor set), a primitive keeps its value unless it is required, + /// in which case the body is refused. + private static void emitExplicitNull(StringBuilder sb, MappedField f, boolean isRecord) { + if (!f.nullClears) return; + String body; + if (isPrimitiveDesc(f.descriptor) && !f.isArray) { + if (!f.required) return; + body = "throw new IllegalArgumentException(\"$." + escape(f.jsonName) + + ": expected a value, got null\");"; + } else if (f.adapter != null) { + // The adapter is handed the null, as the server's codec does. + body = "try { " + writeStmt(f, isRecord, "_r_" + f.helper + "(null)") + + " } catch (IllegalArgumentException _x) { throw com.codename1.mapping.JsonValues.at(" + + pathLiteral(f) + ", _x); }"; + } else if (f.kind.kind == PropertyTypeKind.Kind.PROPERTY && !isSpecial(f)) { + body = readExpr(f, isRecord) + ".set(null);"; + } else if (f.kind.kind == PropertyTypeKind.Kind.LIST_PROPERTY && !isSpecial(f)) { + body = readExpr(f, isRecord) + ".clear();"; + } else { + body = writeStmt(f, isRecord, "null"); + } + sb.append(" else if (").append(presentExpr(f)).append(") { ").append(body).append(" }\n"); + } + /// Emits a loop that converts a `List` of JSON values into enum /// constants (`name()` strings, or pass-through enum instances), /// adding each to `sink`. Unknown names decode to null. @@ -1431,6 +1567,926 @@ private static String escape(String s) { return b.toString(); } + // --------------------------------------------------------------- + // JSON options: generation + // --------------------------------------------------------------- + + /// A field whose JSON form is produced and read by private helpers + /// (`_j_` / `_r_`) rather than inline: an adapter, a format, + /// an array, an adapted list element or a resolved generic class. Both + /// writers call the same `_j_` helper and render its tree, so the direct + /// and the map path agree by construction. + static boolean isSpecial(MappedField f) { + return f.isArray || f.adapter != null || f.elemAdapter != null + || f.format != null || f.specIndex >= 0; + } + + private static void collectAdapters(MappedClass mc, java.util.Set out) { + if (mc.classAdapter != null) out.add(mc.classAdapter); + for (MappedField f : mc.fields) { + if (!f.includeInJson) continue; + if (f.adapter != null) out.add(f.adapter); + if (f.elemAdapter != null) out.add(f.elemAdapter); + } + } + + private static String adapterConst(String adapter) { + return "_A_" + adapter.replace('.', '_').replace('$', '_'); + } + + /// The null test a `@JsonOmitNull` property is written under, or null when + /// its value can never be null (a primitive) or omission does not apply. + private static String omitCond(MappedField f, boolean isRecord) { + if (!f.omitNull) return null; + String read = readExpr(f, isRecord); + switch (f.kind.kind) { + case PROPERTY: + return read + ".get() != null"; + case LIST_PROPERTY: + return null; + default: + if (f.descriptor != null && f.descriptor.length() == 1) return null; + return read + " != null"; + } + } + + private static void emitToMapBody(StringBuilder sb, MappedClass mc) { + sb.append(" java.util.LinkedHashMap m = new java.util.LinkedHashMap();\n"); + sb.append(" if (o == null) return m;\n"); + for (MappedField f : mc.fields) { + if (!f.includeInJson) continue; + if (isSpecial(f)) { + sb.append(" { Object _t = _j_").append(f.helper).append("(o); ") + .append(f.omitNull ? "if (_t != null) " : "") + .append("m.put(\"").append(escape(f.jsonName)).append("\", _t); }\n"); + continue; + } + String cond = omitCond(f, mc.isRecord); + if (cond != null) sb.append(" if (").append(cond).append(") {\n"); + emitFieldToMap(sb, f, mc.isRecord); + if (cond != null) sb.append(" }\n"); + } + sb.append(" return m;\n"); + } + + private static void emitDirectProperty(StringBuilder sb, MappedField f, boolean isRecord, + boolean firstProp, boolean runtimeComma) { + String keyLit = escape(jsonEscape(f.jsonName)); + if (isSpecial(f)) { + sb.append(" {\n"); + sb.append(" Object _t = _j_").append(f.helper).append("(o);\n"); + if (f.omitNull) sb.append(" if (_t != null) {\n"); + emitKey(sb, keyLit, firstProp, runtimeComma, " "); + sb.append(" com.codename1.mapping.Mappers.appendJsonRaw(out, _t);\n"); + if (f.omitNull) sb.append(" }\n"); + sb.append(" }\n"); + return; + } + String cond = omitCond(f, isRecord); + if (cond != null) sb.append(" if (").append(cond).append(") {\n"); + emitKey(sb, keyLit, firstProp, runtimeComma, " "); + emitFieldToJson(sb, f, isRecord); + if (cond != null) sb.append(" }\n"); + } + + private static void emitKey(StringBuilder sb, String keyLit, boolean firstProp, + boolean runtimeComma, String ind) { + if (runtimeComma) { + sb.append(ind).append("out.append(_first ? \"\\\"").append(keyLit) + .append("\\\":\" : \",\\\"").append(keyLit).append("\\\":\");\n"); + sb.append(ind).append("_first = false;\n"); + return; + } + // jsonEscape THEN escape: the inner one makes the key valid JSON, the + // outer one makes it a valid Java literal. escape() alone only did the + // second, so a @JsonProperty containing a quote compiled fine and then + // emitted "a"b" -- unparseable, where the map path escapes it properly + // because JSONWriter writes the key through writeString. + sb.append(ind).append("out.append(\"").append(firstProp ? "" : ",") + .append("\\\"").append(keyLit).append("\\\":\");\n"); + } + + /// The body of `fromMap` (`json`) or `readXml`: a POJO is filled in place, + /// a record and a `@JsonCreator` class collect locals and construct once. + private static void emitReadBody(StringBuilder sb, MappedClass mc, boolean json) { + String src = json ? "m" : "root"; + // Before these options a JSON null was dropped by the parser, so a + // field kept its value. Telling null from absent is opt-in, so a + // class that uses none of the new options reads exactly as before. + for (MappedField f : mc.fields) { + f.nullClears = mc.strict || f.required || f.adapter != null; + } + if (mc.isRecord) { + for (MappedField f : mc.fields) { + sb.append(" ").append(fieldType(f)).append(" _") + .append(f.name).append(" = ") + .append(f.isArray ? "null" : defaultLiteral(f.kind)).append(";\n"); + } + sb.append(" if (").append(src).append(" == null) return "); + appendRecordCtorCall(sb, mc); + sb.append(";\n"); + if (json) emitStrictCheck(sb, mc); + for (MappedField f : mc.fields) { + emitRead(sb, f, true, json); + } + sb.append(" return "); + appendRecordCtorCall(sb, mc); + sb.append(";\n"); + return; + } + if (mc.creatorCall != null) { + for (String name : mc.creatorParams) { + MappedField f = mc.field(name); + sb.append(" ").append(descriptorToJava(f.descriptor)).append(" _p_").append(f.name) + .append(" = ").append(defaultForDescriptor(f.descriptor)).append(";\n"); + } + sb.append(" if (").append(src).append(" == null) return null;\n"); + if (json) emitStrictCheck(sb, mc); + for (String name : mc.creatorParams) { + emitRead(sb, mc.field(name), false, json); + } + sb.append(" ").append(mc.binaryName).append(" o = ").append(mc.creatorCall).append('('); + for (int i = 0; i < mc.creatorParams.size(); i++) { + if (i > 0) sb.append(", "); + sb.append("_p_").append(mc.creatorParams.get(i)); + } + sb.append(");\n"); + for (MappedField f : mc.fields) { + if (f.viaLocal || f.readOnly) continue; + emitRead(sb, f, false, json); + } + sb.append(" return o;\n"); + return; + } + sb.append(" ").append(mc.binaryName).append(" o = new ").append(mc.binaryName).append("();\n"); + sb.append(" if (").append(src).append(" == null) return o;\n"); + if (json) emitStrictCheck(sb, mc); + for (MappedField f : mc.fields) { + emitRead(sb, f, false, json); + } + sb.append(" return o;\n"); + } + + private static void emitRead(StringBuilder sb, MappedField f, boolean isRecord, boolean json) { + if (json) { + if (f.includeInJson) emitFieldFromMap(sb, f, isRecord); + } else { + if (f.includeInXml) emitFieldFromXml(sb, f, isRecord); + } + } + + private static void emitStrictCheck(StringBuilder sb, MappedClass mc) { + if (!mc.strict) return; + sb.append(" for (java.util.Iterator _ki = m.keySet().iterator(); _ki.hasNext(); ) {\n"); + sb.append(" Object _k = _ki.next();\n"); + sb.append(" if (!_known").append(mc.prefix) + .append("(_k)) throw new IllegalArgumentException(\"$.\" + _k + \": unknown member\");\n"); + sb.append(" }\n"); + } + + /// The lookup of a member under its primary name, else the first alias + /// present. + private static String lookupExpr(MappedField f) { + String key = "\"" + escape(f.jsonName) + "\""; + if (f.aliases.isEmpty()) return "m.get(" + key + ")"; + StringBuilder b = new StringBuilder(); + b.append("m.containsKey(").append(key).append(") ? m.get(").append(key).append(") : "); + for (String a : f.aliases) { + String ak = "\"" + escape(a) + "\""; + b.append("m.containsKey(").append(ak).append(") ? m.get(").append(ak).append(") : "); + } + b.append("null"); + return b.toString(); + } + + private static String presentExpr(MappedField f) { + StringBuilder b = new StringBuilder(); + b.append("m.containsKey(\"").append(escape(f.jsonName)).append("\")"); + for (String a : f.aliases) { + b.append(" || m.containsKey(\"").append(escape(a)).append("\")"); + } + return b.toString(); + } + + /// The path of the member read, under the name the body actually used: + /// a value taken from an alias is reported under that alias. + private static String pathLiteral(MappedField f) { + String primary = "\"$." + escape(f.jsonName) + "\""; + if (f.aliases.isEmpty()) return primary; + StringBuilder b = new StringBuilder("(m.containsKey(\"").append(escape(f.jsonName)) + .append("\") ? ").append(primary); + for (String a : f.aliases) { + b.append(" : m.containsKey(\"").append(escape(a)).append("\") ? \"$.") + .append(escape(a)).append('"'); + } + return b.append(" : ").append(primary).append(')').toString(); + } + + private static void emitClassHelpers(StringBuilder sb, MappedClass mc) { + if (mc.strict) { + sb.append(" private static boolean _known").append(mc.prefix).append("(Object k) {\n"); + sb.append(" return false"); + for (MappedField f : mc.fields) { + if (!f.includeInJson) continue; + sb.append("\n || \"").append(escape(f.jsonName)).append("\".equals(k)"); + for (String a : f.aliases) { + sb.append("\n || \"").append(escape(a)).append("\".equals(k)"); + } + } + sb.append(";\n }\n\n"); + } + for (MappedField f : mc.fields) { + if (!f.includeInJson || !isSpecial(f)) continue; + emitWriteHelper(sb, mc, f); + emitReadHelper(sb, f); + } + } + + private static boolean isPrimitiveDesc(String desc) { + return desc != null && desc.length() == 1; + } + + private static boolean isNumberKind(PropertyTypeKind k) { + switch (k.kind) { + case INT: case LONG: case SHORT: case BYTE: case DOUBLE: case FLOAT: + return true; + default: + return false; + } + } + + private static void emitWriteHelper(StringBuilder sb, MappedClass mc, MappedField f) { + String jt = descriptorToJava(f.descriptor); + sb.append(" private static Object _j_").append(f.helper).append('(') + .append(mc.binaryName).append(" o) {\n"); + sb.append(" ").append(jt).append(" _x = ").append(readExpr(f, mc.isRecord)).append(";\n"); + boolean listLike = !f.isArray && f.adapter == null && f.elemAdapter != null; + // An adapter decides what null is, as on the server: it is called. + if (!isPrimitiveDesc(f.descriptor) && f.adapter == null) { + // A null list is [] on every other path; keep that unless the + // property is to be left out instead. + sb.append(" if (_x == null) return ") + .append(listLike && !f.omitNull ? "new java.util.ArrayList()" : "null").append(";\n"); + } + if (f.adapter != null) { + sb.append(" return ").append(adapterConst(f.adapter)).append(".toJson((") + .append(f.adapterCast).append(") _x);\n"); + } else if (f.specIndex >= 0) { + sb.append(" return _sToMap_").append(f.specIndex).append("((") + .append(f.kind.binaryName).append(") _x);\n"); + } else if (f.format != null && f.kind.kind == PropertyTypeKind.Kind.DATE) { + if ("STRING".equals(f.format)) { + sb.append(" return com.codename1.mapping.JsonValues.formatIso((java.util.Date) _x);\n"); + } else { + sb.append(" return Long.valueOf(((java.util.Date) _x).getTime());\n"); + } + } else if (f.format != null) { + sb.append(" return String.valueOf(_x);\n"); + } else if (listLike) { + String e = f.kind.elementBinaryName; + sb.append(" java.util.ArrayList _l = new java.util.ArrayList();\n"); + sb.append(" for (java.util.Iterator _it = ((java.util.List) _x).iterator(); _it.hasNext(); ) {\n"); + sb.append(" Object _e = _it.next();\n"); + sb.append(" _l.add(").append(adapterConst(f.elemAdapter)) + .append(".toJson((").append(e).append(") _e));\n"); + sb.append(" }\n"); + sb.append(" return _l;\n"); + } else { + String ej = f.kind.binaryName; + boolean prim = isPrimitiveName(ej); + boolean mapped = f.elemAdapter == null && f.kind.kind == PropertyTypeKind.Kind.REFERENCE; + sb.append(" java.util.ArrayList _l = new java.util.ArrayList(_x.length);\n"); + if (mapped) { + sb.append(" com.codename1.mapping.Mapper _nm = com.codename1.mapping.Mappers.get(") + .append(ej).append(".class);\n"); + } + sb.append(" for (int _i = 0; _i < _x.length; _i++) {\n"); + sb.append(" ").append(ej).append(" _ex = ").append(prim ? "" : "(" + ej + ") ") + .append("_x[_i];\n"); + sb.append(" _l.add(").append(elementToTree(f, ej, prim)).append(");\n"); + sb.append(" }\n"); + sb.append(" return _l;\n"); + } + sb.append(" }\n\n"); + } + + private static String elementToTree(MappedField f, String ej, boolean prim) { + if (f.elemAdapter != null) { + return adapterConst(f.elemAdapter) + ".toJson(_ex)"; + } + if (prim) { + if ("char".equals(ej)) return "String.valueOf(_ex)"; + return boxOf(ej) + ".valueOf(_ex)"; + } + switch (f.kind.kind) { + case ENUM: + return "_ex == null ? null : _ex.name()"; + case DATE: + return "_ex == null ? null : Long.valueOf(_ex.getTime())"; + case REFERENCE: + return "_ex == null ? null : (_nm == null ? (Object) _ex : _nm.toMap(_ex))"; + default: + return "_ex"; + } + } + + private static void emitReadHelper(StringBuilder sb, MappedField f) { + String jt = descriptorToJava(f.descriptor); + sb.append(" private static ").append(jt).append(" _r_").append(f.helper).append("(Object _v) {\n"); + if (f.adapter != null) { + sb.append(" return (").append(jt).append(") ").append(adapterConst(f.adapter)) + .append(".fromJson(_v);\n"); + } else if (f.specIndex >= 0) { + sb.append(" if (!(_v instanceof java.util.Map)) throw new IllegalArgumentException(\"expected a JSON object\");\n"); + sb.append(" return _sFromMap_").append(f.specIndex) + .append("((java.util.Map) _v);\n"); + } else if (f.format != null && f.kind.kind == PropertyTypeKind.Kind.DATE) { + sb.append(" return com.codename1.mapping.JsonValues.toDate(_v);\n"); + } else if (f.format != null) { + String prim = primitiveOfNumberKind(f.kind); + String expr = numberFromTree(prim, "_v"); + if (isPrimitiveDesc(f.descriptor)) { + sb.append(" return ").append(expr).append(";\n"); + } else { + sb.append(" return ").append(boxOf(prim)).append(".valueOf(").append(expr).append(");\n"); + } + } else { + boolean list = !f.isArray; + String ej = list ? f.kind.elementBinaryName : f.kind.binaryName; + boolean prim = isPrimitiveName(ej); + boolean mapped = !list && f.elemAdapter == null && f.kind.kind == PropertyTypeKind.Kind.REFERENCE + && !"java.lang.Object".equals(ej); + sb.append(" if (!(_v instanceof java.util.List)) throw new IllegalArgumentException(\"expected a JSON array\");\n"); + sb.append(" java.util.List _src = (java.util.List) _v;\n"); + if (list) { + sb.append(" java.util.ArrayList<").append(ej).append("> _a = new java.util.ArrayList<") + .append(ej).append(">();\n"); + } else { + sb.append(" ").append(ej).append("[] _a = new ").append(ej).append("[_src.size()];\n"); + } + if (mapped) { + sb.append(" com.codename1.mapping.Mapper _nm = com.codename1.mapping.Mappers.get(") + .append(ej).append(".class);\n"); + } + sb.append(" for (int _i = 0; _i < _src.size(); _i++) {\n"); + sb.append(" Object _e = _src.get(_i);\n"); + sb.append(" try {\n"); + if (list) { + sb.append(" _a.add((").append(ej).append(") ") + .append(adapterConst(f.elemAdapter)).append(".fromJson(_e));\n"); + } else if (f.elemAdapter == null && f.kind.kind == PropertyTypeKind.Kind.ENUM) { + sb.append(" ").append(ej).append(" _ev = null;\n"); + sb.append(" if (_e instanceof String) { try { _ev = ").append(ej) + .append(".valueOf((String) _e); } catch (IllegalArgumentException _ex) { _ev = null; } }\n"); + sb.append(" _a[_i] = _ev;\n"); + } else { + sb.append(" _a[_i] = ").append(elementFromTree(f, ej, prim)).append(";\n"); + } + sb.append(" } catch (IllegalArgumentException _x) {\n"); + sb.append(" throw com.codename1.mapping.JsonValues.at(\"$[\" + _i + \"]\", _x);\n"); + sb.append(" }\n"); + sb.append(" }\n"); + sb.append(" return _a;\n"); + } + sb.append(" }\n\n"); + } + + private static String elementFromTree(MappedField f, String ej, boolean prim) { + if (f.elemAdapter != null) { + return "(" + ej + ") " + adapterConst(f.elemAdapter) + ".fromJson(_e)"; + } + if (prim) { + if ("boolean".equals(ej)) return "Boolean.TRUE.equals(_e) || \"true\".equals(_e)"; + if ("char".equals(ej)) { + return "(_e == null || _e.toString().length() == 0) ? '\\0' : _e.toString().charAt(0)"; + } + if ("int".equals(ej) || "short".equals(ej) || "byte".equals(ej)) { + String box = "int".equals(ej) ? "Integer" : "short".equals(ej) ? "Short" : "Byte"; + return "(" + ej + ") com.codename1.mapping.JsonValues.toIntegral(_e, " + + box + ".MIN_VALUE, " + box + ".MAX_VALUE)"; + } + if ("long".equals(ej)) { + return "com.codename1.mapping.JsonValues.toIntegral(_e, Long.MIN_VALUE, Long.MAX_VALUE)"; + } + return numberFromTree(ej, "_e"); + } + switch (f.kind.kind) { + case DATE: + return "_e == null ? null : com.codename1.mapping.JsonValues.toDate(_e)"; + case REFERENCE: + if ("java.lang.Object".equals(ej)) return "_e"; + // A null element stays null; anything else that is not an + // object is refused, as the server's codec refuses it. + return "_e == null ? null : (" + ej + ") com.codename1.mapping.JsonValues.object(_nm, _e)"; + default: + return scalarElement(ej, "_e"); + } + } + + private static String numberFromTree(String prim, String var) { + String l = "com.codename1.mapping.JsonValues.toLong(" + var + ")"; + String d = "com.codename1.mapping.JsonValues.toDouble(" + var + ")"; + if ("long".equals(prim)) return l; + if ("double".equals(prim)) return d; + if ("float".equals(prim)) return "(float) " + d; + return "(" + prim + ") " + l; + } + + private static String primitiveOfNumberKind(PropertyTypeKind k) { + switch (k.kind) { + case INT: return "int"; + case LONG: return "long"; + case SHORT: return "short"; + case BYTE: return "byte"; + case DOUBLE: return "double"; + default: return "float"; + } + } + + private static boolean isPrimitiveName(String n) { + return "int".equals(n) || "long".equals(n) || "short".equals(n) || "byte".equals(n) + || "char".equals(n) || "double".equals(n) || "float".equals(n) || "boolean".equals(n); + } + + private static String boxOf(String prim) { + if ("int".equals(prim)) return "Integer"; + if ("char".equals(prim)) return "Character"; + return Character.toUpperCase(prim.charAt(0)) + prim.substring(1); + } + + /// The Java source spelling of an (erased) descriptor. + static String descriptorToJava(String desc) { + int dims = 0; + while (desc.charAt(dims) == '[') dims++; + String base = desc.substring(dims); + String t; + switch (base.charAt(0)) { + case 'I': t = "int"; break; + case 'J': t = "long"; break; + case 'S': t = "short"; break; + case 'B': t = "byte"; break; + case 'C': t = "char"; break; + case 'D': t = "double"; break; + case 'F': t = "float"; break; + case 'Z': t = "boolean"; break; + default: t = base.substring(1, base.length() - 1).replace('/', '.'); + } + StringBuilder b = new StringBuilder(t); + for (int i = 0; i < dims; i++) b.append("[]"); + return b.toString(); + } + + /// The default a `@JsonCreator` parameter takes when its member is absent: + /// the type's own default, so a boxed parameter gets null, not zero. + private static String defaultForDescriptor(String desc) { + if (desc.length() != 1) return "null"; + switch (desc.charAt(0)) { + case 'Z': return "false"; + case 'J': return "0L"; + case 'F': return "0.0f"; + case 'D': return "0.0"; + case 'C': return "'\\0'"; + default: return "0"; + } + } + + // --------------------------------------------------------------- + // JSON options: analysis (aliases, required, strict, omit-null, + // format, adapters, creators, generics, arrays) + // --------------------------------------------------------------- + + /// Reads `@JsonFormat` and `@JsonAdapter` for one field and validates the + /// combination against its type. Every shape this cannot generate is a + /// build error naming the class and field; nothing is skipped silently. + private boolean checkJsonOptions(AnnotatedClass cls, MappedClass mc, MappedField mf, + FieldInfo f, ProcessorContext ctx) { + String where = mc.binaryName + "." + mf.name; + String fieldAdapter = adapterOf(f.getAnnotation(JSON_ADAPTER_DESC)); + if (fieldAdapter != null) { + if (mf.descriptor.length() == 1) { + ctx.error(cls, "@JsonAdapter on " + where + ": a primitive field cannot take an" + + " adapter; declare it with its boxed type"); + return false; + } + if (mf.kind.kind == PropertyTypeKind.Kind.PROPERTY + || mf.kind.kind == PropertyTypeKind.Kind.LIST_PROPERTY) { + ctx.error(cls, "@JsonAdapter on " + where + ": a Property / ListProperty field" + + " cannot take an adapter"); + return false; + } + if (!validAdapter(cls, mc.binaryName, mf.name, fieldAdapter, ctx)) return false; + mf.adapter = fieldAdapter; + mf.adapterCast = descriptorToJava(mf.descriptor); + } else if (mf.kind.kind == PropertyTypeKind.Kind.REFERENCE) { + String a = classAdapterOf(mf.kind.binaryName, ctx); + if (a != null) { + if (!validAdapter(cls, mc.binaryName, mf.name, a, ctx)) return false; + if (mf.isArray) { + mf.elemAdapter = a; + } else { + mf.adapter = a; + mf.adapterCast = mf.kind.binaryName; + } + } + } else if ((mf.kind.kind == PropertyTypeKind.Kind.LIST + || mf.kind.kind == PropertyTypeKind.Kind.LIST_PROPERTY) + && !mf.elementIsEnum && !isScalarBinary(mf.kind.elementBinaryName)) { + String a = classAdapterOf(mf.kind.elementBinaryName, ctx); + if (a != null) { + if (mf.kind.kind == PropertyTypeKind.Kind.LIST_PROPERTY) { + ctx.error(cls, "@JsonAdapter on element type " + mf.kind.elementBinaryName + + " of " + where + ": a ListProperty cannot use an adapted element;" + + " declare a java.util.List"); + return false; + } + if (!validAdapter(cls, mc.binaryName, mf.name, a, ctx)) return false; + mf.elemAdapter = a; + } + } + + AnnotationValues fa = f.getAnnotation(JSON_FORMAT_DESC); + String fmt = fa == null ? null : enumName(fa.get("value")); + if (fmt == null || "DEFAULT".equals(fmt)) return true; + if (mf.adapter != null) { + ctx.error(cls, "@JsonFormat on " + where + " cannot be combined with @JsonAdapter;" + + " the adapter decides the JSON form"); + return false; + } + boolean date = !mf.isArray && mf.kind.kind == PropertyTypeKind.Kind.DATE; + boolean number = false; + if (!mf.isArray) { + switch (mf.kind.kind) { + case INT: case LONG: case SHORT: case BYTE: case DOUBLE: case FLOAT: + number = true; + break; + default: + break; + } + } + if ("STRING".equals(fmt) && !(date || number)) { + ctx.error(cls, "@JsonFormat(STRING) on " + where + ": only a java.util.Date or a" + + " numeric field (primitive or boxed) has a string form; the field is " + + describe(mf)); + return false; + } + if ("NUMBER".equals(fmt) && !date) { + ctx.error(cls, "@JsonFormat(NUMBER) on " + where + ": only a java.util.Date has a" + + " number form other than its default; the field is " + describe(mf)); + return false; + } + mf.format = fmt; + return true; + } + + private static String describe(MappedField mf) { + return (mf.isArray ? "an array of " : "") + mf.kind.kind + " (" + mf.descriptor + ")"; + } + + /// Binds a `@JsonCreator` constructor or static factory: each parameter + /// names, through its own `@JsonProperty`, the JSON member it receives, and + /// the field of that name supplies its type and options. + private boolean bindCreator(AnnotatedClass cls, MappedClass mc, MethodInfo creator, + ProcessorContext ctx) { + String where = "@JsonCreator " + mc.binaryName + "." + + (creator.isConstructor() ? mc.simpleName : creator.getName()); + if (!creator.isPublic()) { + ctx.error(cls, where + " must be public"); + return false; + } + if (!creator.isConstructor()) { + if (!creator.isStatic()) { + ctx.error(cls, where + " must be a constructor or a static factory method"); + return false; + } + if (!creator.getDescriptor().endsWith(")L" + cls.getInternalName() + ";")) { + ctx.error(cls, where + " must return " + mc.binaryName); + return false; + } + } + List params = parameterDescriptors(creator.getDescriptor()); + List> pa = creator.getParameterAnnotations(); + for (int i = 0; i < params.size(); i++) { + Map anns = (pa != null && i < pa.size()) ? pa.get(i) : null; + AnnotationValues jp = anns == null ? null : anns.get(JSON_PROPERTY_DESC); + String name = jp == null ? null : jp.getString("value"); + if (name == null || name.length() == 0) { + ctx.error(cls, where + ": parameter " + i + " must carry @JsonProperty" + + " naming the JSON member it receives"); + return false; + } + MappedField match = null; + for (MappedField f : mc.fields) { + if (f.includeInJson && name.equals(f.jsonName)) { + match = f; + break; + } + } + if (match == null) { + ctx.error(cls, where + ": parameter " + i + " names JSON member \"" + name + + "\", which no JSON field of " + mc.binaryName + " declares"); + return false; + } + if (match.viaLocal) { + ctx.error(cls, where + ": JSON member \"" + name + "\" is taken by two parameters"); + return false; + } + if (!params.get(i).equals(match.descriptor)) { + ctx.error(cls, where + ": parameter " + i + " (\"" + name + "\") is " + + params.get(i) + " but field " + match.name + " is " + match.descriptor); + return false; + } + if (jp.getBoolOrDefault("required", false)) match.required = true; + if (match.aliases.isEmpty()) match.aliases = stringList(jp.get("aliases")); + match.viaLocal = true; + mc.creatorParams.add(match.name); + } + mc.creatorCall = creator.isConstructor() + ? "new " + mc.binaryName + : mc.binaryName + "." + creator.getName(); + return true; + } + + private static List parameterDescriptors(String desc) { + List out = new ArrayList(); + int i = 1; + while (desc.charAt(i) != ')') { + int start = i; + while (desc.charAt(i) == '[') i++; + if (desc.charAt(i) == 'L') { + i = desc.indexOf(';', i) + 1; + } else { + i++; + } + out.add(desc.substring(start, i)); + } + return out; + } + + /// The adapter class a `@JsonAdapter` names, or null. + private static String adapterOf(AnnotationValues av) { + if (av == null) return null; + Object v = av.get("value"); + if (v instanceof org.objectweb.asm.Type) { + return ((org.objectweb.asm.Type) v).getClassName(); + } + return v == null ? null : v.toString(); + } + + /// The class-level `@JsonAdapter` of `binaryName`, or null. + private static String classAdapterOf(String binaryName, ProcessorContext ctx) { + if (binaryName == null || isScalarBinary(binaryName)) return null; + AnnotatedClass c = ctx.lookupWithDependencies(binaryName.replace('.', '/')); + return c == null ? null : adapterOf(c.getClassAnnotation(JSON_ADAPTER_DESC)); + } + + /// The generated mapper holds one instance per adapter class and calls it + /// directly, so the class must be instantiable from generated code. + private static boolean validAdapter(AnnotatedClass cls, String owner, String field, + String adapter, ProcessorContext ctx) { + String where = field == null ? owner : owner + "." + field; + AnnotatedClass a = ctx.lookupWithDependencies(adapter.replace('.', '/')); + if (a == null) { + ctx.error(cls, "@JsonAdapter(" + adapter + ") on " + where + + ": the adapter class cannot be found on the compile classpath"); + return false; + } + if (!a.isPublic() || a.isAbstract() || a.isInterface() || !hasPublicNoArgConstructor(a)) { + ctx.error(cls, "@JsonAdapter(" + adapter + ") on " + where + + ": the adapter must be a public concrete class with a public no-arg constructor"); + return false; + } + return true; + } + + private static String enumName(Object v) { + if (v instanceof String[] && ((String[]) v).length == 2) return ((String[]) v)[1]; + return null; + } + + private static List stringList(Object v) { + List out = new ArrayList(); + if (v instanceof List) { + for (Object o : (List) v) { + if (o != null && o.toString().length() > 0) out.add(o.toString()); + } + } + return out; + } + + /// Classifies an element descriptor (`I`, `Ljava/lang/String;`, ...), + /// upgrading an enum the way field classification does. Anything that is + /// not a scalar, an enum, a date or a class reference is unsupported. + private static PropertyTypeKind kindForDescriptor(String desc, ProcessorContext ctx) { + PropertyTypeKind k = PropertyTypeKind.of(desc, null); + if (k.kind == PropertyTypeKind.Kind.REFERENCE && isEnumType(k.binaryName, ctx)) { + return PropertyTypeKind.enumType(k.binaryName); + } + switch (k.kind) { + case PROPERTY: case LIST_PROPERTY: case LIST: + return PropertyTypeKind.scalar("?"); + default: + return k; + } + } + + private static PropertyTypeKind kindForBinary(String binary, ProcessorContext ctx) { + PropertyTypeKind k = PropertyTypeKind.scalar(binary); + if (k.kind != PropertyTypeKind.Kind.UNSUPPORTED) return k; + if (isEnumType(binary, ctx)) return PropertyTypeKind.enumType(binary); + return PropertyTypeKind.reference(binary); + } + + /// The names of a class's type parameters, read from its generic signature + /// (`Ljava/lang/Object;` gives `[T]`). + static List classTypeParams(String sig) { + List names = new ArrayList(); + if (sig == null || !sig.startsWith("<")) return names; + int i = 1; + while (sig.charAt(i) != '>') { + int colon = sig.indexOf(':', i); + names.add(sig.substring(i, colon)); + i = colon; + while (sig.charAt(i) == ':') { + i++; + if (sig.charAt(i) == ':') continue; + int[] p = { i }; + parseType(sig, p); + i = p[0]; + } + } + return names; + } + + static TypeRef parseTypeSignature(String sig) { + try { + return parseType(sig, new int[] { 0 }); + } catch (RuntimeException e) { + return null; + } + } + + private static TypeRef parseType(String s, int[] p) { + TypeRef t = new TypeRef(); + while (s.charAt(p[0]) == '[') { + t.dims++; + p[0]++; + } + char c = s.charAt(p[0]); + if (c == 'T') { + int e = s.indexOf(';', p[0]); + t.var = s.substring(p[0] + 1, e); + p[0] = e + 1; + return t; + } + if (c == 'L') { + p[0]++; + StringBuilder name = new StringBuilder(); + while (true) { + char ch = s.charAt(p[0]); + if (ch == ';') { + p[0]++; + break; + } + if (ch == '<') { + p[0]++; + t.args.clear(); + while (s.charAt(p[0]) != '>') { + char a = s.charAt(p[0]); + if (a == '*') { + p[0]++; + TypeRef w = new TypeRef(); + w.binary = "java.lang.Object"; + t.args.add(w); + } else { + if (a == '+' || a == '-') p[0]++; + t.args.add(parseType(s, p)); + } + } + p[0]++; + continue; + } + if (ch == '.') { + name.append('$'); + } else { + name.append(ch == '/' ? '.' : ch); + } + p[0]++; + } + t.binary = name.toString(); + return t; + } + p[0]++; + t.binary = String.valueOf(c); + return t; + } + + private static boolean hasVar(TypeRef t) { + if (t.var != null) return true; + for (TypeRef a : t.args) { + if (hasVar(a)) return true; + } + return false; + } + + private static TypeRef subst(TypeRef t, Map sub) { + if (t.var != null) { + TypeRef r = sub.get(t.var); + if (r == null) return t; + TypeRef c = r.copy(); + c.dims += t.dims; + return c; + } + TypeRef c = new TypeRef(); + c.binary = t.binary; + c.dims = t.dims; + for (TypeRef a : t.args) c.args.add(subst(a, sub)); + return c; + } + + /// Resolves every field of a generic `@Mapped` class used with concrete + /// type arguments (`Page page`) into a specialised copy, which the + /// owner's mapper reads and writes with private methods of its own. A use + /// whose arguments are not all concrete keeps the raw behaviour. + private void assignSpecs(MappedClass owner, MappedClass cls, ProcessorContext ctx, int depth) { + for (MappedField f : cls.fields) { + if (!f.includeInJson || f.isArray || f.adapter != null) continue; + if (f.kind.kind != PropertyTypeKind.Kind.REFERENCE || f.typeRef == null + || f.typeRef.args.isEmpty() || f.typeRef.dims != 0 || hasVar(f.typeRef)) continue; + MappedClass gen = accepted.get(f.kind.binaryName); + if (gen == null || gen.classAdapter != null + || gen.typeParams.size() != f.typeRef.args.size()) continue; + if (depth > 4) { + ctx.error(cls.binaryName + "." + f.name + ": generic @Mapped types nest more than" + + " five levels deep"); + return; + } + Map sub = new LinkedHashMap(); + for (int i = 0; i < gen.typeParams.size(); i++) { + sub.put(gen.typeParams.get(i), f.typeRef.args.get(i)); + } + int idx = owner.specs.size(); + MappedClass sp = specialize(gen, sub, idx, cls.binaryName + "." + f.name, ctx); + if (sp == null) return; + f.specIndex = idx; + owner.specs.add(sp); + assignSpecs(owner, sp, ctx, depth + 1); + } + } + + private MappedClass specialize(MappedClass gen, Map sub, int idx, + String use, ProcessorContext ctx) { + MappedClass sp = gen.copyHeader(); + sp.prefix = "s" + idx + "_"; + for (MappedField f : gen.fields) { + MappedField c = f.copy(); + c.specIndex = -1; + c.helper = sp.prefix + c.name; + if (c.typeRef != null && hasVar(c.typeRef) && c.adapter == null && c.format == null) { + TypeRef r = subst(c.typeRef, sub); + if (r.var != null) { + sp.fields.add(c); + continue; + } + String where = gen.binaryName + "." + f.name + " (as used by " + use + ")"; + if (r.dims > 1) { + ctx.error(where + " resolves to a multi-dimensional array"); + return null; + } + TypeRef base = r.copy(); + base.dims = 0; + boolean list = r.dims == 0 && ("java.util.List".equals(base.binary) + || "java.util.ArrayList".equals(base.binary)); + if (r.dims == 1) { + c.isArray = true; + c.kind = kindForBinary(base.binary, ctx); + c.elemAdapter = classAdapterOf(base.binary, ctx); + c.typeRef = null; + } else if (list) { + TypeRef e = base.args.isEmpty() ? null : base.args.get(0); + String eb = (e == null || e.var != null || e.dims != 0) ? "java.lang.String" : e.binary; + c.kind = PropertyTypeKind.list(eb); + c.elementIsEnum = isEnumType(eb, ctx); + c.elemAdapter = c.elementIsEnum ? null : classAdapterOf(eb, ctx); + c.typeRef = null; + } else { + c.kind = kindForBinary(base.binary, ctx); + if (c.kind.kind == PropertyTypeKind.Kind.REFERENCE) { + c.adapter = classAdapterOf(base.binary, ctx); + c.adapterCast = base.binary; + } + c.typeRef = base; + } + if (c.kind.kind == PropertyTypeKind.Kind.UNSUPPORTED + || c.kind.kind == PropertyTypeKind.Kind.BYTE_ARRAY && c.isArray) { + ctx.error(where + " resolves to an unsupported type " + base.binary); + return null; + } + } + sp.fields.add(c); + } + return sp; + } + // --------------------------------------------------------------- // Accumulator types // --------------------------------------------------------------- @@ -1447,6 +2503,64 @@ static final class MappedClass { /// (`new T(_a, _b, ...)` vs `o.a = ...; o.b = ...`). boolean isRecord; final List fields = new ArrayList(); + /// `@JsonStrict`: an undeclared member is refused on read. + boolean strict; + /// Class-level `@JsonAdapter` of this class itself, or null. + String classAdapter; + /// Names of the class's type parameters, in order. + List typeParams = new ArrayList(); + /// Field names the `@JsonCreator` takes, in parameter order. + final List creatorParams = new ArrayList(); + /// `new pkg.Type` or `pkg.Type.factory`, or null without a creator. + String creatorCall; + /// Prefix of the private helpers generated for this class: empty for + /// the mapper's own class, `sN_` for a specialisation. + String prefix = ""; + /// Specialisations of generic classes this mapper's fields use; the + /// index is a field's `specIndex`. + final List specs = new ArrayList(); + + MappedClass copyHeader() { + MappedClass c = new MappedClass(); + c.binaryName = binaryName; + c.packageName = packageName; + c.simpleName = simpleName; + c.mapperBinaryName = mapperBinaryName; + c.mapperSimpleName = mapperSimpleName; + c.xmlRootName = xmlRootName; + c.isRecord = isRecord; + c.strict = strict; + c.classAdapter = classAdapter; + c.typeParams = typeParams; + c.creatorParams.addAll(creatorParams); + c.creatorCall = creatorCall; + return c; + } + + MappedField field(String name) { + for (MappedField f : fields) { + if (f.name.equals(name)) return f; + } + return null; + } + } + + /// A parsed generic type signature: a type variable, or a class with + /// type arguments, either with array dimensions. + static final class TypeRef { + String var; + String binary; + int dims; + final List args = new ArrayList(); + + TypeRef copy() { + TypeRef c = new TypeRef(); + c.var = var; + c.binary = binary; + c.dims = dims; + for (TypeRef a : args) c.args.add(a.copy()); + return c; + } } static final class MappedField { @@ -1475,5 +2589,67 @@ static final class MappedField { /// `true` when this field is a `List` whose element type `E` /// is a Java enum -- elements serialise as their `name()`. boolean elementIsEnum; + /// The field's descriptor (its erased type). + String descriptor; + /// `@JsonProperty(aliases)`: other names accepted on read. + List aliases = new ArrayList(); + /// `@JsonProperty(required = true)`. + boolean required; + /// An explicit JSON null clears the field rather than leaving it as + /// constructed: `@JsonStrict` classes and required members only. + boolean nullClears; + /// `@JsonOmitNull` on the field or its class. + boolean omitNull; + /// `STRING` or `NUMBER` from `@JsonFormat`, or null. + String format; + /// The adapter converting the whole value, or null, and the type its + /// value is cast to before `toJson`. + String adapter; + String adapterCast; + /// The class-level adapter of a list or array element type, or null. + String elemAdapter; + /// A one-dimensional array; `kind` then describes the ELEMENT. + boolean isArray; + /// Parsed generic signature, or null. + TypeRef typeRef; + /// Index into the owner mapper's specialisations, or -1. + int specIndex = -1; + /// Received by the `@JsonCreator`: read into a local, not the instance. + boolean viaLocal; + /// Written, but never set on read (no setter, or a final field, in a + /// class built by a `@JsonCreator` that does not take it). + boolean readOnly; + /// Suffix of this field's private helper methods. + String helper; + + MappedField copy() { + MappedField c = new MappedField(); + c.name = name; + c.kind = kind; + c.jsonName = jsonName; + c.xmlName = xmlName; + c.xmlAttribute = xmlAttribute; + c.includeInJson = includeInJson; + c.includeInXml = includeInXml; + c.useAccessor = useAccessor; + c.getterName = getterName; + c.setterName = setterName; + c.elementIsEnum = elementIsEnum; + c.descriptor = descriptor; + c.aliases = aliases; + c.required = required; + c.omitNull = omitNull; + c.format = format; + c.adapter = adapter; + c.adapterCast = adapterCast; + c.elemAdapter = elemAdapter; + c.isArray = isArray; + c.typeRef = typeRef; + c.specIndex = specIndex; + c.viaLocal = viaLocal; + c.readOnly = readOnly; + c.helper = helper; + return c; + } } } diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/PropertyTypeKind.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/PropertyTypeKind.java index c72aabf0eac..8da23598421 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/PropertyTypeKind.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/PropertyTypeKind.java @@ -71,6 +71,19 @@ public static PropertyTypeKind enumType(String binaryName) { return new PropertyTypeKind(Kind.ENUM, binaryName, null); } + /// A reference to another (presumably mapped) class, for a type the + /// classifier did not see as a field descriptor -- a resolved type + /// argument or an array element. + public static PropertyTypeKind reference(String binaryName) { + return new PropertyTypeKind(Kind.REFERENCE, binaryName, null); + } + + /// A `java.util.List` of `elementBinaryName`, for a list whose element + /// type was resolved from a type argument. + public static PropertyTypeKind list(String elementBinaryName) { + return new PropertyTypeKind(Kind.LIST, "java.util.List", elementBinaryName); + } + public static PropertyTypeKind scalar(String binary) { String[] types={"java.lang.String","java.lang.Integer","java.lang.Long","java.lang.Short","java.lang.Byte","java.lang.Character","java.lang.Double","java.lang.Float","java.lang.Boolean","java.util.Date","byte[]"}; Kind[] kinds={Kind.STRING,Kind.INT,Kind.LONG,Kind.SHORT,Kind.BYTE,Kind.CHAR,Kind.DOUBLE,Kind.FLOAT,Kind.BOOLEAN,Kind.DATE,Kind.BYTE_ARRAY}; @@ -78,7 +91,12 @@ public static PropertyTypeKind scalar(String binary) { return new PropertyTypeKind(Kind.UNSUPPORTED,binary,null); } public static PropertyTypeKind of(FieldInfo field) { - String desc = field.getDescriptor(); + return of(field.getDescriptor(), field.getSignature()); + } + + /// Classifies a type given as a descriptor and optional generic signature, + /// for a type that is not a field of its own -- an array element. + public static PropertyTypeKind of(String desc, String signature) { if (desc == null || desc.length() == 0) { return new PropertyTypeKind(Kind.UNSUPPORTED, "?", null); } @@ -142,7 +160,7 @@ public static PropertyTypeKind of(FieldInfo field) { || "com.codename1.properties.BooleanProperty".equals(binary) || "com.codename1.properties.ByteProperty".equals(binary) || "com.codename1.properties.CharProperty".equals(binary)) { - String elem = firstGenericArg(field.getSignature()); + String elem = firstGenericArg(signature); if (!"com.codename1.properties.Property".equals(binary) || elem == null) { // Typed subclasses carry the owner, not the value, as their generic argument: if ("com.codename1.properties.StringProperty".equals(binary)) elem = "java.lang.String"; @@ -159,12 +177,12 @@ public static PropertyTypeKind of(FieldInfo field) { } if ("com.codename1.properties.ListProperty".equals(binary) || "com.codename1.properties.SetProperty".equals(binary)) { - String elem = firstGenericArg(field.getSignature()); + String elem = firstGenericArg(signature); if (elem == null) elem = "java.lang.String"; return new PropertyTypeKind(Kind.LIST_PROPERTY, binary, elem); } if ("java.util.List".equals(binary) || "java.util.ArrayList".equals(binary)) { - String elem = firstGenericArg(field.getSignature()); + String elem = firstGenericArg(signature); if (elem == null) elem = "java.lang.String"; return new PropertyTypeKind(Kind.LIST, binary, elem); } diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java index c7930472e1c..c57d81835bc 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java @@ -507,13 +507,13 @@ public void anMcpToolTheCodecsCannotServeIsABuildError() throws Exception { s.put("com.example.Shop", PKG + "@Component public class Shop {\n" + " @McpTool(description = \"a\")\n" - + " public String a(@McpParam(\"ids\") int[] ids) { return \"a\"; }\n" + + " public String a(@McpParam(\"ids\") int[][] ids) { return \"a\"; }\n" + " @McpTool(description = \"b\")\n" + " public StringBuilder b() { return null; }\n" + "}\n"); String errors = String.valueOf(process(compile(s)).getErrors()); assertTrue(errors, errors.contains("Parameter 1 of @McpTool")); - assertTrue(errors, errors.contains("an array, which has no JSON form here")); + assertTrue(errors, errors.contains("which has more than one dimension")); assertTrue(errors, errors.contains("returns java.lang.StringBuilder, which cannot be " + "written as JSON")); } @@ -534,6 +534,168 @@ public void twoClassesFoldingToOneCodecNameFailTheBuild() throws Exception { + "com.example.Outer_InnerCn1Json. Rename one of them.")); } + /// The JSON contract options of #5995 on one running server: renamed and + /// aliased members, required ones, absent versus explicit null, a strict + /// class, members left out when null, string-shaped numbers and dates, an + /// adapter on a field and on a class, an immutable class read through its + /// @JsonCreator, a generic class used with its type argument, and arrays. + @Test + public void jsonContractOptionsHoldEndToEnd() throws Exception { + String a = "@com.codename1.annotations."; + Map s = new LinkedHashMap(); + s.put("com.example.Money", PKG + a + "JsonAdapter(MoneyAdapter.class)\n" + + "public final class Money {\n" + + " public final long cents;\n" + + " public Money(long cents) { this.cents = cents; }\n" + + "}\n"); + s.put("com.example.MoneyAdapter", PKG + "public class MoneyAdapter implements " + + "com.codename1.annotations.JsonTypeAdapter {\n" + + " public Object toJson(Money m) {\n" + + " return m == null ? null : (m.cents / 100) + \".\" + (m.cents % 100 < 10 ? \"0\" : \"\") + (m.cents % 100);\n" + + " }\n" + + " public Money fromJson(Object j) {\n" + + " if (!(j instanceof String)) { throw new IllegalArgumentException(\"expected an amount\"); }\n" + + " String t = (String) j;\n" + + " int dot = t.indexOf('.');\n" + + " return new Money(Long.parseLong(t.substring(0, dot)) * 100 + Long.parseLong(t.substring(dot + 1)));\n" + + " }\n" + + "}\n"); + s.put("com.example.Upper", PKG + "public class Upper implements " + + "com.codename1.annotations.JsonTypeAdapter {\n" + + " public Object toJson(String v) { return v == null ? null : \"<\" + v + \">\"; }\n" + + " public String fromJson(Object j) { return j == null ? null : String.valueOf(j) + \"!\"; }\n" + + "}\n"); + s.put("com.example.Point", PKG + a + "JsonStrict\n" + + "public final class Point {\n" + + " private final int x;\n" + + " private final int y;\n" + + " " + a + "JsonCreator\n" + + " public Point(" + a + "JsonProperty(value = \"x\", required = true) int x,\n" + + " " + a + "JsonProperty(value = \"y\", aliases = \"why\") int y) {\n" + + " this.x = x; this.y = y;\n" + + " }\n" + + " public int getX() { return x; }\n" + + " public int getY() { return y; }\n" + + "}\n"); + s.put("com.example.Page", PKG + "public class Page {\n" + + " public List items;\n" + + " public T first;\n" + + " public T[] none;\n" + + "}\n"); + s.put("com.example.Order", PKG + a + "JsonOmitNull\n" + + "public class Order {\n" + + " " + a + "JsonProperty(value = \"order_id\", aliases = {\"orderId\", \"id\"}, required = true)\n" + + " public String id;\n" + + " public String note = \"unset\";\n" + + " " + a + "JsonFormat(com.codename1.annotations.JsonFormat.Shape.STRING)\n" + + " public long big;\n" + + " " + a + "JsonFormat(com.codename1.annotations.JsonFormat.Shape.STRING)\n" + + " public Date at;\n" + + " public Money total;\n" + + " " + a + "JsonAdapter(Upper.class) public String shout;\n" + + " public int[] counts;\n" + + " public Point[] path;\n" + + " public Page page;\n" + + "}\n"); + s.put("com.example.Api", PKG + "@RestController public class Api {\n" + + " @PostMapping(\"/order\") public Order order(@RequestBody Order o) { return o; }\n" + + " @PostMapping(\"/note\") public String note(@RequestBody Order o) {\n" + + " return String.valueOf(o.note);\n" + + " }\n" + + " @PostMapping(\"/point\") public Point point(@RequestBody Point p) { return p; }\n" + + "}\n"); + File classes = compile(s); + ProcessorContext ctx = process(classes); + assertNoErrors(ctx); + int port = freePort(); + Backend backend = start(classes, port, new Properties()); + try { + String order = post(port, "/order", "{\"orderId\":\"o1\",\"big\":\"9007199254740993\"," + + "\"at\":86400000,\"total\":\"12.05\",\"shout\":\"hey\",\"counts\":[1,2]," + + "\"path\":[{\"x\":1,\"why\":2}],\"page\":{\"items\":[{\"x\":3,\"y\":4}]," + + "\"first\":{\"x\":5}},\"extra\":true}"); + assertEquals("{\"order_id\":\"o1\",\"note\":\"unset\",\"big\":\"9007199254740993\"," + + "\"at\":\"1970-01-02T00:00:00.000Z\",\"total\":\"12.05\",\"shout\":\"\"," + + "\"counts\":[1,2],\"path\":[{\"x\":1,\"y\":2}],\"page\":{\"items\":" + + "[{\"x\":3,\"y\":4}],\"first\":{\"x\":5,\"y\":0},\"none\":null}}", order); + // Absent leaves the field as constructed; an explicit null sets it. + assertEquals("unset", post(port, "/note", "{\"id\":\"a\"}")); + assertEquals("null", post(port, "/note", "{\"id\":\"a\",\"note\":null}")); + // Required: missing under every name is refused; explicit null is a value. + assertEquals("HTTP 400: $.order_id: required member is missing", + post(port, "/note", "{\"note\":\"n\"}")); + assertEquals("null", post(port, "/note", "{\"order_id\":null,\"note\":null}")); + // A value read from an alias is reported under the alias it came in. + String alias = post(port, "/note", "{\"orderId\":5}"); + assertTrue(alias, alias.startsWith("HTTP 400: $.orderId: ")); + assertEquals("HTTP 400: $.x: required member is missing", + post(port, "/point", "{\"y\":1}")); + assertEquals("HTTP 400: $.x: expected a value, got null", + post(port, "/point", "{\"x\":null}")); + // Strict refuses an unknown member, by path, nested too. + assertEquals("HTTP 400: $.z: unknown member", + post(port, "/point", "{\"x\":1,\"z\":0}")); + assertEquals("HTTP 400: $.path[0].colour: unknown member", + post(port, "/order", "{\"id\":\"a\",\"path\":[{\"x\":1,\"colour\":2}]}")); + // An adapter's refusal is located; a numeric string must be a number. + assertEquals("HTTP 400: $.total: expected an amount", + post(port, "/order", "{\"id\":\"a\",\"total\":5}")); + assertTrue(post(port, "/order", "{\"id\":\"a\",\"big\":\"lots\"}") + .startsWith("HTTP 400: $.big: expected a whole number")); + assertEquals("HTTP 400: $.counts[1]: expected a whole number from -2147483648 to " + + "2147483647, got a string", + post(port, "/order", "{\"id\":\"a\",\"counts\":[1,\"2\"]}")); + } finally { + backend.stop(); + } + } + + @Test + public void unsupportedJsonShapesFailTheBuildByName() throws Exception { + String format = "@com.codename1.annotations.JsonFormat(" + + "com.codename1.annotations.JsonFormat.Shape.STRING)"; + assertJsonBuildError("public class Shape { public int[][] cells; }", + "the array int[][], which has more than one dimension"); + assertJsonBuildError("public class Shape { " + format + " public boolean flag; }", + "is marked @JsonFormat(STRING), which a boolean has no form of"); + assertJsonBuildError("public class Shape { public T value; }", + "has a type variable for its type (com.example.Shape)"); + assertJsonBuildError("public class Shape { @com.codename1.annotations.JsonAdapter(" + + "Conv.class) public String s; }\n" + + "class Conv implements com.codename1.annotations.JsonTypeAdapter {\n" + + " public Object toJson(String v) { return v; }\n" + + " public String fromJson(Object j) { return null; }\n" + + "}", "which the generated code cannot create"); + } + + /// Builds a route returning `Shape`, declared by `source`, and expects `error`. + @Test + public void aGenericCodecNameThatFoldsOntoAClassIsABuildError() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Page", PKG + "public class Page { public T item; }\n"); + s.put("com.example.User", PKG + "public class User { public String n; }\n"); + // Page and this class both mangle to one codec name. + s.put("com.example.Page_com_example_User", PKG + + "public class Page_com_example_User { public String n; }\n"); + s.put("com.example.Api", PKG + "@RestController public class Api {\n" + + " @GetMapping(\"/p\") public Page p() { return null; }\n" + + " @GetMapping(\"/q\") public Page_com_example_User q() { return null; }\n" + + "}\n"); + String errors = String.valueOf(process(compile(s)).getErrors()); + assertTrue(errors, errors.contains("would both get the JSON codec " + + "com.example.Page_com_example_UserCn1Json")); + } + + private void assertJsonBuildError(String source, String error) throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Shape", PKG + source + "\n"); + s.put("com.example.Api", PKG + "@RestController public class Api {\n" + + " @GetMapping(\"/s\") public Shape s() { return null; }\n" + + "}\n"); + String errors = String.valueOf(process(compile(s)).getErrors()); + assertTrue(errors, errors.contains(error)); + } + @Test public void aProjectClassWithAGeneratedNameFailsTheBuild() throws Exception { Map s = new LinkedHashMap(); @@ -682,7 +844,7 @@ public void aClassNoCodecCanBeWrittenForIsABuildError() throws Exception { + "}\n"); s.put("com.example.ArrayApi", PKG + "@RestController public class ArrayApi {\n" - + " @GetMapping(\"/a\") public int[] a() { return null; }\n" + + " @GetMapping(\"/a\") public int[][] a() { return null; }\n" + "}\n"); s.put("com.example.Unordered", PKG + "public class Unordered { public String n; }\n"); s.put("com.example.SetApi", PKG @@ -695,10 +857,11 @@ public void aClassNoCodecCanBeWrittenForIsABuildError() throws Exception { + " }\n" + "}\n"); String errors = String.valueOf(process(compile(s)).getErrors()); - assertTrue(errors, errors.contains("has a type variable for its type")); + // A parameterized DTO gets a codec of its own per type argument. + assertFalse("Page is a typed generic DTO: " + errors, + errors.contains("com.example.PageApi")); assertTrue(errors, errors.contains("has no constructor without arguments")); - assertTrue(errors, errors.contains("an array, which has no JSON form here other than " - + "byte[]")); + assertTrue(errors, errors.contains("which has more than one dimension")); assertTrue(errors, errors.contains("a TreeSet of com.example.Unordered, which is not " + "Comparable")); assertFalse("a TreeSet of String is ordered by String: " + errors, diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java index 06460819b80..8c9608c45b9 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java @@ -624,4 +624,397 @@ private static File testClassesDir() throws Exception { .getCodeSource().getLocation(); return new File(url.toURI()); } + + // --------------------------------------------------------------- + // JSON options shared with the backend codecs (#5995) + // --------------------------------------------------------------- + + @Test + public void namesAliasesRequiredStrictAndOmitNull() throws Exception { + Map src = new LinkedHashMap(); + src.put("jx.names.Task", "package jx.names;\n" + + "import com.codename1.annotations.*;\n" + + "@Mapped @JsonStrict @JsonOmitNull public class Task {\n" + + " @JsonProperty(value = \"title\", aliases = {\"name\", \"label\"}) public String t;\n" + + " @JsonProperty(aliases = {\"old_due\"}) public String due;\n" + + " @JsonProperty(required = true) public String id;\n" + + " public Integer count;\n" + + " public int n;\n" + + " @JsonProperty(aliases = {\"when\"}) public java.util.Date at;\n" + + " public Task() {}\n" + + "}\n"); + src.put("jx.names.Loose", "package jx.names;\n" + + "@com.codename1.annotations.Mapped public class Loose {\n" + + " public String a;\n" + + " public Loose() {}\n" + + "}\n"); + src.put("jx.names.Box", "package jx.names;\n" + + "@com.codename1.annotations.Mapped public class Box {\n" + + " public java.util.List items;\n" + + " public Box() {}\n" + + "}\n"); + try (URLClassLoader cl = buildAndRegister(src, "jx.names.Task", "jx.names.Loose", "jx.names.Box")) { + Class task = cl.loadClass("jx.names.Task"); + Object t = fromJson(cl, "{\"name\":\"x\",\"old_due\":\"d\",\"id\":null}", task); + assertEquals("x", task.getField("t").get(t)); + assertEquals("d", task.getField("due").get(t)); + assertNull("explicit null satisfies required", task.getField("id").get(t)); + t = fromJson(cl, "{\"label\":\"l\",\"title\":\"a\",\"name\":\"b\",\"id\":\"1\"}", task); + assertEquals("the primary name wins over aliases", "a", task.getField("t").get(t)); + + assertEquals("$.id: required member is missing", failure(cl, "{\"title\":\"a\"}", task)); + assertEquals("$.zzz: unknown member", failure(cl, "{\"id\":\"1\",\"zzz\":1}", task)); + // A refused value is reported under the alias it came in, and a + // date is checked against its month and the clock. + for (String bad : new String[] {"2026-02-30", "2025-02-29", "2026-04-31", + "2026-10-25T24:30:00Z", "2026-10-25T10:00:60Z", "2026-10-25T10:00+99:00"}) { + String why = failure(cl, "{\"id\":\"1\",\"when\":\"" + bad + "\"}", task); + assertTrue(bad + " -> " + why, why.startsWith("$.when: ")); + } + t = fromJson(cl, "{\"id\":\"1\",\"when\":\"2024-02-29T23:59:59Z\"}", task); + assertEquals(1709251199000L, ((java.util.Date) task.getField("at").get(t)).getTime()); + Object loose = fromJson(cl, "{\"a\":\"v\",\"zzz\":1}", cl.loadClass("jx.names.Loose")); + assertEquals("v", loose.getClass().getField("a").get(loose)); + assertEquals("$.items[2].id: required member is missing", + failure(cl, "{\"items\":[{\"id\":\"1\"},{\"id\":\"2\"},{}]}", cl.loadClass("jx.names.Box"))); + + Object w = task.newInstance(); + task.getField("id").set(w, "i"); + assertEquals("{\"id\":\"i\",\"n\":0}", toJson(cl, w)); + task.getField("t").set(w, "x"); + task.getField("count").set(w, Integer.valueOf(3)); + assertEquals("{\"title\":\"x\",\"id\":\"i\",\"count\":3,\"n\":0}", toJson(cl, w)); + assertDirectMatchesMap(cl, cl.loadClass("jx.names.TaskCn1Mapper"), + cl.loadClass("jx.names.TaskCn1Mapper").newInstance(), w); + } + } + + @Test + public void jsonFormatWritesStringsAndReadsBothShapes() throws Exception { + Map src = new LinkedHashMap(); + src.put("jx.fmt.Ev", "package jx.fmt;\n" + + "import com.codename1.annotations.*;\n" + + "import java.util.Date;\n" + + "@Mapped public class Ev {\n" + + " @JsonFormat(JsonFormat.Shape.STRING) public Date at;\n" + + " public Date plain;\n" + + " @JsonFormat(JsonFormat.Shape.NUMBER) public Date num;\n" + + " @JsonFormat(JsonFormat.Shape.STRING) public long big;\n" + + " @JsonFormat(JsonFormat.Shape.STRING) public Double ratio;\n" + + " public Ev() {}\n" + + "}\n"); + try (URLClassLoader cl = buildAndRegister(src, "jx.fmt.Ev")) { + Class ev = cl.loadClass("jx.fmt.Ev"); + Object e = ev.newInstance(); + ev.getField("at").set(e, new java.util.Date(1700000000123L)); + ev.getField("num").set(e, new java.util.Date(5L)); + ev.getField("big").setLong(e, 12345678901234L); + ev.getField("ratio").set(e, Double.valueOf(1.5)); + String json = toJson(cl, e); + assertEquals("{\"at\":\"2023-11-14T22:13:20.123Z\",\"plain\":null,\"num\":5," + + "\"big\":\"12345678901234\",\"ratio\":\"1.5\"}", json); + assertDirectMatchesMap(cl, cl.loadClass("jx.fmt.EvCn1Mapper"), + cl.loadClass("jx.fmt.EvCn1Mapper").newInstance(), e); + + Object back = fromJson(cl, json, ev); + assertEquals(new java.util.Date(1700000000123L), ev.getField("at").get(back)); + assertEquals(12345678901234L, ev.getField("big").getLong(back)); + assertEquals(Double.valueOf(1.5), ev.getField("ratio").get(back)); + + back = fromJson(cl, "{\"at\":1700000000123,\"plain\":\"2023-11-14\"," + + "\"num\":\"2023-11-15T00:13:20.123+02:00\",\"big\":7,\"ratio\":2}", ev); + assertEquals(new java.util.Date(1700000000123L), ev.getField("at").get(back)); + assertEquals(new java.util.Date(1699920000000L), ev.getField("plain").get(back)); + assertEquals(new java.util.Date(1700000000123L), ev.getField("num").get(back)); + assertEquals(7L, ev.getField("big").getLong(back)); + assertEquals(Double.valueOf(2), ev.getField("ratio").get(back)); + assertTrue(failure(cl, "{\"at\":\"yesterday\"}", ev).startsWith("$.at: ")); + } + } + + @Test + public void adaptersOnFieldAndOnClass() throws Exception { + Map src = new LinkedHashMap(); + src.put("jx.ad.Money", "package jx.ad;\n" + + "public class Money { public final long cents; public Money(long c) { cents = c; } }\n"); + src.put("jx.ad.MoneyAdapter", "package jx.ad;\n" + + "public class MoneyAdapter implements com.codename1.annotations.JsonTypeAdapter {\n" + + " public Object toJson(Money m) { return \"$\" + m.cents; }\n" + + " public Money fromJson(Object j) {\n" + + " if (!(j instanceof String) || !((String) j).startsWith(\"$\")) throw new IllegalArgumentException(\"bad money\");\n" + + " return new Money(Long.parseLong(((String) j).substring(1)));\n" + + " }\n" + + "}\n"); + src.put("jx.ad.Point", "package jx.ad;\n" + + "@com.codename1.annotations.JsonAdapter(PointAdapter.class)\n" + + "public class Point { public int x, y; public Point(int x, int y) { this.x = x; this.y = y; } }\n"); + src.put("jx.ad.PointAdapter", "package jx.ad;\n" + + "import java.util.*;\n" + + "public class PointAdapter implements com.codename1.annotations.JsonTypeAdapter {\n" + + " public Object toJson(Point p) { return Arrays.asList(Integer.valueOf(p.x), Integer.valueOf(p.y)); }\n" + + " public Point fromJson(Object j) {\n" + + " List l = (List) j;\n" + + " return new Point(((Number) l.get(0)).intValue(), ((Number) l.get(1)).intValue());\n" + + " }\n" + + "}\n"); + src.put("jx.ad.Order", "package jx.ad;\n" + + "import com.codename1.annotations.*;\n" + + "@Mapped public class Order {\n" + + " @JsonAdapter(MoneyAdapter.class) public Money total;\n" + + " public Point at;\n" + + " public java.util.List path;\n" + + " public Point[] marks;\n" + + " public Order() {}\n" + + "}\n"); + try (URLClassLoader cl = buildAndRegister(src, "jx.ad.Order")) { + Class order = cl.loadClass("jx.ad.Order"); + String json = "{\"total\":\"$150\",\"at\":[1,2],\"path\":[[3,4]],\"marks\":[[5,6]]}"; + Object o = fromJson(cl, json, order); + Object total = order.getField("total").get(o); + assertEquals(150L, total.getClass().getField("cents").getLong(total)); + Object at = order.getField("at").get(o); + assertEquals(2, at.getClass().getField("y").getInt(at)); + assertEquals(json, toJson(cl, o)); + assertDirectMatchesMap(cl, cl.loadClass("jx.ad.OrderCn1Mapper"), + cl.loadClass("jx.ad.OrderCn1Mapper").newInstance(), o); + assertEquals("$.total: bad money", failure(cl, "{\"total\":\"150\"}", order)); + // The adapter decides what an explicit null is, as on the server. + assertEquals("$.total: bad money", failure(cl, "{\"total\":null}", order)); + } + } + + @Test + public void jsonCreatorBuildsImmutableClasses() throws Exception { + Map src = new LinkedHashMap(); + src.put("jx.cr.Imm", "package jx.cr;\n" + + "import com.codename1.annotations.*;\n" + + "@Mapped public class Imm {\n" + + " private final String name;\n" + + " private final int age;\n" + + " public final String tag;\n" + + " @JsonCreator public Imm(@JsonProperty(\"name\") String name,\n" + + " @JsonProperty(value = \"age\", required = true) int age) {\n" + + " this.name = name; this.age = age; this.tag = \"t\" + age;\n" + + " }\n" + + " public String getName() { return name; }\n" + + " public int getAge() { return age; }\n" + + "}\n"); + src.put("jx.cr.Code", "package jx.cr;\n" + + "import com.codename1.annotations.*;\n" + + "@Mapped public class Code {\n" + + " public final String v;\n" + + " private Code(String v) { this.v = v; }\n" + + " @JsonCreator public static Code of(@JsonProperty(\"v\") String v) { return new Code(v == null ? \"none\" : v); }\n" + + "}\n"); + try (URLClassLoader cl = buildAndRegister(src, "jx.cr.Imm", "jx.cr.Code")) { + Class imm = cl.loadClass("jx.cr.Imm"); + Object o = fromJson(cl, "{\"name\":\"b\",\"age\":4,\"tag\":\"ignored\"}", imm); + assertEquals("b", imm.getMethod("getName").invoke(o)); + assertEquals(4, imm.getMethod("getAge").invoke(o)); + assertEquals("t4", imm.getField("tag").get(o)); + assertEquals("{\"name\":\"b\",\"age\":4,\"tag\":\"t4\"}", toJson(cl, o)); + assertEquals("$.age: required member is missing", failure(cl, "{\"name\":\"b\"}", imm)); + o = fromJson(cl, "{\"age\":1}", imm); + assertNull(imm.getMethod("getName").invoke(o)); + Class code = cl.loadClass("jx.cr.Code"); + Object c = fromJson(cl, "{}", code); + assertEquals("none", code.getField("v").get(c)); + } + } + + @Test + public void genericFieldResolvesItsTypeArguments() throws Exception { + Map src = new LinkedHashMap(); + src.put("jx.gen.Page", "package jx.gen;\n" + + "@com.codename1.annotations.Mapped public class Page {\n" + + " public java.util.List items;\n" + + " public T first;\n" + + " public T[] more;\n" + + " public int total;\n" + + " public Page() {}\n" + + "}\n"); + src.put("jx.gen.User", "package jx.gen;\n" + + "@com.codename1.annotations.Mapped public class User { public String name; public User() {} }\n"); + src.put("jx.gen.Holder", "package jx.gen;\n" + + "@com.codename1.annotations.Mapped public class Holder {\n" + + " public Page page;\n" + + " public Holder() {}\n" + + "}\n"); + try (URLClassLoader cl = buildAndRegister(src, "jx.gen.Page", "jx.gen.User", "jx.gen.Holder")) { + Class holder = cl.loadClass("jx.gen.Holder"); + Class user = cl.loadClass("jx.gen.User"); + String json = "{\"page\":{\"items\":[{\"name\":\"a\"},{\"name\":\"b\"}],\"first\":{\"name\":\"c\"}," + + "\"more\":[{\"name\":\"d\"}],\"total\":2}}"; + Object h = fromJson(cl, json, holder); + Object page = holder.getField("page").get(h); + List items = (List) page.getClass().getField("items").get(page); + assertTrue(user.isInstance(items.get(1))); + assertEquals("b", user.getField("name").get(items.get(1))); + assertTrue(user.isInstance(page.getClass().getField("first").get(page))); + Object[] more = (Object[]) page.getClass().getField("more").get(page); + assertTrue(user.isInstance(more[0])); + assertEquals(json, toJson(cl, h)); + } + } + + @Test + public void arraysMapToJsonArrays() throws Exception { + Map src = new LinkedHashMap(); + src.put("jx.arr.Color", "package jx.arr;\npublic enum Color { RED, GREEN }\n"); + src.put("jx.arr.Dto", "package jx.arr;\n" + + "@com.codename1.annotations.Mapped public class Dto { public String name; public Dto() {} }\n"); + src.put("jx.arr.Arr", "package jx.arr;\n" + + "@com.codename1.annotations.Mapped public class Arr {\n" + + " public int[] nums;\n" + + " public boolean[] flags;\n" + + " public String[] tags;\n" + + " public Dto[] dtos;\n" + + " public Color[] colors;\n" + + " public java.util.Date[] when;\n" + + " public byte[] raw;\n" + + " public Arr() {}\n" + + "}\n"); + try (URLClassLoader cl = buildAndRegister(src, "jx.arr.Dto", "jx.arr.Arr")) { + Class arr = cl.loadClass("jx.arr.Arr"); + String json = "{\"nums\":[1,2],\"flags\":[true,false],\"tags\":[\"a\",null]," + + "\"dtos\":[{\"name\":\"u\"},null],\"colors\":[\"RED\"],\"when\":[5],\"raw\":\"AQI=\"}"; + Object a = fromJson(cl, json, arr); + assertTrue(Arrays.equals(new int[] {1, 2}, (int[]) arr.getField("nums").get(a))); + Object[] dtos = (Object[]) arr.getField("dtos").get(a); + assertEquals("jx.arr.Dto", dtos.getClass().getComponentType().getName()); + assertEquals("u", dtos[0].getClass().getField("name").get(dtos[0])); + assertEquals(json, toJson(cl, a)); + assertDirectMatchesMap(cl, cl.loadClass("jx.arr.ArrCn1Mapper"), + cl.loadClass("jx.arr.ArrCn1Mapper").newInstance(), a); + assertTrue(failure(cl, "{\"nums\":[1,{}]}", arr).startsWith("$.nums[1]: expected a number")); + // Refused, not truncated or dropped, as the server refuses them. + assertTrue(failure(cl, "{\"nums\":[1.5]}", arr).startsWith("$.nums[0]: expected a whole number")); + assertTrue(failure(cl, "{\"nums\":[3000000000]}", arr).startsWith("$.nums[0]: expected a whole number")); + assertTrue(failure(cl, "{\"dtos\":[1]}", arr).startsWith("$.dtos[0]: expected a JSON object")); + } + } + + @Test + public void unsupportedJsonShapesAreBuildErrors() throws Exception { + assertBuildError("grid", "jx.err1.Grid", "package jx.err1;\n" + + "@com.codename1.annotations.Mapped public class Grid { public int[][] grid; public Grid() {} }\n"); + assertBuildError("jx.err2.Fmt.name", "jx.err2.Fmt", "package jx.err2;\n" + + "import com.codename1.annotations.*;\n" + + "@Mapped public class Fmt { @JsonFormat(JsonFormat.Shape.STRING) public String name; public Fmt() {} }\n"); + assertBuildError("jx.err3.Two", "jx.err3.Two", "package jx.err3;\n" + + "import com.codename1.annotations.*;\n" + + "@Mapped public class Two {\n" + + " public String a;\n" + + " @JsonCreator public Two(@JsonProperty(\"a\") String a) { this.a = a; }\n" + + " @JsonCreator public static Two of(@JsonProperty(\"a\") String a) { return new Two(a); }\n" + + "}\n"); + assertBuildError("jx.err4.NoName", "jx.err4.NoName", "package jx.err4;\n" + + "import com.codename1.annotations.*;\n" + + "@Mapped public class NoName {\n" + + " public final String a;\n" + + " @JsonCreator public NoName(String a) { this.a = a; }\n" + + "}\n"); + Map src = new LinkedHashMap(); + src.put("jx.err5.Bad", "package jx.err5;\n" + + "public class Bad implements com.codename1.annotations.JsonTypeAdapter {\n" + + " public Bad(int x) {}\n" + + " public Object toJson(String s) { return s; }\n" + + " public String fromJson(Object o) { return String.valueOf(o); }\n" + + "}\n"); + src.put("jx.err5.Holder", "package jx.err5;\n" + + "import com.codename1.annotations.*;\n" + + "@Mapped public class Holder { @JsonAdapter(Bad.class) public String s; public Holder() {} }\n"); + assertBuildError("jx.err5.Holder.s", src); + } + + @Test + public void explicitNullAndAbsentMemberFollowTheBackend() throws Exception { + Map src = new LinkedHashMap(); + src.put("jx.nul.N", "package jx.nul;\n" + + "import com.codename1.annotations.*;\n" + + "@Mapped public class N {\n" + + " public String note = \"unset\";\n" + + " public int plain = 7;\n" + + " @JsonProperty(required = true) public int must;\n" + + " public java.util.List nums;\n" + + " public Integer[] boxes;\n" + + " public N() {}\n" + + "}\n"); + src.put("jx.nul.S", "package jx.nul;\n" + + "@com.codename1.annotations.Mapped @com.codename1.annotations.JsonStrict\n" + + "public class S { public String note = \"unset\"; public S() {} }\n"); + try (URLClassLoader cl = buildAndRegister(src, "jx.nul.N", "jx.nul.S")) { + Class n = cl.loadClass("jx.nul.N"); + Object o = fromJson(cl, "{\"must\":1}", n); + assertEquals("absent keeps the initializer", "unset", n.getField("note").get(o)); + o = fromJson(cl, "{\"must\":1,\"note\":null,\"plain\":null," + + "\"nums\":[1,null],\"boxes\":[2,null]}", n); + // Before these options the parser dropped a null member, so a class + // that opts into none of them keeps its value exactly as it did. + assertEquals("explicit null keeps the value by default", "unset", n.getField("note").get(o)); + Class strict = cl.loadClass("jx.nul.S"); + Object so = fromJson(cl, "{\"note\":null}", strict); + assertNull("explicit null clears a reference in a @JsonStrict class", + strict.getField("note").get(so)); + assertEquals("a primitive ignores null", 7, n.getField("plain").getInt(o)); + assertEquals(Arrays.asList(Integer.valueOf(1), null), n.getField("nums").get(o)); + assertTrue(Arrays.equals(new Integer[] {2, null}, (Integer[]) n.getField("boxes").get(o))); + assertEquals("$.must: expected a value, got null", failure(cl, "{\"must\":null}", n)); + } + } + + // --------------------------------------------------------------- + // Helpers for the JSON option tests + // --------------------------------------------------------------- + + private URLClassLoader buildAndRegister(Map sources, String... mapped) throws Exception { + File classes = tmp.newFolder(); + JavaSourceCompiler.compile(sources, classes, Arrays.asList(testClassesDir())); + runProcessorOrFail(classes); + URLClassLoader cl = childLoader(classes); + for (String m : mapped) { + cl.loadClass(m + "Cn1Mapper").getMethod("register").invoke(null); + } + return cl; + } + + private void assertBuildError(String expected, String fqn, String source) throws Exception { + assertBuildError(expected, JavaSourceCompiler.singleSource(fqn, source)); + } + + private void assertBuildError(String expected, Map sources) throws Exception { + File classes = tmp.newFolder(); + JavaSourceCompiler.compile(sources, classes, Arrays.asList(testClassesDir())); + ProcessorContext ctx = runProcessor(classes); + assertTrue("expected a build error naming " + expected, ctx.hasErrors()); + StringBuilder all = new StringBuilder(); + for (ProcessorContext.ProcessingError e : ctx.getErrors()) all.append(e).append('\n'); + assertTrue("error should name " + expected + ": " + all, all.indexOf(expected) >= 0); + } + + private static Object fromJson(ClassLoader cl, String json, Class type) throws Exception { + try { + return cl.loadClass("com.codename1.mapping.Mappers") + .getMethod("fromJson", String.class, Class.class).invoke(null, json, type); + } catch (java.lang.reflect.InvocationTargetException e) { + if (e.getCause() instanceof Exception) throw (Exception) e.getCause(); + throw e; + } + } + + private static String toJson(ClassLoader cl, Object o) throws Exception { + return (String) cl.loadClass("com.codename1.mapping.Mappers") + .getMethod("toJson", Object.class).invoke(null, o); + } + + /// The message of the IllegalArgumentException reading `json` must fail with. + private static String failure(ClassLoader cl, String json, Class type) throws Exception { + try { + fromJson(cl, json, type); + } catch (IllegalArgumentException e) { + return e.getMessage(); + } + fail("expected " + json + " to be refused"); + return null; + } } diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/RestControllerAnnotationProcessorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/RestControllerAnnotationProcessorTest.java index e9d3055f604..152cb7bea1f 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/processors/RestControllerAnnotationProcessorTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/RestControllerAnnotationProcessorTest.java @@ -1193,17 +1193,16 @@ public void anInformationalStatusIsRefused() throws Exception { } @Test - public void anArrayReturnOtherThanBytesIsRefused() throws Exception { - // Json writes byte[] as base64 and has no handling for any other array, - // so this would be answered as the JSON string "[I@1a2b3c" while the - // build and the request both reported success. + public void aMultiDimensionalArrayReturnIsRefused() throws Exception { + // One-dimensional arrays are written as JSON arrays; an array of arrays + // has no codec, and must not reach Json as "[[I@1a2b3c". ProcessorContext ctx = run(compile( "package com.example;\n" + "import com.codename1.backend.annotations.*;\n" + "@RestController\n" + "public class Notes {\n" + " @GetMapping(\"/ids\")\n" - + " public int[] ids() { return new int[0]; }\n" + + " public int[][] ids() { return new int[0][]; }\n" + "}\n")); assertTrue("an array the router cannot encode should not compile", ctx.hasErrors()); String all = ctx.getErrors().toString(); diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 3dfe6906f87..59b4ebd3c2e 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -26,6 +26,7 @@ import com.codename1.backend.orm.EntityDefinition; import com.codename1.backend.orm.EntityManager; import com.codename1.impl.backend.BackendApplication; +import com.codename1.impl.backend.JsonCodec; import com.codename1.impl.backend.Management; import com.codename1.impl.backend.RequestSecurity; import com.codename1.impl.backend.WiringEnvironment; @@ -2214,6 +2215,9 @@ public void register(HttpServer.WebSocketRegistry direct) } }; } + JsonCodec.setMaxDepth(config.getInt( + JsonCodec.MAX_DEPTH_SETTING, + JsonCodec.MAX_DEPTH)); Cors cors = Cors.fromConfig(config); if (cors != null) { // Whether a request arrives over TLS, for telling a same-origin diff --git a/vm/backend/src/com/codename1/impl/backend/JsonCodec.java b/vm/backend/src/com/codename1/impl/backend/JsonCodec.java index d494dd2086c..05fe84dfcdf 100644 --- a/vm/backend/src/com/codename1/impl/backend/JsonCodec.java +++ b/vm/backend/src/com/codename1/impl/backend/JsonCodec.java @@ -47,9 +47,30 @@ public final class JsonCodec { /// would otherwise exhaust. public static final int MAX_DEPTH = 64; + /// The setting that replaces [#MAX_DEPTH] for this server. + public static final String MAX_DEPTH_SETTING = "cn1.json.maxDepth"; + + private static int maxDepth = MAX_DEPTH; + private JsonCodec() { } + /// How deep the codecs follow one object into another: [#MAX_DEPTH] unless + /// `cn1.json.maxDepth` says otherwise. + public static int maxDepth() { + return maxDepth; + } + + /// Sets [#maxDepth()]; the server calls it once, from its configuration, + /// before it serves anything. + public static void setMaxDepth(int depth) { + if (depth < 1) { + throw new IllegalArgumentException(MAX_DEPTH_SETTING + " is " + depth + + "; it has to be at least 1"); + } + maxDepth = depth; + } + /// Where a value sits in a body. Built one level per nested object or array /// the codec enters, and turned into text only for a message. public static final class Path { @@ -122,15 +143,130 @@ public static IllegalArgumentException mismatch(Path at, String name, int index, return new IllegalArgumentException(out.toString()); } - /// Refuses an object nested past [#MAX_DEPTH], which in a response means a + /// Refuses an object nested past [#maxDepth()], which in a response means a /// cycle: two objects that reach each other through their fields. public static IllegalStateException tooDeep(String type) { - return new IllegalStateException("A " + type + " nests more than " + MAX_DEPTH + return new IllegalStateException("A " + type + " nests more than " + maxDepth + " objects deep while being written as JSON, which is a cycle between " + "objects that refer to each other. Mark the field that points back " + "with @JsonIgnore."); } + /// The refusal of member `name` of `at`, which the body leaves out although + /// the class marks it `@JsonProperty(required = true)`. + public static IllegalArgumentException missing(Path at, String name) { + return located(at, name, -1, "required member is missing"); + } + + /// The refusal of member `name` of `at`, which a `@JsonStrict` class does + /// not declare. + public static IllegalArgumentException unknown(Path at, String name) { + return located(at, name, -1, "unknown member"); + } + + /// The refusal a `JsonTypeAdapter` made of the value at member `name` or + /// element `index` of `at`, with where it was put in front of its message. + public static IllegalArgumentException refused(Path at, String name, int index, + IllegalArgumentException cause) { + String why = cause.getMessage(); + return located(at, name, index, why == null || why.length() == 0 + ? "refused by its JSON adapter" : why); + } + + private static IllegalArgumentException located(Path at, String name, int index, + String message) { + StringBuilder out = new StringBuilder(); + at.append(out); + segment(out, name, index); + out.append(": ").append(message); + return new IllegalArgumentException(out.toString()); + } + + /// `json` for a number field marked `@JsonFormat(STRING)`, which reads its + /// own digits in quotes as well as a JSON number: a numeric string becomes + /// the Long or Double the parser would have made of it, anything else is + /// passed on for the typed read to refuse. + public static Object numberText(Object json) { + if (!(json instanceof String)) { + return json; + } + String s = ((String) json).trim(); + if (s.length() == 0 || s.length() > 64) { + return json; + } + boolean whole = true; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c >= '0' && c <= '9' || (i == 0 && c == '-')) { + continue; + } + if (c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-') { + whole = false; + continue; + } + return json; + } + try { + if (whole) { + return Long.valueOf(Long.parseLong(s)); + } + return Double.valueOf(Double.parseDouble(s)); + } catch (NumberFormatException e) { + return json; + } + } + + /// A date as an ISO-8601 instant in UTC, `2026-10-25T08:30:00.000Z`, for a + /// field marked `@JsonFormat(STRING)`. [#readDate] reads it back. + public static void writeIsoDate(Date value, ByteSink out) { + if (value == null) { + out.putAscii("null"); + return; + } + long millis = value.getTime(); + // Floored by hand: vm/JavaAPI's Math has no floorDiv. + long days = millis / 86400000L; + if (millis % 86400000L < 0) { + days--; + } + long ms = millis - days * 86400000L; + // civil_from_days, Howard Hinnant's algorithm; the inverse of daysFromCivil. + long z = days + 719468; + long era = (z >= 0 ? z : z - 146096) / 146097; + long doe = z - era * 146097; + long yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + long y = yoe + era * 400; + long doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + long mp = (5 * doy + 2) / 153; + long d = doy - (153 * mp + 2) / 5 + 1; + long m = mp < 10 ? mp + 3 : mp - 9; + if (m <= 2) { + y++; + } + StringBuilder sb = new StringBuilder(24); + sb.append('"'); + pad(sb, y, 4).append('-'); + pad(sb, m, 2).append('-'); + pad(sb, d, 2).append('T'); + pad(sb, ms / 3600000L, 2).append(':'); + pad(sb, ms / 60000L % 60, 2).append(':'); + pad(sb, ms / 1000L % 60, 2).append('.'); + pad(sb, ms % 1000L, 3).append("Z\""); + out.putAscii(sb.toString()); + } + + private static StringBuilder pad(StringBuilder sb, long v, int width) { + if (v < 0) { + sb.append('-'); + v = -v; + } + String digits = Long.toString(v); + for (int i = digits.length(); i < width; i++) { + sb.append('0'); + } + return sb.append(digits); + } + private static String describe(Object got) { if (got == null) { return "null"; From ad4eb2692bf6b79736e5d15f53d072340c776618 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:42:02 +0200 Subject: [PATCH 2/5] docs: move JSON contract snippets into the backend demos; self-contained adapter example Co-Authored-By: Claude Opus 5.5 --- .../codename1/annotations/JsonAdapter.java | 16 +++--- .../backend/contract/Money.java | 56 +++++++++++++++++++ .../backend/contract/MoneyAdapter.java | 41 ++++++++++++++ .../backend/contract/Point.java | 46 +++++++++++++++ docs/developer-guide/Backend-Web.asciidoc | 29 +--------- 5 files changed, 154 insertions(+), 34 deletions(-) create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/contract/Money.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/contract/MoneyAdapter.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/contract/Point.java diff --git a/CodenameOne/src/com/codename1/annotations/JsonAdapter.java b/CodenameOne/src/com/codename1/annotations/JsonAdapter.java index c2e907b5687..edc722b8012 100644 --- a/CodenameOne/src/com/codename1/annotations/JsonAdapter.java +++ b/CodenameOne/src/com/codename1/annotations/JsonAdapter.java @@ -35,24 +35,26 @@ /// A field's adapter wins over its class's. /// /// ```java -/// public final class CentsAdapter implements JsonTypeAdapter { -/// public Object toJson(Money m) { return m == null ? null : Long.valueOf(m.cents()); } -/// public Money fromJson(Object json) { +/// public final class EpochSecondsAdapter implements JsonTypeAdapter { +/// public Object toJson(java.util.Date d) { +/// return d == null ? null : Long.valueOf(d.getTime() / 1000); +/// } +/// public java.util.Date fromJson(Object json) { /// if (json == null) { /// return null; /// } /// // instanceof, never a bare cast: a failed cast does not throw on iOS. /// if (!(json instanceof Number)) { -/// throw new IllegalArgumentException("expected cents"); +/// throw new IllegalArgumentException("expected epoch seconds"); /// } -/// return new Money(((Number) json).longValue()); +/// return new java.util.Date(((Number) json).longValue() * 1000); /// } /// } /// /// @Mapped /// public class Invoice { -/// @JsonAdapter(CentsAdapter.class) -/// public Money total; +/// @JsonAdapter(EpochSecondsAdapter.class) +/// public java.util.Date issued; /// } /// ``` /// diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/contract/Money.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/contract/Money.java new file mode 100644 index 00000000000..b4933a0d8ec --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/contract/Money.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2012, 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.codenameone.developerguide.backend.contract; + +/** An amount kept in cents, for the Backend chapter's adapter example. */ +public final class Money { + private final long cents; + + public Money(long cents) { + this.cents = cents; + } + + /** Parses "12.34"; anything else is refused. */ + public static Money parse(String s) { + int dot = s.indexOf('.'); + try { + if (dot < 0) { + return new Money(Long.parseLong(s) * 100); + } + if (s.length() - dot != 3) { + throw new IllegalArgumentException("expected an amount"); + } + long whole = Long.parseLong(s.substring(0, dot)); + long frac = Long.parseLong(s.substring(dot + 1)); + return new Money(whole * 100 + (s.startsWith("-") ? -frac : frac)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("expected an amount"); + } + } + + public String toDecimalString() { + long abs = Math.abs(cents); + String frac = String.valueOf(abs % 100); + return (cents < 0 ? "-" : "") + abs / 100 + "." + (frac.length() == 1 ? "0" + frac : frac); + } +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/contract/MoneyAdapter.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/contract/MoneyAdapter.java new file mode 100644 index 00000000000..2814fc5d2b5 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/contract/MoneyAdapter.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2012, 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.codenameone.developerguide.backend.contract; + +import com.codename1.annotations.JsonTypeAdapter; + +/** The Backend chapter's adapter: cents sent as a decimal string. */ +// tag::backend-json-adapter[] +public final class MoneyAdapter implements JsonTypeAdapter { + public Object toJson(Money m) { + return m == null ? null : m.toDecimalString(); + } + + public Money fromJson(Object json) { + if (!(json instanceof String)) { + throw new IllegalArgumentException("expected an amount"); + } + return Money.parse((String) json); + } +} +// end::backend-json-adapter[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/contract/Point.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/contract/Point.java new file mode 100644 index 00000000000..a2c493ddbd7 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/contract/Point.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2012, 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.codenameone.developerguide.backend.contract; + +import com.codename1.annotations.JsonCreator; +import com.codename1.annotations.JsonProperty; +import com.codename1.annotations.JsonStrict; + +/** The Backend chapter's immutable, strictly read DTO. */ +// tag::backend-json-creator[] +@JsonStrict +public final class Point { + private final int x; + private final int y; + + @JsonCreator + public Point(@JsonProperty(value = "x", required = true) int x, + @JsonProperty("y") int y) { + this.x = x; + this.y = y; + } + + public int getX() { return x; } + public int getY() { return y; } +} +// end::backend-json-creator[] diff --git a/docs/developer-guide/Backend-Web.asciidoc b/docs/developer-guide/Backend-Web.asciidoc index 629f3ea20ea..30ed31d6083 100644 --- a/docs/developer-guide/Backend-Web.asciidoc +++ b/docs/developer-guide/Backend-Web.asciidoc @@ -264,21 +264,7 @@ An immutable class keeps its fields `final` and is read through its creator: [source,java,role=cn1-no-try] ---- -@JsonStrict -public final class Point { - private final int x; - private final int y; - - @JsonCreator - public Point(@JsonProperty(value = "x", required = true) int x, - @JsonProperty("y") int y) { - this.x = x; - this.y = y; - } - - public int getX() { return x; } - public int getY() { return y; } -} +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/contract/Point.java[tag=backend-json-creator,indent=0] ---- An adapter converts between a type and the parsed form of JSON: `null`, a @@ -288,18 +274,7 @@ in cents and sent as a decimal string: [source,java,role=cn1-no-try] ---- -public final class MoneyAdapter implements JsonTypeAdapter { - public Object toJson(Money m) { - return m == null ? null : m.toDecimalString(); - } - - public Money fromJson(Object json) { - if (!(json instanceof String)) { - throw new IllegalArgumentException("expected an amount"); - } - return Money.parse((String) json); - } -} +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/contract/MoneyAdapter.java[tag=backend-json-adapter,indent=0] ---- The adapter needs a public constructor without arguments. The build creates one From 4c5058e8876052853388e062514f1b8669a50a29 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:43:37 +0200 Subject: [PATCH 3/5] docs: Money demo parses strictly and refuses overflow; adapter accepts null Co-Authored-By: Claude Opus 5.5 --- .../backend/contract/Money.java | 42 +++++++++++++------ .../backend/contract/MoneyAdapter.java | 3 ++ 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/contract/Money.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/contract/Money.java index b4933a0d8ec..8a27d1448b0 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/contract/Money.java +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/contract/Money.java @@ -27,30 +27,48 @@ public final class Money { private final long cents; public Money(long cents) { + if (cents == Long.MIN_VALUE) { + // Has no positive counterpart, so it has no decimal form below. + throw new IllegalArgumentException("amount out of range"); + } this.cents = cents; } - /** Parses "12.34"; anything else is refused. */ + /** + * Parses a whole amount ("12") or one with exactly two decimals ("12.34"), + * optionally negative; anything else, or an amount too large to hold, is + * refused. + */ public static Money parse(String s) { + int i = s.startsWith("-") ? 1 : 0; int dot = s.indexOf('.'); - try { - if (dot < 0) { - return new Money(Long.parseLong(s) * 100); + int end = dot < 0 ? s.length() : dot; + if (end == i || (dot >= 0 && s.length() - dot != 3)) { + throw new IllegalArgumentException("expected an amount"); + } + long units = 0; + for (int p = i; p < s.length(); p++) { + if (p == dot) { + continue; } - if (s.length() - dot != 3) { + char c = s.charAt(p); + if (c < '0' || c > '9' || units > (Long.MAX_VALUE - 9) / 10) { throw new IllegalArgumentException("expected an amount"); } - long whole = Long.parseLong(s.substring(0, dot)); - long frac = Long.parseLong(s.substring(dot + 1)); - return new Money(whole * 100 + (s.startsWith("-") ? -frac : frac)); - } catch (NumberFormatException e) { - throw new IllegalArgumentException("expected an amount"); + units = units * 10 + (c - '0'); + } + if (dot < 0) { + if (units > Long.MAX_VALUE / 100) { + throw new IllegalArgumentException("expected an amount"); + } + units *= 100; } + return new Money(i == 1 ? -units : units); } public String toDecimalString() { long abs = Math.abs(cents); - String frac = String.valueOf(abs % 100); - return (cents < 0 ? "-" : "") + abs / 100 + "." + (frac.length() == 1 ? "0" + frac : frac); + long frac = abs % 100; + return (cents < 0 ? "-" : "") + abs / 100 + (frac < 10 ? ".0" : ".") + frac; } } diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/contract/MoneyAdapter.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/contract/MoneyAdapter.java index 2814fc5d2b5..4155d631182 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/contract/MoneyAdapter.java +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/contract/MoneyAdapter.java @@ -32,6 +32,9 @@ public Object toJson(Money m) { } public Money fromJson(Object json) { + if (json == null) { + return null; + } if (!(json instanceof String)) { throw new IllegalArgumentException("expected an amount"); } From 7af27aa9bfc1a358553d6e7ca16b49f292ab5799 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:44:33 +0200 Subject: [PATCH 4/5] docs: Money demo accepts the largest amount a long holds Co-Authored-By: Claude Opus 5.5 --- .../com/codenameone/developerguide/backend/contract/Money.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/contract/Money.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/contract/Money.java index 8a27d1448b0..a07ccb07e69 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/contract/Money.java +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/contract/Money.java @@ -52,7 +52,7 @@ public static Money parse(String s) { continue; } char c = s.charAt(p); - if (c < '0' || c > '9' || units > (Long.MAX_VALUE - 9) / 10) { + if (c < '0' || c > '9' || units > (Long.MAX_VALUE - (c - '0')) / 10) { throw new IllegalArgumentException("expected an amount"); } units = units * 10 + (c - '0'); From 338bfafd9dcbba185d6a8fbbd3b41a88425ce8a2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:16:43 +0200 Subject: [PATCH 5/5] Fix PMD and Vale gate findings Chain the parse cause, drop a redundant qualified name, end the number-sniffing loop without a branch, and reword an adverb. Co-Authored-By: Claude Opus 5.5 --- CodenameOne/src/com/codename1/mapping/JsonValues.java | 6 +++--- docs/developer-guide/Backend-Web.asciidoc | 2 +- .../src/com/codename1/impl/backend/JsonCodec.java | 11 +++++------ 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/CodenameOne/src/com/codename1/mapping/JsonValues.java b/CodenameOne/src/com/codename1/mapping/JsonValues.java index f43149fd6a0..14f81d84d4b 100644 --- a/CodenameOne/src/com/codename1/mapping/JsonValues.java +++ b/CodenameOne/src/com/codename1/mapping/JsonValues.java @@ -95,7 +95,7 @@ public static long toIntegral(Object v, long min, long max) { /// #### Throws /// /// - `IllegalArgumentException`: when `json` is not an object - public static Object object(com.codename1.mapping.Mapper mapper, Object json) { + public static Object object(Mapper mapper, Object json) { if (!(json instanceof java.util.Map) || mapper == null) { throw new IllegalArgumentException("expected a JSON object"); } @@ -121,7 +121,7 @@ private static double parseDouble(String s) { try { return Double.parseDouble(s); } catch (NumberFormatException e) { - throw new IllegalArgumentException("expected a number, found \"" + s + "\""); + throw new IllegalArgumentException("expected a number, found \"" + s + "\"", e); } } @@ -317,7 +317,7 @@ public static long parseIso(String s) { } catch (RuntimeException e) { // NumberFormatException and StringIndexOutOfBoundsException are // thrown explicitly by the parsers, never by a cast. - throw new IllegalArgumentException("expected an ISO-8601 date, found \"" + s + "\""); + throw new IllegalArgumentException("expected an ISO-8601 date, found \"" + s + "\"", e); } } diff --git a/docs/developer-guide/Backend-Web.asciidoc b/docs/developer-guide/Backend-Web.asciidoc index 30ed31d6083..589e0265c1f 100644 --- a/docs/developer-guide/Backend-Web.asciidoc +++ b/docs/developer-guide/Backend-Web.asciidoc @@ -319,7 +319,7 @@ did becomes a declaration: | `JsonNode`, `mapper.readTree(text)` | `Json.parse(text)`, which answers a `Map`, a `List`, a `String`, a `Long`, a - `Double`, a `Boolean` or `null`, nested as deeply as the document is. + `Double`, a `Boolean` or `null`, nested to whatever depth the document has. | `ObjectNode` built by hand for a response | A `Map` or `List` returned from the method, or written with `Json.write(value)`. diff --git a/vm/backend/src/com/codename1/impl/backend/JsonCodec.java b/vm/backend/src/com/codename1/impl/backend/JsonCodec.java index 05fe84dfcdf..ccfbd256c24 100644 --- a/vm/backend/src/com/codename1/impl/backend/JsonCodec.java +++ b/vm/backend/src/com/codename1/impl/backend/JsonCodec.java @@ -197,14 +197,13 @@ public static Object numberText(Object json) { boolean whole = true; for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); - if (c >= '0' && c <= '9' || (i == 0 && c == '-')) { - continue; - } - if (c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-') { + if (c == '.' || c == 'e' || c == 'E' || c == '+' || (c == '-' && i > 0)) { whole = false; - continue; + } else if (c < '0' || c > '9') { + if (i != 0 || c != '-') { + return json; + } } - return json; } try { if (whole) {