diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d0d73b5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: MIT AND Palimpsest-0.8 +# SPDX-FileCopyrightText: 2024 Jonathan D.A. Jewell + +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x, 20.x] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Run tests + run: npm test + + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Check formatting (ReScript) + run: npm run build -- -warn-error +A + continue-on-error: true # Warnings shouldn't fail CI for now + + - name: Audit dependencies + run: npm audit --audit-level=high + continue-on-error: true # Don't fail on audit issues in dependencies diff --git a/examples/02_http/HttpExample.res b/examples/02_http/HttpExample.res new file mode 100644 index 0000000..80a141d --- /dev/null +++ b/examples/02_http/HttpExample.res @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: MIT AND Palimpsest-0.8 +// SPDX-FileCopyrightText: 2024 Jonathan D.A. Jewell + +@@ocaml.doc(" +HTTP example demonstrating data fetching with TEA. +Fetches users from JSONPlaceholder API with loading and error states. +") + +open Tea + +// ============================================================================ +// Types +// ============================================================================ + +type user = { + id: int, + name: string, + email: string, + username: string, +} + +type remoteData<'a, 'e> = + | NotAsked + | Loading + | Success('a) + | Failure('e) + +type model = { + users: remoteData, string>, +} + +type msg = + | FetchUsers + | GotUsers(result, Http.httpError>) + +// ============================================================================ +// Decoders +// ============================================================================ + +let userDecoder: Json.decoder = Json.map4( + (id, name, email, username) => {id, name, email, username}, + Json.field("id", Json.int), + Json.field("name", Json.string), + Json.field("email", Json.string), + Json.field("username", Json.string), +) + +let usersDecoder: Json.decoder> = Json.array(userDecoder) + +// ============================================================================ +// Init +// ============================================================================ + +let init = _ => ( + {users: NotAsked}, + Cmd.none, +) + +// ============================================================================ +// Update +// ============================================================================ + +let update = (msg, model) => { + switch msg { + | FetchUsers => ( + {...model, users: Loading}, + Http.getJson( + "https://jsonplaceholder.typicode.com/users", + usersDecoder, + result => GotUsers(result), + ), + ) + | GotUsers(Ok(users)) => ({...model, users: Success(users)}, Cmd.none) + | GotUsers(Error(err)) => ({...model, users: Failure(Http.errorToString(err))}, Cmd.none) + } +} + +// ============================================================================ +// View +// ============================================================================ + +let viewUser = (user: user) => { +
+
+ {React.string(user.name)} +
+
+ {React.string(`@${user.username}`)} +
+
+ {React.string(user.email)} +
+
+} + +let view = (model, dispatch) => { +
+

{React.string("Users")}

+ + {switch model.users { + | NotAsked => +

+ {React.string("Click the button to fetch users")} +

+ | Loading => +
+
{React.string("Loading...")}
+
+ | Success(users) => +
+

+ {React.string(`Loaded ${Belt.Int.toString(Belt.Array.length(users))} users`)} +

+ {users->Belt.Array.map(viewUser)->React.array} +
+ | Failure(error) => +
+ {React.string("Error: ")} + {React.string(error)} +
+ }} +
+} + +// ============================================================================ +// Subscriptions +// ============================================================================ + +let subscriptions = _model => Sub.none + +// ============================================================================ +// App +// ============================================================================ + +module App = MakeWithDispatch({ + type model = model + type msg = msg + type flags = unit + let init = init + let update = update + let view = view + let subscriptions = subscriptions +}) + +// ============================================================================ +// Mount +// ============================================================================ + +let mount = () => { + switch ReactDOM.querySelector("#root") { + | Some(root) => { + let rootElement = ReactDOM.Client.createRoot(root) + rootElement->ReactDOM.Client.Root.render() + } + | None => Js.Console.error("Could not find #root element") + } +} diff --git a/examples/02_http/index.html b/examples/02_http/index.html new file mode 100644 index 0000000..9a910b4 --- /dev/null +++ b/examples/02_http/index.html @@ -0,0 +1,25 @@ + + + + + + TEA HTTP Example + + + +
+ + + diff --git a/src/Tea.res b/src/Tea.res index ca141d0..2c95fb9 100644 --- a/src/Tea.res +++ b/src/Tea.res @@ -48,6 +48,7 @@ module Cmd = Tea_Cmd module Sub = Tea_Sub module Html = Tea_Html module Json = Tea_Json +module Http = Tea_Http // ============================================================================ // Application types and functors diff --git a/src/Tea.resi b/src/Tea.resi index 21ece65..cd3d2da 100644 --- a/src/Tea.resi +++ b/src/Tea.resi @@ -10,6 +10,7 @@ module Cmd = Tea_Cmd module Sub = Tea_Sub module Html = Tea_Html module Json = Tea_Json +module Http = Tea_Http // Application types type app<'flags, 'model, 'msg> = Tea_App.app<'flags, 'model, 'msg> diff --git a/src/Tea_Cmd.res b/src/Tea_Cmd.res index d3c4223..19a68ad 100644 --- a/src/Tea_Cmd.res +++ b/src/Tea_Cmd.res @@ -90,3 +90,8 @@ let rec execute = (cmd: t<'msg>, dispatch: 'msg => unit): unit => { let message = (msg: 'msg): t<'msg> => { Effect(dispatch => dispatch(msg)) } + +@ocaml.doc("Create a command from an effect callback. The callback receives dispatch and can call it asynchronously.") +let effect = (callback: ('msg => unit) => unit): t<'msg> => { + Effect(callback) +} diff --git a/src/Tea_Cmd.resi b/src/Tea_Cmd.resi index 356a18d..470c65c 100644 --- a/src/Tea_Cmd.resi +++ b/src/Tea_Cmd.resi @@ -29,3 +29,6 @@ let execute: (t<'msg>, 'msg => unit) => unit @ocaml.doc("Create a command that dispatches a message immediately") let message: 'msg => t<'msg> + +@ocaml.doc("Create a command from an effect callback. The callback receives dispatch and can call it asynchronously.") +let effect: (('msg => unit) => unit) => t<'msg> diff --git a/src/Tea_Http.res b/src/Tea_Http.res new file mode 100644 index 0000000..f2ce070 --- /dev/null +++ b/src/Tea_Http.res @@ -0,0 +1,383 @@ +// SPDX-License-Identifier: MIT AND Palimpsest-0.8 +// SPDX-FileCopyrightText: 2024 Jonathan D.A. Jewell + +@@ocaml.doc(" +HTTP commands for TEA applications. +Provides a type-safe way to make HTTP requests and decode responses. +") + +// ============================================================================ +// Types +// ============================================================================ + +@ocaml.doc("HTTP methods") +type method = + | GET + | POST + | PUT + | PATCH + | DELETE + | HEAD + | OPTIONS + +@ocaml.doc("HTTP headers as key-value pairs") +type header = (string, string) + +@ocaml.doc("HTTP error types") +type httpError = + | BadUrl(string) + | Timeout + | NetworkError(string) + | BadStatus(int, string) + | BadBody(Tea_Json.decodeError) + +@ocaml.doc("Request configuration") +type request<'a> = { + method: method, + url: string, + headers: array
, + body: option, + timeout: option, + decoder: Tea_Json.decoder<'a>, +} + +@ocaml.doc("Response type") +type response<'a> = { + url: string, + status: int, + statusText: string, + headers: Js.Dict.t, + body: 'a, +} + +// ============================================================================ +// Internal: Fetch bindings +// ============================================================================ + +module Internal = { + type fetchResponse + + @val external fetch: (string, 'options) => Js.Promise.t = "fetch" + + @get external responseOk: fetchResponse => bool = "ok" + @get external responseStatus: fetchResponse => int = "status" + @get external responseStatusText: fetchResponse => string = "statusText" + @get external responseUrl: fetchResponse => string = "url" + @send external responseText: fetchResponse => Js.Promise.t = "text" + @send external responseJson: fetchResponse => Js.Promise.t = "json" + + // Get headers as dict + let getHeaders: fetchResponse => Js.Dict.t = %raw(` + function(response) { + const headers = {}; + response.headers.forEach((value, key) => { + headers[key] = value; + }); + return headers; + } + `) + + let methodToString = method => + switch method { + | GET => "GET" + | POST => "POST" + | PUT => "PUT" + | PATCH => "PATCH" + | DELETE => "DELETE" + | HEAD => "HEAD" + | OPTIONS => "OPTIONS" + } + + let buildFetchOptions = (request: request<'a>) => { + let options = Js.Dict.empty() + + Js.Dict.set(options, "method", Js.Json.string(methodToString(request.method))) + + // Headers + if Belt.Array.length(request.headers) > 0 { + let headersDict = Js.Dict.empty() + Belt.Array.forEach(request.headers, ((key, value)) => { + Js.Dict.set(headersDict, key, value) + }) + Js.Dict.set(options, "headers", Obj.magic(headersDict)) + } + + // Body + switch request.body { + | Some(body) => Js.Dict.set(options, "body", Obj.magic(Js.Json.stringify(body))) + | None => () + } + + options + } + + // Timeout wrapper + let withTimeout = (promise: Js.Promise.t<'a>, timeoutMs: int): Js.Promise.t<'a> => { + %raw(` + function(promise, timeoutMs) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('TIMEOUT')); + }, timeoutMs); + + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + } + ); + }); + } + `)(promise, timeoutMs) + } +} + +// ============================================================================ +// Request builders +// ============================================================================ + +@ocaml.doc("Create a GET request") +let get = (url: string, decoder: Tea_Json.decoder<'a>): request<'a> => { + method: GET, + url, + headers: [], + body: None, + timeout: None, + decoder, +} + +@ocaml.doc("Create a POST request with JSON body") +let post = (url: string, body: Js.Json.t, decoder: Tea_Json.decoder<'a>): request<'a> => { + method: POST, + url, + headers: [("Content-Type", "application/json")], + body: Some(body), + timeout: None, + decoder, +} + +@ocaml.doc("Create a PUT request with JSON body") +let put = (url: string, body: Js.Json.t, decoder: Tea_Json.decoder<'a>): request<'a> => { + method: PUT, + url, + headers: [("Content-Type", "application/json")], + body: Some(body), + timeout: None, + decoder, +} + +@ocaml.doc("Create a PATCH request with JSON body") +let patch = (url: string, body: Js.Json.t, decoder: Tea_Json.decoder<'a>): request<'a> => { + method: PATCH, + url, + headers: [("Content-Type", "application/json")], + body: Some(body), + timeout: None, + decoder, +} + +@ocaml.doc("Create a DELETE request") +let delete = (url: string, decoder: Tea_Json.decoder<'a>): request<'a> => { + method: DELETE, + url, + headers: [], + body: None, + timeout: None, + decoder, +} + +// ============================================================================ +// Request modifiers +// ============================================================================ + +@ocaml.doc("Add a header to the request") +let withHeader = (request: request<'a>, key: string, value: string): request<'a> => { + ...request, + headers: Belt.Array.concat(request.headers, [(key, value)]), +} + +@ocaml.doc("Add multiple headers to the request") +let withHeaders = (request: request<'a>, headers: array
): request<'a> => { + ...request, + headers: Belt.Array.concat(request.headers, headers), +} + +@ocaml.doc("Set request timeout in milliseconds") +let withTimeout = (request: request<'a>, timeoutMs: int): request<'a> => { + ...request, + timeout: Some(timeoutMs), +} + +@ocaml.doc("Set the request body") +let withBody = (request: request<'a>, body: Js.Json.t): request<'a> => { + ...request, + body: Some(body), + headers: if !Belt.Array.some(request.headers, ((k, _)) => k == "Content-Type") { + Belt.Array.concat(request.headers, [("Content-Type", "application/json")]) + } else { + request.headers + }, +} + +// ============================================================================ +// Error helpers +// ============================================================================ + +@ocaml.doc("Convert HTTP error to string for display") +let errorToString = (error: httpError): string => + switch error { + | BadUrl(url) => `Invalid URL: ${url}` + | Timeout => "Request timed out" + | NetworkError(msg) => `Network error: ${msg}` + | BadStatus(status, statusText) => `HTTP ${Belt.Int.toString(status)}: ${statusText}` + | BadBody(decodeError) => `Failed to decode response: ${Tea_Json.errorToString(decodeError)}` + } + +// ============================================================================ +// Send commands +// ============================================================================ + +@ocaml.doc("Send an HTTP request and handle the result") +let send = (request: request<'a>, toMsg: result<'a, httpError> => 'msg): Tea_Cmd.t<'msg> => { + Tea_Cmd.effect(dispatch => { + let fetchPromise = Internal.fetch(request.url, Internal.buildFetchOptions(request)) + + let promiseWithTimeout = switch request.timeout { + | Some(ms) => Internal.withTimeout(fetchPromise, ms) + | None => fetchPromise + } + + let _ = promiseWithTimeout + ->Js.Promise.then_(response => { + if Internal.responseOk(response) { + Internal.responseJson(response) + ->Js.Promise.then_(json => { + switch Tea_Json.decodeValue(request.decoder, json) { + | Ok(value) => dispatch(toMsg(Ok(value))) + | Error(decodeError) => dispatch(toMsg(Error(BadBody(decodeError)))) + } + Js.Promise.resolve() + }, _) + ->Js.Promise.catch(_ => { + // JSON parse failed, try as text + dispatch(toMsg(Error(BadBody(Tea_Json.Failure("Invalid JSON", Js.Json.null))))) + Js.Promise.resolve() + }, _) + } else { + dispatch( + toMsg( + Error(BadStatus(Internal.responseStatus(response), Internal.responseStatusText(response))), + ), + ) + Js.Promise.resolve() + } + }, _) + ->Js.Promise.catch(error => { + let errorMsg = Obj.magic(error)["message"] + let httpError = if errorMsg == "TIMEOUT" { + Timeout + } else { + NetworkError(errorMsg) + } + dispatch(toMsg(Error(httpError))) + Js.Promise.resolve() + }, _) + + () + }) +} + +@ocaml.doc("Send a request expecting a full response object") +let sendWithResponse = ( + request: request<'a>, + toMsg: result, httpError> => 'msg, +): Tea_Cmd.t<'msg> => { + Tea_Cmd.effect(dispatch => { + let fetchPromise = Internal.fetch(request.url, Internal.buildFetchOptions(request)) + + let promiseWithTimeout = switch request.timeout { + | Some(ms) => Internal.withTimeout(fetchPromise, ms) + | None => fetchPromise + } + + let _ = promiseWithTimeout + ->Js.Promise.then_(fetchResponse => { + if Internal.responseOk(fetchResponse) { + Internal.responseJson(fetchResponse) + ->Js.Promise.then_(json => { + switch Tea_Json.decodeValue(request.decoder, json) { + | Ok(value) => { + let response: response<'a> = { + url: Internal.responseUrl(fetchResponse), + status: Internal.responseStatus(fetchResponse), + statusText: Internal.responseStatusText(fetchResponse), + headers: Internal.getHeaders(fetchResponse), + body: value, + } + dispatch(toMsg(Ok(response))) + } + | Error(decodeError) => dispatch(toMsg(Error(BadBody(decodeError)))) + } + Js.Promise.resolve() + }, _) + ->Js.Promise.catch(_ => { + dispatch(toMsg(Error(BadBody(Tea_Json.Failure("Invalid JSON", Js.Json.null))))) + Js.Promise.resolve() + }, _) + } else { + dispatch( + toMsg( + Error( + BadStatus(Internal.responseStatus(fetchResponse), Internal.responseStatusText(fetchResponse)), + ), + ), + ) + Js.Promise.resolve() + } + }, _) + ->Js.Promise.catch(error => { + let errorMsg = Obj.magic(error)["message"] + let httpError = if errorMsg == "TIMEOUT" { + Timeout + } else { + NetworkError(errorMsg) + } + dispatch(toMsg(Error(httpError))) + Js.Promise.resolve() + }, _) + + () + }) +} + +// ============================================================================ +// Convenience functions +// ============================================================================ + +@ocaml.doc("Simple GET request - just URL and decoder") +let getString = (url: string, toMsg: result => 'msg): Tea_Cmd.t<'msg> => { + send(get(url, Tea_Json.string), toMsg) +} + +@ocaml.doc("GET request with JSON decoder") +let getJson = ( + url: string, + decoder: Tea_Json.decoder<'a>, + toMsg: result<'a, httpError> => 'msg, +): Tea_Cmd.t<'msg> => { + send(get(url, decoder), toMsg) +} + +@ocaml.doc("POST request with JSON body and decoder") +let postJson = ( + url: string, + body: Js.Json.t, + decoder: Tea_Json.decoder<'a>, + toMsg: result<'a, httpError> => 'msg, +): Tea_Cmd.t<'msg> => { + send(post(url, body, decoder), toMsg) +} diff --git a/src/Tea_Http.resi b/src/Tea_Http.resi new file mode 100644 index 0000000..86cf376 --- /dev/null +++ b/src/Tea_Http.resi @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MIT AND Palimpsest-0.8 +// SPDX-FileCopyrightText: 2024 Jonathan D.A. Jewell + +@@ocaml.doc(" +HTTP commands for TEA applications. +Provides a type-safe way to make HTTP requests and decode responses. +") + +@ocaml.doc("HTTP methods") +type method = + | GET + | POST + | PUT + | PATCH + | DELETE + | HEAD + | OPTIONS + +@ocaml.doc("HTTP headers as key-value pairs") +type header = (string, string) + +@ocaml.doc("HTTP error types") +type httpError = + | BadUrl(string) + | Timeout + | NetworkError(string) + | BadStatus(int, string) + | BadBody(Tea_Json.decodeError) + +@ocaml.doc("Request configuration") +type request<'a> + +@ocaml.doc("Response type with full metadata") +type response<'a> = { + url: string, + status: int, + statusText: string, + headers: Js.Dict.t, + body: 'a, +} + +// ============================================================================ +// Request builders +// ============================================================================ + +@ocaml.doc("Create a GET request") +let get: (string, Tea_Json.decoder<'a>) => request<'a> + +@ocaml.doc("Create a POST request with JSON body") +let post: (string, Js.Json.t, Tea_Json.decoder<'a>) => request<'a> + +@ocaml.doc("Create a PUT request with JSON body") +let put: (string, Js.Json.t, Tea_Json.decoder<'a>) => request<'a> + +@ocaml.doc("Create a PATCH request with JSON body") +let patch: (string, Js.Json.t, Tea_Json.decoder<'a>) => request<'a> + +@ocaml.doc("Create a DELETE request") +let delete: (string, Tea_Json.decoder<'a>) => request<'a> + +// ============================================================================ +// Request modifiers +// ============================================================================ + +@ocaml.doc("Add a header to the request") +let withHeader: (request<'a>, string, string) => request<'a> + +@ocaml.doc("Add multiple headers to the request") +let withHeaders: (request<'a>, array
) => request<'a> + +@ocaml.doc("Set request timeout in milliseconds") +let withTimeout: (request<'a>, int) => request<'a> + +@ocaml.doc("Set the request body") +let withBody: (request<'a>, Js.Json.t) => request<'a> + +// ============================================================================ +// Error helpers +// ============================================================================ + +@ocaml.doc("Convert HTTP error to string for display") +let errorToString: httpError => string + +// ============================================================================ +// Send commands +// ============================================================================ + +@ocaml.doc("Send an HTTP request and handle the result") +let send: (request<'a>, result<'a, httpError> => 'msg) => Tea_Cmd.t<'msg> + +@ocaml.doc("Send a request expecting a full response object") +let sendWithResponse: (request<'a>, result, httpError> => 'msg) => Tea_Cmd.t<'msg> + +// ============================================================================ +// Convenience functions +// ============================================================================ + +@ocaml.doc("Simple GET request for a string response") +let getString: (string, result => 'msg) => Tea_Cmd.t<'msg> + +@ocaml.doc("GET request with JSON decoder") +let getJson: (string, Tea_Json.decoder<'a>, result<'a, httpError> => 'msg) => Tea_Cmd.t<'msg> + +@ocaml.doc("POST request with JSON body and decoder") +let postJson: (string, Js.Json.t, Tea_Json.decoder<'a>, result<'a, httpError> => 'msg) => Tea_Cmd.t<'msg> diff --git a/test/Tea_Cmd_test.res b/test/Tea_Cmd_test.res new file mode 100644 index 0000000..00abbd1 --- /dev/null +++ b/test/Tea_Cmd_test.res @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT AND Palimpsest-0.8 +// SPDX-FileCopyrightText: 2024 Jonathan D.A. Jewell + +@@ocaml.doc("Tests for Tea_Cmd module") + +// Node.js test bindings +@module("node:test") external test: (string, unit => unit) => unit = "test" +@module("node:assert") external strictEqual: ('a, 'a) => unit = "strictEqual" +@module("node:assert") external deepStrictEqual: ('a, 'a) => unit = "deepStrictEqual" +@module("node:assert") external ok: bool => unit = "ok" + +// ============================================================================ +// Tests for Tea_Cmd.none +// ============================================================================ + +test("Cmd.none does nothing when executed", () => { + let dispatched = ref(false) + Tea_Cmd.execute(Tea_Cmd.none, _ => dispatched := true) + // Give a moment for any async execution + ok(!dispatched.contents) +}) + +// ============================================================================ +// Tests for Tea_Cmd.batch +// ============================================================================ + +test("Cmd.batch with empty array returns none equivalent", () => { + let dispatched = ref(false) + Tea_Cmd.execute(Tea_Cmd.batch([]), _ => dispatched := true) + ok(!dispatched.contents) +}) + +test("Cmd.batch with single command returns that command", () => { + let count = ref(0) + let cmd = Tea_Cmd.message(1) + let batched = Tea_Cmd.batch([cmd]) + Tea_Cmd.execute(batched, msg => count := count.contents + msg) + // message is executed via setTimeout, so we can't check immediately + // This test verifies no exception is thrown + ok(true) +}) + +// ============================================================================ +// Tests for Tea_Cmd.message +// ============================================================================ + +test("Cmd.message creates a command that dispatches the message", () => { + let cmd = Tea_Cmd.message(42) + // Verify command was created without error + ok(true) +}) + +// ============================================================================ +// Tests for Tea_Cmd.map +// ============================================================================ + +test("Cmd.map transforms none to none", () => { + let mapped = Tea_Cmd.map(x => x * 2, Tea_Cmd.none) + let dispatched = ref(false) + Tea_Cmd.execute(mapped, _ => dispatched := true) + ok(!dispatched.contents) +}) + +test("Cmd.map transforms message command", () => { + let cmd = Tea_Cmd.message(5) + let mapped = Tea_Cmd.map(x => x * 2, cmd) + // Verify transformation was created without error + ok(true) +}) + +// ============================================================================ +// Tests for Tea_Cmd.effect +// ============================================================================ + +test("Cmd.effect creates an effect command", () => { + let executed = ref(false) + let cmd = Tea_Cmd.effect(_ => executed := true) + Tea_Cmd.execute(cmd, _ => ()) + // effect is executed via setTimeout + ok(true) +}) diff --git a/test/Tea_Json_test.res b/test/Tea_Json_test.res new file mode 100644 index 0000000..2b13e9b --- /dev/null +++ b/test/Tea_Json_test.res @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: MIT AND Palimpsest-0.8 +// SPDX-FileCopyrightText: 2024 Jonathan D.A. Jewell + +@@ocaml.doc("Tests for Tea_Json module") + +// Node.js test bindings +@module("node:test") external test: (string, unit => unit) => unit = "test" +@module("node:assert") external strictEqual: ('a, 'a) => unit = "strictEqual" +@module("node:assert") external deepStrictEqual: ('a, 'a) => unit = "deepStrictEqual" +@module("node:assert") external ok: bool => unit = "ok" +@module("node:assert") external fail: string => unit = "fail" + +// ============================================================================ +// Helper +// ============================================================================ + +let assertOk = result => + switch result { + | Ok(_) => ok(true) + | Error(err) => fail(Tea_Json.errorToString(err)) + } + +let assertError = result => + switch result { + | Ok(_) => fail("Expected error but got Ok") + | Error(_) => ok(true) + } + +let assertEqualResult = (result, expected) => + switch result { + | Ok(value) => deepStrictEqual(value, expected) + | Error(err) => fail(Tea_Json.errorToString(err)) + } + +// ============================================================================ +// Tests for string decoder +// ============================================================================ + +test("string decoder succeeds on string", () => { + let json = Js.Json.string("hello") + assertEqualResult(Tea_Json.decodeValue(Tea_Json.string, json), "hello") +}) + +test("string decoder fails on number", () => { + let json = Js.Json.number(42.0) + assertError(Tea_Json.decodeValue(Tea_Json.string, json)) +}) + +// ============================================================================ +// Tests for int decoder +// ============================================================================ + +test("int decoder succeeds on integer", () => { + let json = Js.Json.number(42.0) + assertEqualResult(Tea_Json.decodeValue(Tea_Json.int, json), 42) +}) + +test("int decoder fails on float", () => { + let json = Js.Json.number(42.5) + assertError(Tea_Json.decodeValue(Tea_Json.int, json)) +}) + +test("int decoder fails on string", () => { + let json = Js.Json.string("42") + assertError(Tea_Json.decodeValue(Tea_Json.int, json)) +}) + +// ============================================================================ +// Tests for float decoder +// ============================================================================ + +test("float decoder succeeds on number", () => { + let json = Js.Json.number(3.14) + assertEqualResult(Tea_Json.decodeValue(Tea_Json.float, json), 3.14) +}) + +test("float decoder fails on string", () => { + let json = Js.Json.string("3.14") + assertError(Tea_Json.decodeValue(Tea_Json.float, json)) +}) + +// ============================================================================ +// Tests for bool decoder +// ============================================================================ + +test("bool decoder succeeds on true", () => { + let json = Js.Json.boolean(true) + assertEqualResult(Tea_Json.decodeValue(Tea_Json.bool, json), true) +}) + +test("bool decoder succeeds on false", () => { + let json = Js.Json.boolean(false) + assertEqualResult(Tea_Json.decodeValue(Tea_Json.bool, json), false) +}) + +test("bool decoder fails on string", () => { + let json = Js.Json.string("true") + assertError(Tea_Json.decodeValue(Tea_Json.bool, json)) +}) + +// ============================================================================ +// Tests for field decoder +// ============================================================================ + +test("field decoder extracts field from object", () => { + let json = Js.Json.parseExn(`{"name": "Alice"}`) + let decoder = Tea_Json.field("name", Tea_Json.string) + assertEqualResult(Tea_Json.decodeValue(decoder, json), "Alice") +}) + +test("field decoder fails on missing field", () => { + let json = Js.Json.parseExn(`{"other": "value"}`) + let decoder = Tea_Json.field("name", Tea_Json.string) + assertError(Tea_Json.decodeValue(decoder, json)) +}) + +test("field decoder fails on non-object", () => { + let json = Js.Json.string("not an object") + let decoder = Tea_Json.field("name", Tea_Json.string) + assertError(Tea_Json.decodeValue(decoder, json)) +}) + +// ============================================================================ +// Tests for array decoder +// ============================================================================ + +test("array decoder succeeds on array of strings", () => { + let json = Js.Json.parseExn(`["a", "b", "c"]`) + let decoder = Tea_Json.array(Tea_Json.string) + assertEqualResult(Tea_Json.decodeValue(decoder, json), ["a", "b", "c"]) +}) + +test("array decoder succeeds on empty array", () => { + let json = Js.Json.parseExn(`[]`) + let decoder = Tea_Json.array(Tea_Json.int) + assertEqualResult(Tea_Json.decodeValue(decoder, json), []) +}) + +test("array decoder fails if element fails", () => { + let json = Js.Json.parseExn(`[1, "two", 3]`) + let decoder = Tea_Json.array(Tea_Json.int) + assertError(Tea_Json.decodeValue(decoder, json)) +}) + +// ============================================================================ +// Tests for map decoder +// ============================================================================ + +test("map transforms decoded value", () => { + let json = Js.Json.number(5.0) + let decoder = Tea_Json.map(x => x * 2, Tea_Json.int) + assertEqualResult(Tea_Json.decodeValue(decoder, json), 10) +}) + +// ============================================================================ +// Tests for map2 decoder +// ============================================================================ + +test("map2 combines two fields", () => { + let json = Js.Json.parseExn(`{"x": 10, "y": 20}`) + let decoder = Tea_Json.map2( + (x, y) => x + y, + Tea_Json.field("x", Tea_Json.int), + Tea_Json.field("y", Tea_Json.int), + ) + assertEqualResult(Tea_Json.decodeValue(decoder, json), 30) +}) + +// ============================================================================ +// Tests for oneOf decoder +// ============================================================================ + +test("oneOf succeeds with first matching decoder", () => { + let json = Js.Json.string("hello") + // Both decoders must return same type - use string for both + let decoder = Tea_Json.oneOf([ + Tea_Json.map(x => `int:${Belt.Int.toString(x)}`, Tea_Json.int), + Tea_Json.map(s => s, Tea_Json.string), + ]) + assertEqualResult(Tea_Json.decodeValue(decoder, json), "hello") +}) + +test("oneOf fails if none match", () => { + let json = Js.Json.boolean(true) + // Both decoders return int + let decoder = Tea_Json.oneOf([ + Tea_Json.int, + Tea_Json.map(_ => 0, Tea_Json.string), + ]) + assertError(Tea_Json.decodeValue(decoder, json)) +}) + +// ============================================================================ +// Tests for optional decoder +// ============================================================================ + +test("optional returns Some for valid value", () => { + let json = Js.Json.string("hello") + let decoder = Tea_Json.optional(Tea_Json.string) + assertEqualResult(Tea_Json.decodeValue(decoder, json), Some("hello")) +}) + +test("optional returns None for null", () => { + let json = Js.Json.null + let decoder = Tea_Json.optional(Tea_Json.string) + assertEqualResult(Tea_Json.decodeValue(decoder, json), None) +}) + +// ============================================================================ +// Tests for decodeString +// ============================================================================ + +test("decodeString parses and decodes JSON string", () => { + assertEqualResult(Tea_Json.decodeString(Tea_Json.int, "42"), 42) +}) + +test("decodeString fails on invalid JSON", () => { + assertError(Tea_Json.decodeString(Tea_Json.int, "not json")) +}) + +// ============================================================================ +// Tests for succeed and fail +// ============================================================================ + +test("succeed always returns the given value", () => { + let json = Js.Json.null + let decoder = Tea_Json.succeed(42) + assertEqualResult(Tea_Json.decodeValue(decoder, json), 42) +}) + +test("fail always returns an error", () => { + let json = Js.Json.null + let decoder = Tea_Json.fail("always fails") + assertError(Tea_Json.decodeValue(decoder, json)) +}) + +// ============================================================================ +// Tests for errorToString +// ============================================================================ + +test("errorToString formats Failure error", () => { + let err = Tea_Json.Failure("test error", Js.Json.null) + let str = Tea_Json.errorToString(err) + ok(Js.String2.includes(str, "test error")) +})