From 45e4bf3fb67678b277b6a6b359d79918706be40e Mon Sep 17 00:00:00 2001 From: GGOBP Date: Mon, 7 Sep 2026 14:48:50 +0900 Subject: [PATCH] refactor(js)!: redesign JSON serialization and parsing around gleam_json Replace the handwritten JSON FFI in glendix/js/json with gleam_json. - stringify now delegates to gleam/json.to_string (source-compatible). - parse now takes an explicit decode.Decoder and returns the decoded value, delegating to gleam/json.parse. - JsonError distinguishes deterministic syntax failures (InvalidSyntax with JsonSyntaxError) from decoder/type failures (DecoderMismatch), mapped in pure Gleam without JavaScript engine-specific text. - Remove src/glendix/js/json_ffi.mjs and its externals. - Cover null, scalars, escaped strings, arrays, objects, malformed input, unexpected byte, and decoder mismatch (incl. nested path). - Document the migration in README.md/.ko/.ja. Closes #13 --- README.ja.md | 33 ++++++++ README.ko.md | 32 ++++++++ README.md | 34 ++++++++ src/glendix/js/json.gleam | 60 ++++++++------ src/glendix/js/json_ffi.mjs | 16 ---- test/glendix/js/json_test.gleam | 133 ++++++++++++++++++++++++++++++++ test/glendix_test.gleam | 13 ---- 7 files changed, 270 insertions(+), 51 deletions(-) delete mode 100644 src/glendix/js/json_ffi.mjs create mode 100644 test/glendix/js/json_test.gleam diff --git a/README.ja.md b/README.ja.md index 6e5f993..27a777d 100644 --- a/README.ja.md +++ b/README.ja.md @@ -26,6 +26,39 @@ glendix = ">= 6.0.0 and < 7.0.0" Mendix クライアント値または MPK コンポーネントが必要な場合だけ `mendraw`、 パッケージ取得が必要な場合だけ `mxpak` を利用します。 +## 型付き JSON ヘルパー + +`glendix/js/json` はシリアライズとパースを `gleam_json` に委譲します。 +シリアライズ API は以前のまま利用できます。 + +```gleam +import gleam/json +import glendix/js/json as glendix_json + +glendix_json.stringify(value: json.int(42)) +``` + +Glendix 6 以降、パース時には期待する結果型の decoder を指定します。 + +```gleam +import gleam/dynamic/decode +import glendix/js/json as glendix_json + +glendix_json.parse( + from: "{\"name\":\"glendix\"}", + using: { + use name <- decode.field("name", decode.string) + decode.success(name) + }, +) +``` + +6 より前の `parse(from: source)` 呼び出しは +`parse(from: source, using: decoder)` に移行してください。構文エラーは +`InvalidSyntax`、有効な JSON が decoder と一致しない場合は +`DecoderMismatch` を返します。どちらも決定的で、JavaScript engine 固有の +例外メッセージを公開しません。 + ## 実験的ネイティブパッケージマネージャー Glendix はプロジェクトのパッケージマネージャー・JavaScript runtime と diff --git a/README.ko.md b/README.ko.md index 378fece..7189632 100644 --- a/README.ko.md +++ b/README.ko.md @@ -25,6 +25,38 @@ glendix = ">= 6.0.0 and < 7.0.0" Mendix 클라이언트 값이나 MPK 컴포넌트가 필요할 때만 `mendraw`를 추가하고, 패키지 설치가 필요할 때만 `mxpak`을 사용한다. +## 타입 기반 JSON 도우미 + +`glendix/js/json`은 직렬화와 파싱을 `gleam_json`에 위임한다. 직렬화 API는 +기존과 호환된다. + +```gleam +import gleam/json +import glendix/js/json as glendix_json + +glendix_json.stringify(value: json.int(42)) +``` + +Glendix 6부터 파싱할 때는 기대하는 결과 타입의 decoder를 명시해야 한다. + +```gleam +import gleam/dynamic/decode +import glendix/js/json as glendix_json + +glendix_json.parse( + from: "{\"name\":\"glendix\"}", + using: { + use name <- decode.field("name", decode.string) + decode.success(name) + }, +) +``` + +6 이전의 `parse(from: source)` 호출은 +`parse(from: source, using: decoder)`로 변경한다. 문법적으로 잘못된 JSON은 +`InvalidSyntax`, 유효한 JSON이 decoder와 맞지 않으면 `DecoderMismatch`를 +반환한다. 두 오류 모두 결정적이며 JavaScript 엔진별 예외 메시지를 노출하지 않는다. + ## 실험적 네이티브 패키지 매니저 Glendix는 프로젝트의 패키지 매니저·JavaScript 런타임과 Mendix Pluggable diff --git a/README.md b/README.md index 34a773f..05f432e 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,40 @@ components, and add/use `mxpak` only when package acquisition is required. A widget project's `package.json` normally includes the Mendix Pluggable Widgets Tools and their React peer dependencies. +## Typed JSON helpers + +`glendix/js/json` delegates serialization and parsing to `gleam_json`. +Serialization remains source-compatible: + +```gleam +import gleam/json +import glendix/js/json as glendix_json + +glendix_json.stringify(value: json.int(42)) +``` + +Starting with Glendix 6, parsing requires a decoder for the expected result +type: + +```gleam +import gleam/dynamic/decode +import glendix/js/json as glendix_json + +glendix_json.parse( + from: "{\"name\":\"glendix\"}", + using: { + use name <- decode.field("name", decode.string) + decode.success(name) + }, +) +``` + +Migrate a pre-6 call from `parse(from: source)` to +`parse(from: source, using: decoder)`. Invalid JSON returns `InvalidSyntax`, +while valid JSON that does not match the decoder returns `DecoderMismatch`. +Both error forms are deterministic and do not expose JavaScript-engine exception +messages. + ## Experimental native package managers Glendix can isolate Mendix Pluggable Widgets Tools from the project's package diff --git a/src/glendix/js/json.gleam b/src/glendix/js/json.gleam index f09eb1e..c4ff62a 100644 --- a/src/glendix/js/json.gleam +++ b/src/glendix/js/json.gleam @@ -1,36 +1,52 @@ -//// Serializes and parses typed `gleam/json` values in JavaScript. +//// Serializes `gleam/json` values and parses JSON into caller-selected types. //// +import gleam/dynamic/decode import gleam/json import gleam/result -/// Represents a JSON parsing failure while preserving the JavaScript reason. +/// Describes why JSON text could not be parsed into the requested type. pub type JsonError { - /// JavaScript rejected the supplied JSON text. - InvalidJson(reason: String) + /// The input is not syntactically valid JSON. + InvalidSyntax(reason: JsonSyntaxError) + /// The JSON value does not match the requested decoder. + DecoderMismatch(errors: List(decode.DecodeError)) +} + +/// Describes a deterministic JSON syntax failure. +pub type JsonSyntaxError { + /// The input ended before the JSON value was complete. + UnexpectedEndOfInput + /// The parser encountered an invalid byte. + UnexpectedByte(byte: String) + /// The parser encountered an invalid sequence. + UnexpectedSequence(sequence: String) } /// Serializes a JSON value. pub fn stringify(value value: json.Json) -> String { - stringify_raw(value) + json.to_string(value) } -/// Parses JSON text into a typed JSON value. -pub fn parse(from source: String) -> Result(json.Json, JsonError) { - parse_raw(source) - |> result.map_error(fn(error) { - InvalidJson(reason: raw_json_error_message(error)) - }) +/// Parses JSON text with an explicit decoder for the expected result type. +/// +/// Before Glendix 6, this function returned an untyped `json.Json` value. +/// Callers now provide a decoder so invalid value shapes fail at this boundary. +pub fn parse( + from source: String, + using decoder: decode.Decoder(value), +) -> Result(value, JsonError) { + json.parse(from: source, using: decoder) + |> result.map_error(map_decode_error) } -type RawJsonError - -// -- FFI -- -@external(javascript, "./json_ffi.mjs", "json_stringify") -fn stringify_raw(value: json.Json) -> String - -@external(javascript, "./json_ffi.mjs", "json_parse") -fn parse_raw(source: String) -> Result(json.Json, RawJsonError) - -@external(javascript, "./json_ffi.mjs", "json_error_message") -fn raw_json_error_message(error: RawJsonError) -> String +fn map_decode_error(error: json.DecodeError) -> JsonError { + case error { + json.UnexpectedEndOfInput -> InvalidSyntax(reason: UnexpectedEndOfInput) + json.UnexpectedByte(byte) -> + InvalidSyntax(reason: UnexpectedByte(byte: byte)) + json.UnexpectedSequence(sequence) -> + InvalidSyntax(reason: UnexpectedSequence(sequence: sequence)) + json.UnableToDecode(errors) -> DecoderMismatch(errors: errors) + } +} diff --git a/src/glendix/js/json_ffi.mjs b/src/glendix/js/json_ffi.mjs deleted file mode 100644 index e092e35..0000000 --- a/src/glendix/js/json_ffi.mjs +++ /dev/null @@ -1,16 +0,0 @@ -// JSON FFI -import { Ok, Error as GleamError } from "../../gleam.mjs"; -export function json_stringify(value) { - return JSON.stringify(value); -} -export function json_parse(json) { - try { - return new Ok(JSON.parse(json)); - } catch (e) { - const reason = e instanceof globalThis.Error ? e.message : String(e); - return new GleamError(reason); - } -} -export function json_error_message(error) { - return error; -} diff --git a/test/glendix/js/json_test.gleam b/test/glendix/js/json_test.gleam new file mode 100644 index 0000000..658c0a8 --- /dev/null +++ b/test/glendix/js/json_test.gleam @@ -0,0 +1,133 @@ +//// Tests typed JSON serialization, parsing, and deterministic error mapping. +//// + +import gleam/dynamic/decode +import gleam/json +import gleam/list +import gleam/option +import gleeunit/should +import glendix/js/json as glendix_json + +/// Verifies serialization remains compact for null, scalars, arrays, and objects. +pub fn stringify_supported_values_test() -> Nil { + [ + #(json.null(), "null"), + #(json.string("line\n\"quoted\""), "\"line\\n\\\"quoted\\\"\""), + #(json.int(42), "42"), + #(json.float(3.5), "3.5"), + #(json.bool(True), "true"), + #(json.array(from: [1, 2, 3], of: json.int), "[1,2,3]"), + #( + json.object([ + #("name", json.string("glendix")), + #("enabled", json.bool(True)), + ]), + "{\"name\":\"glendix\",\"enabled\":true}", + ), + #(json.array(from: [], of: json.int), "[]"), + #(json.object([]), "{}"), + ] + |> list.each(fn(example) { + let #(value, expected) = example + glendix_json.stringify(value: value) + |> should.equal(expected) + }) +} + +/// Verifies null is accepted only through a decoder that models its absence. +pub fn parse_null_test() -> Nil { + glendix_json.parse(from: "null", using: decode.optional(decode.string)) + |> should.equal(Ok(option.None)) +} + +/// Verifies typed scalar parsing covers escaped strings, numbers, and booleans. +pub fn parse_scalar_values_test() -> Nil { + glendix_json.parse(from: "\"line\\n\\\"quoted\\\"\"", using: decode.string) + |> should.equal(Ok("line\n\"quoted\"")) + + glendix_json.parse(from: "42", using: decode.int) + |> should.equal(Ok(42)) + + glendix_json.parse(from: "3.5", using: decode.float) + |> should.equal(Ok(3.5)) + + glendix_json.parse(from: "true", using: decode.bool) + |> should.equal(Ok(True)) +} + +/// Verifies typed array parsing preserves empty and ordered values. +pub fn parse_array_values_test() -> Nil { + ["[]", "[1,2,3]"] + |> list.map(fn(source) { + glendix_json.parse(from: source, using: decode.list(of: decode.int)) + }) + |> should.equal([Ok([]), Ok([1, 2, 3])]) +} + +/// Verifies object fields are decoded into a caller-selected result type. +pub fn parse_object_value_test() -> Nil { + glendix_json.parse( + from: "{\"name\":\"glendix\",\"enabled\":true}", + using: name_and_enabled_decoder(), + ) + |> should.equal(Ok(#("glendix", True))) +} + +/// Verifies malformed input has stable syntax errors without engine messages. +pub fn parse_malformed_json_test() -> Nil { + ["", "["] + |> list.each(fn(source) { + glendix_json.parse(from: source, using: decode.list(of: decode.int)) + |> should.equal( + Error(glendix_json.InvalidSyntax( + reason: glendix_json.UnexpectedEndOfInput, + )), + ) + }) +} + +/// Verifies an invalid leading byte is reported without an exception string. +pub fn parse_unexpected_byte_test() -> Nil { + case glendix_json.parse(from: "?", using: decode.int) { + Error(glendix_json.InvalidSyntax(reason: glendix_json.UnexpectedByte( + byte: _, + ))) -> Nil + Ok(_) -> should.fail() + Error(_) -> should.fail() + } +} + +/// Verifies valid JSON with an incompatible decoder preserves typed details. +pub fn parse_decoder_mismatch_test() -> Nil { + glendix_json.parse(from: "\"not an int\"", using: decode.int) + |> should.equal( + Error( + glendix_json.DecoderMismatch(errors: [ + decode.DecodeError(expected: "Int", found: "String", path: []), + ]), + ), + ) +} + +/// Verifies nested decoder errors preserve their deterministic field path. +pub fn parse_nested_decoder_mismatch_path_test() -> Nil { + glendix_json.parse(from: "{\"count\":\"many\"}", using: count_decoder()) + |> should.equal( + Error( + glendix_json.DecoderMismatch(errors: [ + decode.DecodeError(expected: "Int", found: "String", path: ["count"]), + ]), + ), + ) +} + +fn name_and_enabled_decoder() -> decode.Decoder(#(String, Bool)) { + use name <- decode.field("name", decode.string) + use enabled <- decode.field("enabled", decode.bool) + decode.success(#(name, enabled)) +} + +fn count_decoder() -> decode.Decoder(Int) { + use count <- decode.field("count", decode.int) + decode.success(count) +} diff --git a/test/glendix_test.gleam b/test/glendix_test.gleam index 5681626..78a70f4 100644 --- a/test/glendix_test.gleam +++ b/test/glendix_test.gleam @@ -3,7 +3,6 @@ import gleam/javascript/array as javascript_array import gleam/javascript/promise -import gleam/json import gleam/list import gleam/string import gleeunit @@ -15,7 +14,6 @@ import glendix/define/model import glendix/define/ui import glendix/js/array import glendix/js/environment -import glendix/js/json as javascript_json import glendix/js/object import glendix/js/promise as glendix_promise import glendix/lustre @@ -237,17 +235,6 @@ pub fn javascript_array_string_round_trip_test() -> Nil { |> should.equal(["glendix", "js", "array"]) } -/// Verifies the JavaScript JSON FFI parses and serializes typed JSON values. -pub fn javascript_json_round_trip_test() -> Nil { - let original = json.object([#("name", json.string("glendix"))]) - original - |> javascript_json.stringify - |> javascript_json.parse - |> should.be_ok - |> javascript_json.stringify - |> should.equal(javascript_json.stringify(original)) -} - /// Verifies Lustre conversion uses stable discriminants instead of constructor names. pub fn lustre_foreign_constructor_render_contract_test() -> Nil { let tree =