Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 と
Expand Down
32 changes: 32 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 38 additions & 22 deletions src/glendix/js/json.gleam
Original file line number Diff line number Diff line change
@@ -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)
}
}
16 changes: 0 additions & 16 deletions src/glendix/js/json_ffi.mjs

This file was deleted.

133 changes: 133 additions & 0 deletions test/glendix/js/json_test.gleam
Original file line number Diff line number Diff line change
@@ -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)
}
13 changes: 0 additions & 13 deletions test/glendix_test.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 =
Expand Down
Loading