karewan / kndata
Strict and fast PHP 8.3+ data mapping and validation (JSON, forms, arrays) with TypeScript, Java, Kotlin and Swift type generation
Requires
- php: >=8.3
- ext-filter: *
- ext-json: *
- ext-mbstring: *
- ext-tokenizer: *
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Strict and fast PHP 8.3+ data mapping and validation (JSON, forms, arrays) with TypeScript, Java, Kotlin and Swift type generation.
- One definition: a PHP class describes the data, its validation, its JSON form and the types of its clients.
- Strict by design: exact conversions, every error at once with its path and a stable code, never the value.
- Fast: each class is compiled to plain PHP, cached like the routes of KnRoute; mapping an 18-field object costs about 2.5 µs (JSON decoding aside).
- No dependency.
Table of contents
- KnData
Installation
PHP 8.3+ with the mbstring and tokenizer extensions.
composer require karewan/kndata
Quick start
declare(strict_types=1); use Karewan\KnData\Attribute\NullIfInvalid; use Karewan\KnData\Validation\{Latitude, Length, Round, Trim}; final readonly class NewDelivery { /** * @param list<int> $parcel_ids */ public function __construct( public int $order_id, public int $courier_id, public array $parcel_ids = [], #[Trim, Length(max: 50)] public ?string $recipient = null, #[NullIfInvalid, Round(6), Latitude] public ?float $lat = null, public ?Address $address = null, public DeliveryStatus $status = DeliveryStatus::Pending, public ?DateTimeImmutable $at = null, ) { } }
use Karewan\KnData\Error\InvalidDataException; use Karewan\KnData\KnData; KnData::configure(cacheDir: __DIR__ . '/Storage/cache/kndata', scanForModifiedFiles: $debug); try { $delivery = KnData::fromJson($body, NewDelivery::class); } catch (InvalidDataException $e) { // $.order_id: wrong_type (expected int, got string); $.parcel_ids[2]: wrong_type (expected int, got string) $logger->warning($e->getMessage()); respond(400, ['errors' => $e->toArray()]); } $form = KnData::fromPost(PasswordForm::class); // $_POST, form profile $json = KnData::toJson($delivery); // back to JSON, same keys
KnData is a static facade on a shared Mapper. Inject a Mapper instead when you prefer: it has the same methods.
Sources and profiles
The source says how the raw values are read. The rules and the classes are the same for every source.
| Source | Created with | Profile |
|---|---|---|
| Decoded JSON, PHP array or object | KnData::map($data, ...), Source::json($data) |
Json |
| JSON text | KnData::fromJson($text, ...), Source::fromJson($text) |
Json |
| Exact JSON types | Source::strict($data), or #[Strict] on a class or a field |
Strict |
| HTML form, query string | KnData::fromPost(...), KnData::fromQuery(...), Source::form($array) |
Form |
- Json (default): numbers written as strings (
"12","4.5") and integral floats (3.0for anint) are accepted; booleans must betrueorfalse. - Strict: exact JSON types only.
- Form: every scalar is a string. Numbers are converted with surrounding spaces ignored, booleans accept
1,true,onand0,false,off(any case), and an empty string counts as absent.#[Strict]has no effect on a form.
fromJson() decodes JSON objects as PHP objects, so {} and [] keep their meaning.
Conversion rules
A string holding a number must follow the JSON grammar exactly: "12abc", " 12" (except in a form), "+12", "012",
"0x1A" and "" are refused. Nothing is truncated or guessed.
| PHP type | Accepted | Refused |
|---|---|---|
int |
12; "12" and 3.0 (not Strict); " 12 " (Form) |
2.9 (wrong_type), 1e20 or "9223372036854775808" (out_of_range), "1e3", true |
float |
4.5, 4; "4.5", "1e-3" (not Strict) |
"4,5", ".5", "NaN", false |
string |
any string, kept as it is | numbers, booleans, arrays |
bool |
true, false; "1", "true", "on", "0", "false", "off" (Form) |
"true" and 1 in JSON, "yes" |
| backed enum | its value, converted like its backing type | an unknown value (invalid_enum) |
| pure enum | the name of the case, "Green" |
anything else |
| class | an object or an associative array; [] as an empty object (not Strict); an instance of the class |
a list, a scalar |
list<T> |
a JSON array, each item converted; any array in a form (name[]) |
a JSON object |
array<string, T> |
a JSON object, or [] |
a non-empty JSON array |
DateTimeImmutable |
RFC 3339 "2026-09-29T10:00:00+02:00" (or #[DateFormat]) |
"2026-02-30T10:00:00Z" and other formats (invalid_format) |
mixed |
any JSON value, objects as associative arrays |
Classes
A class is mapped through its constructor when it has parameters: every parameter is a field, promoted or not, and
the constructor keeps its invariants. readonly classes are the natural fit. A class without constructor parameters is
created with new and its public properties are set.
final class Filters // property mode { public int $page = 1; /** @var list<string> */ public array $tags = []; }
An InvalidArgumentException thrown by the constructor becomes an invalid violation on the object, with the
exception message as message.
Types
| PHP | Notes |
|---|---|
int, float, string, bool |
|
?T, T|null |
The only union supported |
| backed or pure enum | |
| a class | Any depth, maxDepth (64) guards recursion |
array + @var list<T>, T[], array<T> |
A list |
array + @var non-empty-list<T> |
A list with at least one item (too_few) |
array + @var array<string, T> or array<int, T> |
A map; with int keys, a JSON array is accepted too |
array + @var array<string, mixed>, mixed |
Free JSON, objects as associative arrays |
DateTimeImmutable, DateTimeInterface, DateTime |
RFC 3339, or #[DateFormat('Y-m-d')], #[DateFormat('U')] for a timestamp |
The item type of an array comes from the PHPDoc (@var on the property, @param on the constructor, the phpstan-
and psalm- forms win), which PHPStan reads too. Short class names are resolved with the use imports of the file,
aliases and group imports included. #[ListOf(Device::class)] and #[MapOf('int')] give it without PHPDoc.
A type KnData cannot handle is refused when the class is first used, with a DefinitionException that names the field:
missing type, union type, array without item type, array{...} shape, interface, readonly property without constructor,
rule on a type it does not support, invalid pattern... It is a programming error: answer it as a server error.
Required, optional and nullable fields
- A field without default value is required: absent, it is a
missingviolation. - A field with a default value may be absent: the default applies.
- Nullable is not optional:
?int $xmust be present, even asnull. Write?int $x = nullfor an optional field. nullfor a non-nullable field isnull_not_allowed.- An unknown key is ignored, and reported as a warning by
tryMap().#[RejectUnknownKeys]makes it a violation. - Keys match exactly (no case folding).
Mapping attributes
In Karewan\KnData\Attribute.
| Attribute | Target | Effect |
|---|---|---|
#[Name('crossOrigin')] |
field | Key in the data, read and written |
#[Naming(KeyCase::Snake)] |
class | Key of every field without #[Name]: Snake (fromUserId → from_user_id), Camel, Pascal, Kebab |
#[Strict] |
class, field | Exact JSON types |
#[RejectUnknownKeys] |
class | An unknown key is a violation |
#[NullIfInvalid] |
nullable field | An invalid value becomes null, with a warning: a malformed detail does not reject the object |
#[DateFormat('Y-m-d')] |
date field | Format read and written, 'U' for a Unix timestamp |
#[Ignore] |
field | Neither read nor written (a constructor parameter needs a default) |
#[OmitNull] |
class, field | A null value is left out of the serialized data |
#[ListOf(T)], #[MapOf(T)] |
array field | Item type without PHPDoc |
#[Message('key')] |
field | Message key or text for every violation of the field |
#[Export(Direction::Input)] |
class, enum | Generated types, see Type generation |
Validation
Rules are attributes in Karewan\KnData\Validation, checked while mapping. They are typed objects: a rule on a type it
does not apply to (#[Length] on an int), an invalid pattern or inverted bounds are refused when the class is read.
final readonly class PasswordChangeForm { public function __construct( #[Length(max: 50)] public string $current, #[Length(min: 12, max: 50), Message('bad_new_password')] #[Regex('/[a-z]/', message: 'pwd_lower')] #[Regex('/[A-Z]/', message: 'pwd_upper')] #[Regex('/[0-9]/', message: 'pwd_number')] #[Regex('/[\p{P}\p{S}]/u', message: 'pwd_ssymb')] public string $new, #[SameAs('new')] public string $confirm, ) { } }
Order of processing
- The raw value is read from the source.
- Raw transforms apply (
Trim,StripControlChars...). - Presence: absent (or empty in a form) takes the default, or is
missing. - The value is converted to the type of the field.
- Typed transforms (
Round) and constraints run in declaration order. The first constraint that fails stops the field: one violation per field. - Comparisons between fields (
SameAs), once every field is valid. - The object is built, then the class-level
#[Callback]rules run.
Transforms
| Attribute | Types | Effect |
|---|---|---|
#[Trim] |
string | Removes surrounding spaces, Unicode spaces included |
#[StripControlChars(keepNewlines: false)] |
string | Removes tab, line breaks and the other control characters |
#[CollapseWhitespace] |
string | Each run of spaces and line breaks becomes one space |
#[Lowercase], #[Uppercase] |
string | Multibyte case |
#[EmptyToNull] |
nullable | "" becomes null |
#[Round(6)] |
int, float | Rounds to a number of decimals (negative: tens, hundreds), mode: PHP_ROUND_HALF_EVEN... |
Constraints
Every constraint takes an optional message:.
| Attribute | Types | Code |
|---|---|---|
#[NotBlank] |
string | blank |
#[Length(min: 2, max: 50)] |
string (characters) | too_short, too_long |
#[Range(min: 1, max: 10)] |
int, float | too_small, too_large |
#[Positive], #[PositiveOrZero] |
int, float | not_positive, negative |
#[MultipleOf(0.5)] |
int, float | not_multiple |
#[MaxDecimals(6)] |
int, float | too_many_decimals |
#[Count(min: 1, max: 50)] |
list, map | too_few, too_many |
#[Unique] |
list of scalars or enums | not_unique |
#[Regex('/.../')], #[Regex('/.../', match: false)] (repeatable) |
string | pattern_mismatch, forbidden_pattern |
#[Letters], #[AlphaNum] (unicode: true), #[Hex], #[Digits] |
string | invalid_chars |
#[NoControlChars(allowNewlines: false)] |
string | control_chars |
#[Email] |
string | invalid_email |
#[Url(schemes: ['https'])] |
string | invalid_url |
#[Phone] (E.164, allowSpaces: false) |
string | invalid_phone |
#[Latitude], #[Longitude] |
int, float | invalid_coordinate |
#[Uuid] |
string | invalid_uuid |
#[Ip(version: 4)] |
string | invalid_ip |
#[Timezone] |
string | invalid_timezone |
#[DateRange(min: '2020-01-01', max: 'now')] |
date | too_early, too_late |
#[OneOf(['fr', 'en'])] |
scalars | not_allowed |
#[SameAs('password')] |
any | not_same |
#[Each(...)] |
list, map | codes of its rules, at the path of the item |
#[Callback(...)] (repeatable) |
any, class | invalid or the code returned |
Put a #[Length(max: ...)] before a #[Regex] on user input: some patterns are slow on long strings (kndata warmup
warns about it). A pattern error never passes as a success.
Limit the decimals of a number
Round the value, for example a GPS coordinate to 6 decimals (about 11 cm), or refuse the extra decimals:
#[Round(6), Latitude] public ?float $lat = null, // 45.76404312 -> 45.764043 #[MaxDecimals(2)] public float $price, // 12.345 -> too_many_decimals
Decimals are counted on the shortest writing of the number, so 0.1 + 0.2 has 17 and 1.5E-7 has 8.
Rules on the items of a list
/** @param list<string> $tags */ public function __construct(#[Count(max: 10), Each(new Trim(), new Length(max: 20))] public array $tags = [])
A refused item is reported at its path: $.tags[3]: too_long.
Comparison between fields
#[SameAs('password')] compares with another field (PHP name), strictly, once both are valid.
Custom rules
With a function, on a field or on a class:
#[Callback([Siret::class, 'check'])] // static method, 'Siret::check' also works public string $siret, #[Callback([Period::class, 'ordered'])] // on the class: receives the built object final readonly class Period { ... }
The function returns true or null when valid; false, an error code, a Failure or a list of Failure when not.
Or as a class, reusable and typed:
use Karewan\KnData\Metadata\{Type, TypeKind}; use Karewan\KnData\Validation\{Constraint, Failure}; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)] final readonly class Siret implements Constraint { public function __construct(public ?string $message = null) { } public function supports(Type $type): bool { return $type->kind === TypeKind::String; } public function check(mixed $value): ?Failure { return self::luhn($value) ? null : new Failure('invalid_siret', [], $this->message); } }
Implement Transform for a custom transform. Give your rules promoted constructor properties: the cache files rebuild
them with their constructor; others are reloaded through reflection.
Validate without a class
For a query string or a small form, declare the fields at the call. Same reading, same rules:
use Karewan\KnData\Source; use Karewan\KnData\Validation\{Field, Length, Range, Trim}; $result = KnData::validate(Source::query(), [ 'page' => Field::int(new Range(min: 1))->default(1), 'search' => Field::string(new Trim(), new Length(max: 100))->optional(), 'sort' => Field::enum(Sort::class)->default(Sort::Name), 'ids' => Field::listOf(Field::int(new Positive()))->optional(), ]); $result->throwIfInvalid(); $page = $result->int('page'); // int|null; string(), float(), bool(), array(), get()
Modifiers: optional(), default($value), nullable(), nullIfInvalid(), strict(), message('key'), intKeys().
Types: int, float, string, bool, mixed, enum(Class), date(?format), object(Class), listOf(Field),
mapOf(Field).
Errors
Every violation is collected (up to maxErrors, 50) before InvalidDataException is thrown.
final readonly class Violation { public string $path; // "$.position.lat", "$.items[2]", "$['odd key']" public string $code; // ErrorCode value or custom code public array $params; // ['max' => 50] public ?string $expected; // type errors: "int", "list<int>" public ?string $given; // type errors: "string", "number", "boolean", "object", "array", "null" public ?string $message; // from message: or #[Message] }
A violation never holds the value: it can be logged or sent back to a client without leaking a password, a token or a position. Class names are shortened.
| Code | Meaning |
|---|---|
missing |
Required field absent |
null_not_allowed |
null for a non-nullable field |
wrong_type |
Value of another type |
out_of_range |
Number beyond the int range |
invalid_enum |
Value outside the enum (params.allowed) |
invalid_format |
Date in another format |
unknown_key |
With #[RejectUnknownKeys] |
too_deep |
Nesting beyond maxDepth |
too_many_errors |
maxErrors reached |
invalid_json |
fromJson() text is not JSON |
invalid |
Constructor InvalidArgumentException, Callback returning false |
InvalidDataException: getViolations(), getMessages(), toArray() (for a JSON answer), and an English
getMessage() for the logs.
Warnings and tryMap
tryMap() never throws for refused data. Its Result holds the object or the violations, and the warnings: unknown
keys, values replaced by #[NullIfInvalid]. Watch them before making a contract stricter.
$result = KnData::tryMap($data, NewDelivery::class); foreach ($result->getWarnings() as $warning) { $logger->info((string)$warning); // $.lat: nulled_invalid (code 'wrong_type') } $delivery = $result->getValue(); // throws InvalidDataException when refused
Messages
getMessages() gives one message per violation through the message resolver: English by default
(The field age must be at least 18.). A message set with message: or #[Message] replaces it; its {placeholders}
are filled: {field}, {path}, {code}, {expected}, {given} and the parameters ({min}, {max}, {allowed}...).
Plug your translations with a resolver:
use Karewan\KnData\Error\Violation; use Karewan\KnData\Validation\DefaultMessageResolver; KnData::configure(messageResolver: static fn(Violation $v): string => DefaultMessageResolver::format(__($v->message ?? 'validation_' . $v->code), $v) );
Serialization
serialize() and toJson() write objects back with the same keys, enums (value, or name for a pure enum) and date
formats. Only the readable fields are written (a constructor parameter without public property is input only). An
empty map or object is written {}, not the [] of json_encode(). #[OmitNull] leaves null values out.
toJson() uses JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION.
KnData::toJson($settings); // {"delay":5,"lang":"fr","langs":[],"labels":{}} KnData::serialize([$a, $b]); // list of objects
Cache
Each class is compiled to a PHP file in cacheDir (one per class, loaded by OPcache), as KnRoute caches its routes:
KnData::configure( cacheDir: __DIR__ . '/Storage/cache/kndata', scanForModifiedFiles: $debug, );
scanForModifiedFiles: false(production): a cached class is used without looking at its source files. Empty the cache directory at each deployment, or runkndata warmupafter emptying it.scanForModifiedFiles: true(development): a class is recompiled when its file, a parent, a trait, or a class or enum of its fields changed (size or modification time).- Files are written atomically (temporary file then rename) and invalidated in OPcache. A file of another KnData version is recompiled.
- Without
cacheDir, classes are compiled in memory at their first use in each request: fine for tests and CLI scripts, slow for a website.
Type generation
Mark the classes and enums with #[Export]: the classes and enums they reference are exported with them.
vendor/bin/kndata generate typescript --src App/Dto --out web/src/api.ts vendor/bin/kndata generate kotlin --src App/Dto --out android/Api.kt --package com.example.api vendor/bin/kndata generate swift --src App/Dto --out ios/Api.swift vendor/bin/kndata generate java --src App/Dto --out android/src/main/java/com/example/api --package com.example.api --style fastjson2
Add --check in the CI: it writes nothing and fails when the files are not up to date.
Directions
#[Export(...)] |
Meaning |
|---|---|
Direction::Input |
Sent by the clients: a field with a default value is optional |
Direction::Output |
Written by PHP: every readable field is present, readonly in TypeScript |
Direction::Both (default) |
Both ways. TypeScript gets Name (output) and NameInput when they differ |
A referenced class takes the direction of the classes that reference it, merged with its own #[Export]: a class
exported as input and read in an output class is generated both ways.
#[Export(name: 'OrderDto')] renames the type. Two exported types with the same name are a DefinitionException.
Generated types
| PHP | TypeScript | Java | Kotlin | Swift |
|---|---|---|---|---|
int |
number |
long / Long |
Long |
Int |
float |
number |
double / Double |
Double |
Double |
string |
string |
String |
String |
String |
bool |
boolean |
boolean / Boolean |
Boolean |
Bool |
?T |
T | null |
boxed type | T? |
T? |
list<T> |
T[] |
List<T> |
List<T> |
[T] |
array<string, T> |
Record<string, T> |
Map<String, T> |
Map<String, T> |
[String: T] |
mixed |
unknown |
JsonNode / JsonElement / Object |
JsonElement / Any? |
JSONValue (generated) |
| date | string (number for U) |
String |
String |
String |
| backed enum | as const object + type |
enum with getValue() / fromValue(), + UNRECOGNIZED when the clients read it |
enum class with value, + Unrecognized when the clients read it |
enum: Int / String, + unrecognized(raw) when the clients read it |
| class | interface |
record, class with public fields for FastJson2 |
data class |
struct: Codable |
- The PHPDoc summaries and the constraints become documentation (
@minLength 12,@format email...).#[OneOf]becomes a literal union in TypeScript. - Default values are carried over when the language can write them. Kotlin: an unchanged default is not sent
(
encodeDefaults = false). Swift: a nullable field without a null default is encoded asnull. - Identifiers are camelCase with the key kept by
@JsonProperty,@SerializedName,@JSONField,@SerialNameorCodingKeys; reserved words are escaped.--int-type intgives 32-bit integers in Java and Kotlin.
Enum values added later
An enum the clients read (reached from an output class, or exported as output) gets a fallback case in Java
(UNRECOGNIZED), Kotlin (Unrecognized) and Swift (unrecognized(raw)): a value added to the server after the
generation reads as this case instead of failing the whole object, in lists and maps too. fromValue() returns it
instead of throwing. Java and Kotlin cannot send it: its value is unknown, getValue() throws. Swift keeps the raw
value and sends it back as received. A case named Unrecognized is a DefinitionException on an enum the clients
read. TypeScript is unchanged: a web front ships with its server.
when (order.status) { OrderStatus.Pending, OrderStatus.Paid -> showProgress() OrderStatus.Shipped -> showTracking() OrderStatus.Unrecognized -> showUpdateHint() // a status this version of the app does not know }
A pure enum gets the same fallback, its case names being its values. An enum only sent by the clients (reached from input classes only) keeps its plain form: the clients never decode it.
Java and Kotlin libraries
--style picks the JSON library. The generated code carries what the library needs to send exactly what PHP expects,
with its default configuration: no null where PHP would refuse it or apply its default, a null where PHP needs one, int
enums as numbers, unknown keys and enum values of a newer server accepted.
| Style | Generated | Notes |
|---|---|---|
Java jackson (default) |
records | @JsonIgnoreProperties(ignoreUnknown = true), @JsonInclude(NON_NULL) on the fields whose null means "not set" |
Java gson |
records | int enums and the enums the clients read get a TypeAdapter; the nulls PHP must receive go through the generated KnDataWriteNull |
Java fastjson2 |
classes with public fields | For Android: FastJson2 for Android reads no records. Defaults are field initializers, int map keys are strings |
Java none |
records | No annotation |
Kotlin kotlinx (default) |
data classes | Use Json { ignoreUnknownKeys = true }, so that a key added by the server does not break the installed apps |
Kotlin fastjson2 |
data classes | Works without kotlin-reflect: every parameter carries @param:JSONField |
Kotlin none |
data classes | No annotation |
The Kotlin styles generate the same Kotlin API: moving from FastJson2 to kotlinx.serialization takes a new generation,
not a code change. One class cannot serve both libraries: FastJson2 would pick the constructor generated by the kotlinx
plugin. The Java classes of the fastjson2 style can also be used from Kotlin.
With KnHttp, getAsObject() and getAsObjectList() read the fastjson2 types
as they are. To send one, prefer JSON.toJSONString(dto):
KnHttp.post(url) .addStringBody(JSON.toJSONString(dto)) .setContentType("application/json; charset=utf-8")
addJSONObjectBody((JSONObject) JSON.toJSON(dto)) works too, but drops every null: a field that PHP requires and that
is null is then missing.
On Android, keep the generated classes from R8 when the library reads them by reflection (Gson, Jackson, FastJson2):
-keep class com.example.api.** { *; }.
Command line
kndata warmup --cache-dir <dir> <path|class>...
kndata generate <typescript|java|kotlin|swift> --src <path> [--src <path>...] --out <path>
[--package <name>] [--style <style>] [--int-type long|int] [--check]
kndata --help | --version
The project autoloader is found by Composer's bin proxy, or give it with --autoload vendor/autoload.php.
Performance
composer bench (php -d opcache.enable_cli=1 benchmarks/run.php), PHP 8.5, OPcache, JIT off, 50,000 runs:
| Operation | µs |
|---|---|
json_decode() of the event, alone |
1.8 |
Strict hydration written by hand, json_decode() included |
3.5 |
KnData::fromJson(), 18 fields, json_decode() included |
4.3 |
| Form with 5 fields, trims and rules | 2.0 |
toJson() of the event |
2.9 |
| First use in a request: new mapper, cache file loaded, mapping | 6.7 |
The event is a parcel tracking scan with 18 fields: integers, floats, strings, nulls and a list. For comparison, netresearch/jsonmapper took 15 µs for an object of the same shape with a reused instance, 34 µs with a new one.
PHP 8.5
KnData runs on PHP 8.3 and uses PHP 8.5 when it is there:
- Closures in attributes:
#[Callback(static fn(string $v): bool => ...)]works; the cache reloads such rules through reflection. #[\NoDiscard]ontryMap()andvalidate(): PHP 8.5 warns when their result is ignored.
Tests
composer test
A plain PHP runner, no dependency. Every PHP warning fails a test. The tests cover the conversion table of each profile,
every rule through both the compiled classes and the standalone validator, the cache, the generated code, the command
line, and random data against every fixture. Refresh the snapshots of the generated code with
KNDATA_UPDATE_SNAPSHOTS=1 composer test.
The generated code is compiled and run when the tools are there, skipped otherwise:
| Variable | Test |
|---|---|
KNDATA_TSC or tsc in the PATH |
The TypeScript compiles in strict mode |
KNDATA_JAVAC or javac in the PATH |
The Java compiles |
KNDATA_JARS: directory of jars |
Java with Jackson, Gson and FastJson2 reads what KnData writes, enum values added later included, and what it writes maps back in the strict profile |
KNDATA_KOTLIN_HOME or kotlinc in the PATH |
The same with Kotlin, kotlinx.serialization and FastJson2, without kotlin-reflect |
KNDATA_SWIFTC or swiftc in the PATH |
The Swift compiles, keeps the enum values a newer server adds and sends them back |
KNDATA_JARS holds fastjson2, gson, jackson-core, jackson-databind, jackson-annotations,
kotlinx-serialization-core-jvm and kotlinx-serialization-json-jvm.
Changelog
See CHANGELOG.md.