diff --git a/.well-known/agent-skills/index.json b/.well-known/agent-skills/index.json index d00120d..3b5d9fe 100644 --- a/.well-known/agent-skills/index.json +++ b/.well-known/agent-skills/index.json @@ -90,9 +90,14 @@ "description": "Use when transforming plain objects functionally — Record utilities for map/filter/pick/omit/reduce, plus partition and filterMap for advanced object manipulation.", "files": ["SKILL.md"] }, + { + "name": "tsentials-string", + "description": "Use when converting string casing — toPascalCase/toCamelCase/toKebabCase/toSnakeCase/toMacroCase/toTrainCase/toTitleCase/toUnderscoreCamelCase, all word-boundary aware (camelCase, PascalCase, consecutive-uppercase runs like \"XMLParser\", whitespace, hyphens, underscores).", + "files": ["SKILL.md"] + }, { "name": "tsentials-meta", - "description": "Use when deciding which tsentials module to use — overview of all 18 subpath imports organized by concern, install command, and a quick-reference table mapping problems to modules.", + "description": "Use when deciding which tsentials module to use — overview of all 19 subpath imports organized by concern, install command, and a quick-reference table mapping problems to modules.", "files": ["SKILL.md"] } ] diff --git a/.well-known/agent-skills/tsentials-clone/SKILL.md b/.well-known/agent-skills/tsentials-clone/SKILL.md index d8010de..872c488 100644 --- a/.well-known/agent-skills/tsentials-clone/SKILL.md +++ b/.well-known/agent-skills/tsentials-clone/SKILL.md @@ -59,7 +59,7 @@ Hybrid implementation: tries native `structuredClone()` first; if unavailable (R | Type | Behavior | |------|---------| | Primitives (string, number, boolean, null, undefined, bigint) | Returned as-is (immutable) | -| Plain object | Deep copy — `__proto__` preserved | +| Plain object | Deep copy of own enumerable keys — result is always a plain `Object.prototype` object; class instances lose their custom prototype/methods (see note below) | | Array (including sparse) | Deep copy — holes preserved | | Date | `new Date(timestamp)` | | RegExp | `new RegExp(source, flags)` | @@ -68,10 +68,14 @@ Hybrid implementation: tries native `structuredClone()` first; if unavailable (R | ArrayBuffer | `buffer.slice(0)` | | DataView | Buffer + byteOffset + byteLength | | TypedArray (Uint8Array, Float64Array, BigInt64Array, …) | Buffer cloned separately | -| Error and subclasses | message, name, cause, custom props (code, statusCode, …) | +| Error (built-in: `Error`, `TypeError`, `RangeError`, …) | message, name, cause preserved; subclass identity kept | | Boolean / Number / String wrapper objects | `Object(valueOf())` | | Circular reference | Tracked via WeakMap — preserved correctly | -| SharedArrayBuffer | Same reference returned (shared memory semantics) | +| SharedArrayBuffer | **Native `structuredClone` (default in Node ≥ 17 / modern browsers):** returns a *new* `SharedArrayBuffer` instance backed by the same shared memory — not the same reference. **Recursive fallback only** (no native `structuredClone`): returns the exact same reference | + +> **Important caveat — custom Error subclasses and custom properties:** `deepClone` tries native `structuredClone` first, and it does **not** throw for `Error` objects — so the recursive fallback (which is the part of this library that copies custom properties and preserves user-defined subclasses) is *not* reached for a standalone `Error` in Node.js or a browser. Under native `structuredClone`, a **user-defined** `Error` subclass becomes a plain `Error` (its class identity and any custom `name` are lost), and **any custom enumerable properties** (`code`, `statusCode`, etc.) are silently dropped. Only in environments without native `structuredClone` (e.g. some React Native/Hermes builds) does the recursive fallback run and actually preserve subclass identity and custom properties. Verified empirically — see the corrected example below. +> +> **Custom prototypes:** for the same reason, cloning a class instance (not a plain object literal) with `deepClone` produces a plain object — it does **not** remain `instanceof YourClass` and loses its methods. Use `Cloneable` (see below) for class instances that must retain their type/methods. ### Graceful degradation @@ -107,11 +111,13 @@ deepClone({ createdAt: new Date(), tags: new Set(['a', 'b']) }); const buf = new Uint8Array([1, 2, 3]); const bufClone = deepClone(buf); -// Error with custom properties +// Error — under native structuredClone (default in Node/browsers), only +// message/name/cause survive; custom own properties are dropped const err = Object.assign(new Error('fail'), { code: 'ERR_X', statusCode: 500 }); const errClone = deepClone(err); -errClone.code; // 'ERR_X' -errClone.statusCode; // 500 +errClone.message; // 'fail' +errClone instanceof Error; // true +errClone.code; // undefined — custom properties are NOT preserved here // Graceful degradation — never throws deepClone({ fn: () => 42 }); // { fn: () => 42 } — same reference @@ -184,7 +190,8 @@ const changed = working.filter((p, i) => p.price !== entities[i]?.price); - Circular reference support works in both native and fallback modes - `Function` values are returned by reference (closures cannot be copied) - `WeakMap` / `WeakSet` cannot be cloned — use `Map` / `Set` if you need copyable contents -- Error subclass custom properties (`code`, `statusCode`, etc.) are cloned automatically +- Error custom properties (`code`, `statusCode`, etc.) and custom Error subclasses are **not** preserved under native `structuredClone` (the default in Node.js/browsers) — they only survive via the recursive fallback (no native `structuredClone` available). Do not rely on custom Error properties surviving `deepClone()` in Node +- Class instances lose their custom prototype/methods through `deepClone()` — they come back as plain objects; use `Cloneable` instead if type/methods must be retained - `cloneArray()` calls `.clone()` on each item — items must implement `Cloneable` - Always deep-copy nested collections inside `clone()` — a shallow copy defeats the purpose - For simple value objects or DTOs, prefer `deepClone()` over implementing `Cloneable` diff --git a/.well-known/agent-skills/tsentials-errors/SKILL.md b/.well-known/agent-skills/tsentials-errors/SKILL.md index 42f1e56..f8344a2 100644 --- a/.well-known/agent-skills/tsentials-errors/SKILL.md +++ b/.well-known/agent-skills/tsentials-errors/SKILL.md @@ -16,8 +16,8 @@ npm install tsentials ## Import ```typescript -import { Err, ErrorMetadata } from 'tsentials/errors'; -import type { AppError, ErrorType } from 'tsentials/errors'; +import { Err, ErrorMetadata, ErrorType } from 'tsentials/errors'; +import type { AppError } from 'tsentials/errors'; ``` > **Important:** Import `Err` from `'tsentials/errors'` — NOT from `'tsentials/result'`. @@ -92,7 +92,7 @@ error.metadata // ErrorMetadata | undefined (ReadonlyMap) ## ErrorType Enum ```typescript -import type { ErrorType } from 'tsentials/errors'; +import { ErrorType } from 'tsentials/errors'; ErrorType.Failure // general failure ErrorType.Validation // 400-class, user input errors diff --git a/.well-known/agent-skills/tsentials-http/SKILL.md b/.well-known/agent-skills/tsentials-http/SKILL.md index efe94f8..86cec4f 100644 --- a/.well-known/agent-skills/tsentials-http/SKILL.md +++ b/.well-known/agent-skills/tsentials-http/SKILL.md @@ -103,13 +103,15 @@ const deleted = await RequestBuilder.delete(`https://api.example.com/users/${id} .header('Authorization', `Bearer ${token}`) .send(); -// Raw body (non-JSON) +// Raw (non-JSON) body on POST/PUT/PATCH — .body() bypasses JSON serialization entirely const uploaded = await RequestBuilder.post('https://api.example.com/upload') .header('Content-Type', 'text/plain') .body(rawText) .send(); ``` +`.json(value)` stringifies `value` and sets `Content-Type: application/json`. `.body(raw)` sends `raw` (a `BodyInit` — string, `Blob`, `FormData`, `ArrayBuffer`, ...) untouched, with no assumption that it's JSON — `send()` never runs `JSON.parse`/`JSON.stringify` on a `.body()` payload, so it can't throw on non-JSON content. Calling `.json()` after `.body()` (or vice versa) simply replaces whichever was set last. + ### RequestBuilder API | Static Factory | Returns | diff --git a/.well-known/agent-skills/tsentials-json/SKILL.md b/.well-known/agent-skills/tsentials-json/SKILL.md index b06d617..696429f 100644 --- a/.well-known/agent-skills/tsentials-json/SKILL.md +++ b/.well-known/agent-skills/tsentials-json/SKILL.md @@ -116,7 +116,7 @@ isJson({ key: undefined }); // false — undefined not valid JSON `safeJsonParse` returns `Result` so it chains directly into any railway pipeline: ```typescript -import { Result } from 'tsentials/result'; +import { Result, fromAsync } from 'tsentials/result'; import { safeJsonParse, isJsonObject, parseAndValidate } from 'tsentials/json'; // Chain into Result pipeline diff --git a/.well-known/agent-skills/tsentials-meta/SKILL.md b/.well-known/agent-skills/tsentials-meta/SKILL.md index f2f6d28..7b67c30 100644 --- a/.well-known/agent-skills/tsentials-meta/SKILL.md +++ b/.well-known/agent-skills/tsentials-meta/SKILL.md @@ -1,6 +1,6 @@ --- name: tsentials-meta -description: Use when deciding which tsentials module to use — overview of all 18 subpath imports organized by concern, install command, and a quick-reference table mapping problems to modules. +description: Use when deciding which tsentials module to use — overview of all 19 subpath imports organized by concern, install command, and a quick-reference table mapping problems to modules. --- # tsentials — Module Index @@ -26,6 +26,7 @@ npm install tsentials | `tsentials/maybe` | `Maybe`, `tryFirst`, `tryFind`, `choose` | `tsentials-maybe` | | `tsentials/union` | `Union` discriminated union utility | `tsentials-union` | | `tsentials/function` | `pipe`, `flow`, `identity`, `constant`, `flip` | `tsentials-function` | +| `tsentials/string` | `toPascalCase`, `toCamelCase`, `toKebabCase`, `toSnakeCase`, `toMacroCase`, `toTrainCase`, `toTitleCase`, `toUnderscoreCamelCase` | `tsentials-string` | ### Data Structures & Types @@ -96,6 +97,7 @@ npm install tsentials | Composable equality checks | `tsentials/eq` | | Type-safe sorting and ordering | `tsentials/ord` | | Composable boolean predicates | `tsentials/predicate` | +| Convert between naming conventions (camelCase, kebab-case, snake_case, ...) | `tsentials/string` | --- @@ -128,6 +130,7 @@ import type { Cloneable } from 'tsentials/clone'; import { safeJsonParse, safeJsonStringify, parseAndValidate } from 'tsentials/json'; import { isJson, isJsonObject } from 'tsentials/json'; import type { Json, JsonObject } from 'tsentials/json'; +import { toPascalCase, toCamelCase, toKebabCase, toSnakeCase, toMacroCase, toTrainCase, toTitleCase, toUnderscoreCamelCase } from 'tsentials/string'; ``` --- diff --git a/.well-known/agent-skills/tsentials-record/SKILL.md b/.well-known/agent-skills/tsentials-record/SKILL.md index df89fe2..4d6ae98 100644 --- a/.well-known/agent-skills/tsentials-record/SKILL.md +++ b/.well-known/agent-skills/tsentials-record/SKILL.md @@ -24,7 +24,7 @@ Record.filter(users, u => u.name !== 'Bob'); Record.filterMap(users, u => u.name.length > 3 ? u.name : null); // Modify -Record.upsert(users, 'c', { name: 'Charlie' }); +Record.upsert(users, 'b', { name: 'Bobby' }); // { a: {...}, b: { name: 'Bobby' } } Record.remove(users, 'b'); Record.pick(users, 'a'); Record.omit(users, 'b'); @@ -38,3 +38,4 @@ Record.partition(users, u => u.name.startsWith('A')); - Use `Record.map` instead of `Object.entries(...).reduce(...)`. - Use `pick` / `omit` for whitelist/blacklist field selection. +- `upsert`'s `key` must belong to the record's existing key union — `Record.upsert(users, 'c', ...)` above would not compile since `users` only has keys `'a' | 'b'`. Widen the record's type first if you need to add a genuinely new key. diff --git a/.well-known/agent-skills/tsentials-result/SKILL.md b/.well-known/agent-skills/tsentials-result/SKILL.md index 06e0bfa..be8ceb0 100644 --- a/.well-known/agent-skills/tsentials-result/SKILL.md +++ b/.well-known/agent-skills/tsentials-result/SKILL.md @@ -17,6 +17,7 @@ npm install tsentials ```typescript import { Result, ResultChain, chain, ResultAsync, fromAsync } from 'tsentials/result'; +import type { VoidResult } from 'tsentials/result'; import { maybeToResult, resultToMaybe } from 'tsentials/result'; ``` diff --git a/.well-known/agent-skills/tsentials-rules/SKILL.md b/.well-known/agent-skills/tsentials-rules/SKILL.md index f01b14d..53cebe4 100644 --- a/.well-known/agent-skills/tsentials-rules/SKILL.md +++ b/.well-known/agent-skills/tsentials-rules/SKILL.md @@ -205,6 +205,8 @@ RuleEngine.andTypedAsync(...rules: TypedAsyncRule(...rules: TypedAsyncRule[]) ``` +All three typed combinators return `Result.success(value)` when their rules pass: `andTyped`/`andTypedAsync` carry the *last passing* rule's value, `orTyped`/`orTypedAsync` carry the value of the *first passing* rule, and `linearTyped`/`linearTypedAsync` carry the *last* rule's value once the whole sequence passes. Zero rules is a special case per combinator: `linearTyped`/`linearTypedAsync` return a `RuleEngine.Empty` failure; `andTyped`/`andTypedAsync` return `Result.success(undefined)` (no errors to collect); `orTyped`/`orTypedAsync` **throw** `Error('Result.failureFrom requires at least one error.')` — same as the untyped `or` — because there are no errors to report and no success to return. + --- ## Domain Error Hierarchies with Rules @@ -242,7 +244,7 @@ if (!result.ok) { ```typescript import { fromAsync } from 'tsentials/result'; -// evaluate() is sync — use Result.then to chain into a pipeline +// evaluate() is sync — call it inside .andThen() to fold it into the async pipeline const response = await fromAsync(fetchUser(userId)) .andThen(user => RuleEngine.evaluate(canRegister, user)) .match( diff --git a/.well-known/agent-skills/tsentials-string/SKILL.md b/.well-known/agent-skills/tsentials-string/SKILL.md new file mode 100644 index 0000000..b6ca1f6 --- /dev/null +++ b/.well-known/agent-skills/tsentials-string/SKILL.md @@ -0,0 +1,38 @@ +--- +name: tsentials-string +description: Use when converting string casing — toPascalCase/toCamelCase/toKebabCase/toSnakeCase/toMacroCase/toTrainCase/toTitleCase/toUnderscoreCamelCase, all word-boundary aware (camelCase, PascalCase, consecutive-uppercase runs like "XMLParser", whitespace, hyphens, underscores). +--- + +# tsentials/string — Skill + +Use when converting between naming conventions (camelCase, kebab-case, snake_case, etc.). + +## API + +```typescript +import { + toPascalCase, + toCamelCase, + toKebabCase, + toSnakeCase, + toMacroCase, + toTrainCase, + toTitleCase, + toUnderscoreCamelCase, +} from 'tsentials/string'; + +toPascalCase('hello-world'); // "HelloWorld" +toCamelCase('hello-world'); // "helloWorld" +toKebabCase('helloWorld'); // "hello-world" +toSnakeCase('helloWorld'); // "hello_world" +toMacroCase('helloWorld'); // "HELLO_WORLD" +toTrainCase('hello_world'); // "Hello-World" +toTitleCase('helloWorld'); // "Hello World" +toUnderscoreCamelCase('helloWorld'); // "_helloWorld" +``` + +## Patterns + +- Every function accepts any input casing (camelCase, PascalCase, kebab-case, snake_case, space-separated) — word boundaries are detected automatically, so there is no need to normalize input first. +- Consecutive uppercase runs split before the trailing capitalized word: `"XMLParser"` → `["XML", "Parser"]`. +- All functions are pure and take a single `string` argument — no options object, no locale handling. diff --git a/.well-known/agent-skills/tsentials-union/SKILL.md b/.well-known/agent-skills/tsentials-union/SKILL.md index 8aef3b3..2f49a6f 100644 --- a/.well-known/agent-skills/tsentials-union/SKILL.md +++ b/.well-known/agent-skills/tsentials-union/SKILL.md @@ -35,19 +35,26 @@ type PaymentResult = Union<{ ## Creating Values +Assign a fresh literal with `as`, not `:` — annotating the *variable* with `: PaymentResult` narrows it down to whichever member the literal matches, which breaks `Union.match` exhaustiveness the moment you use that variable: + +```typescript +const result = { tag: 'success', value: { transactionId: 'txn_123' } } as PaymentResult; +const pending = { tag: 'pending', value: { estimatedMs: 3000 } } as PaymentResult; +const failed = { tag: 'failed', value: { error: Err.unexpected('Pay.Failed', 'Payment failed.') } } as PaymentResult; +``` + +`Union.of(tag, value)` also constructs a value, but its type parameter can't be inferred from a `Union` alias — you must spell out the full record shape `T` explicitly: + ```typescript -// Union.of(tag, value) -const result: PaymentResult = Union.of ? T : never, 'success'>( - 'success', - { transactionId: 'txn_123' }, -); - -// Or assign directly (the shape is { tag, value }) -const result: PaymentResult = { tag: 'success', value: { transactionId: 'txn_123' } }; -const pending: PaymentResult = { tag: 'pending', value: { estimatedMs: 3000 } }; -const failed: PaymentResult = { tag: 'failed', value: { error: Err.unexpected('Pay.Failed', 'Payment failed.') } }; +const created = Union.of<{ + success: { transactionId: string }; + pending: { estimatedMs: number }; + failed: { error: AppError }; +}, 'success'>('success', { transactionId: 'txn_123' }); ``` +The `as` form above is shorter and equally type-safe — prefer it unless you specifically need `Union.of`. + --- ## Exhaustive Pattern Match diff --git a/.well-known/agent-skills/tsentials/SKILL.md b/.well-known/agent-skills/tsentials/SKILL.md index f6db5b4..7bf9fc0 100644 --- a/.well-known/agent-skills/tsentials/SKILL.md +++ b/.well-known/agent-skills/tsentials/SKILL.md @@ -1,6 +1,6 @@ --- name: tsentials -description: Master skill for tsentials — railway-oriented programming library for TypeScript. Load this skill first when working with any tsentials module. Contains critical naming rules, API patterns, decision trees for module selection, and references to all 18 module-specific skills. +description: Master skill for tsentials — railway-oriented programming library for TypeScript. Load this skill first when working with any tsentials module. Contains critical naming rules, API patterns, decision trees for module selection, and references to all 19 module-specific skills. --- # tsentials — Master Skill @@ -33,6 +33,7 @@ These naming rules MUST be followed. Getting them wrong causes runtime bugs. | RequestBuilder terminal | `.fetchResult()` | `.send()` | Terminal method is `send`, not `fetchResult` | | deepClone mechanism | "calls .clone()" | uses `structuredClone()` | `cloneArray` calls `.clone()`, `deepClone` uses structuredClone | | Predicate.and arity | `Predicate.and(a, b, c)` | `Predicate.and(Predicate.and(a, b), c)` | `and`/`or` take exactly 2 args. Use `all`/`any` for variadic | +| String case functions | `toCamelCase(str, opts)` | `toCamelCase(str)` | Single-argument, no options object — normalization is automatic | ## Core API Patterns @@ -137,6 +138,7 @@ RuleEngine.evaluateAsync(rule, ctx) // async → Promise | Structural equality | `eq` | `tsentials-eq` | | Type-safe ordering/sorting | `ord` | `tsentials-ord` | | Composable boolean predicates | `predicate` | `tsentials-predicate` | +| Convert between naming conventions | `string` | `tsentials-string` | | Module overview / all imports | — | `tsentials-meta` | ## Design Principles @@ -172,4 +174,5 @@ import { SystemDateTimeProvider, createFakeDateTimeProvider } from 'tsentials/ti import type { DateTimeProvider } from 'tsentials/time'; import { deepClone, cloneArray } from 'tsentials/clone'; import { safeJsonParse, parseAndValidate } from 'tsentials/json'; +import { toPascalCase, toCamelCase, toKebabCase, toSnakeCase } from 'tsentials/string'; ``` diff --git a/README.md b/README.md index 3eacd20..928a84e 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![npm version](https://img.shields.io/npm/v/tsentials?style=flat-square&color=blue)](https://www.npmjs.com/package/tsentials) [![npm downloads](https://img.shields.io/npm/dm/tsentials?style=flat-square)](https://www.npmjs.com/package/tsentials) [![bundle size](https://img.shields.io/bundlephobia/minzip/tsentials?style=flat-square&label=gzip)](https://bundlephobia.com/package/tsentials) -[![tests](https://img.shields.io/badge/tests-1079%20passing-brightgreen?style=flat-square)](./tests) +[![tests](https://img.shields.io/badge/tests-1089%20passing-brightgreen?style=flat-square)](./tests) [![CI](https://img.shields.io/github/actions/workflow/status/senrecep/tsentials/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/senrecep/tsentials/actions) [![license](https://img.shields.io/github/license/senrecep/tsentials?style=flat-square)](./LICENSE) [![TypeScript](https://img.shields.io/badge/TypeScript-5.0%2B-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/) diff --git a/docs/index.html b/docs/index.html index 08cf8bf..23d7bfd 100644 --- a/docs/index.html +++ b/docs/index.html @@ -191,7 +191,7 @@

tsentials

npm version npm downloads bundle size - tests + tests CI TypeScript Node.js diff --git a/package-lock.json b/package-lock.json index 93647c0..9571003 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tsentials", - "version": "0.1.11", + "version": "0.1.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tsentials", - "version": "0.1.11", + "version": "0.1.12", "license": "MIT", "devDependencies": { "@biomejs/biome": "^2.4.15", diff --git a/package.json b/package.json index d11df0b..ab21bae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tsentials", - "version": "0.1.11", + "version": "0.1.12", "description": "Railway-oriented programming for TypeScript — Result, Maybe, Rule Engine, and DDD base classes with full async pipeline support", "type": "module", "main": "./dist/index.js", diff --git a/src/http/fetch-result.ts b/src/http/fetch-result.ts index 85b32c5..ec06780 100644 --- a/src/http/fetch-result.ts +++ b/src/http/fetch-result.ts @@ -125,4 +125,19 @@ export const fetchResult = { (e) => Err.fromException(e), ).then((r) => (r.ok ? r.value : r)) as Promise>; }, + + /** + * Sends `init` as-is (including any body already set on it) — returns Result. + * Use for non-JSON payloads; for JSON bodies prefer post/put/patch, which serialize + * the body for you. + */ + async send(url: string | URL, init: RequestInit): Promise> { + return R.tryAsync( + async () => { + const response = await fetch(url, init); + return responseToResult(response); + }, + (e) => Err.fromException(e), + ).then((r) => (r.ok ? r.value : r)) as Promise>; + }, } as const; diff --git a/src/http/request-builder.ts b/src/http/request-builder.ts index a39c704..9baacf8 100644 --- a/src/http/request-builder.ts +++ b/src/http/request-builder.ts @@ -18,6 +18,8 @@ export class RequestBuilder { readonly #headers: Record = {}; readonly #query: URLSearchParams; #body: BodyInit | null = null; + #jsonPayload: unknown; + #hasJsonPayload = false; private constructor(method: string, url: string | URL) { this.#method = method; @@ -60,6 +62,8 @@ export class RequestBuilder { /** Sets the request body as JSON and adds Content-Type header. */ json(body: unknown): this { this.#body = JSON.stringify(body); + this.#jsonPayload = body; + this.#hasJsonPayload = true; this.#headers['Content-Type'] = 'application/json'; return this; } @@ -67,6 +71,7 @@ export class RequestBuilder { /** Sets a raw body. */ body(body: BodyInit): this { this.#body = body; + this.#hasJsonPayload = false; return this; } @@ -84,11 +89,17 @@ export class RequestBuilder { case 'GET': return fetchResult.get(this.#url, init); case 'POST': - return fetchResult.post(this.#url, JSON.parse((this.#body as string) ?? 'null'), init); + return this.#hasJsonPayload + ? fetchResult.post(this.#url, this.#jsonPayload, init) + : fetchResult.send(this.#url, init); case 'PUT': - return fetchResult.put(this.#url, JSON.parse((this.#body as string) ?? 'null'), init); + return this.#hasJsonPayload + ? fetchResult.put(this.#url, this.#jsonPayload, init) + : fetchResult.send(this.#url, init); case 'PATCH': - return fetchResult.patch(this.#url, JSON.parse((this.#body as string) ?? 'null'), init); + return this.#hasJsonPayload + ? fetchResult.patch(this.#url, this.#jsonPayload, init) + : fetchResult.send(this.#url, init); case 'DELETE': return fetchResult.delete(this.#url, init); default: diff --git a/src/rules/rule-engine.ts b/src/rules/rule-engine.ts index 44ae3f6..5d49ed2 100644 --- a/src/rules/rule-engine.ts +++ b/src/rules/rule-engine.ts @@ -122,11 +122,18 @@ export const RuleEngine = { ...rules: TypedRule[] ): TypedRule { return (context: TContext) => { + if (rules.length === 0) { + return Result.failure( + Err.unexpected('RuleEngine.Empty', 'No rules provided in linearTyped'), + ); + } + let lastValue: TResult | undefined; for (const rule of rules) { const result = rule(context); if (!result.ok) return result; + lastValue = result.value; } - return Result.failure(Err.unexpected('RuleEngine.Empty', 'No rules provided in linearTyped')); + return Result.success(lastValue as TResult); }; }, @@ -137,13 +144,18 @@ export const RuleEngine = { ...rules: TypedAsyncRule[] ): TypedAsyncRule { return async (context: TContext) => { + if (rules.length === 0) { + return Result.failure( + Err.unexpected('RuleEngine.Empty', 'No rules provided in linearTypedAsync'), + ); + } + let lastValue: TResult | undefined; for (const rule of rules) { const result = await rule(context); if (!result.ok) return result; + lastValue = result.value; } - return Result.failure( - Err.unexpected('RuleEngine.Empty', 'No rules provided in linearTypedAsync'), - ); + return Result.success(lastValue as TResult); }; }, diff --git a/tests/http/fetch-result.test.ts b/tests/http/fetch-result.test.ts index 81e15b2..4dd1a68 100644 --- a/tests/http/fetch-result.test.ts +++ b/tests/http/fetch-result.test.ts @@ -508,3 +508,64 @@ describe('fetchResult.delete', () => { if (!result.ok) expect(result.errors[0]!.type).toBe(ErrorType.Failure); }); }); + +describe('fetchResult.send', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('sends init as-is without forcing JSON encoding', async () => { + const fetchSpy = vi.fn(() => + Promise.resolve(createMockResponse({ ok: true, status: 200, json: {} })), + ); + vi.stubGlobal('fetch', fetchSpy); + await fetchResult.send('https://api.example.com/upload', { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: 'raw text', + }); + expect(fetchSpy).toHaveBeenCalledWith('https://api.example.com/upload', { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: 'raw text', + }); + }); + + it('returns success on 200', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(createMockResponse({ ok: true, status: 200, json: { id: 1 } }))), + ); + const result = await fetchResult.send<{ id: number }>('https://api.example.com/user', { + method: 'POST', + body: 'raw', + }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual({ id: 1 }); + }); + + it('returns failure on 4xx without throwing', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(createMockResponse({ ok: false, status: 400 }))), + ); + const result = await fetchResult.send('https://api.example.com/upload', { + method: 'POST', + body: 'not json', + }); + expect(result.ok).toBe(false); + }); + + it('returns failure on network error', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.reject(new TypeError('fetch failed'))), + ); + const result = await fetchResult.send('https://api.example.com/upload', { + method: 'POST', + body: 'not json', + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors[0]!.code).toBe('TypeError'); + }); +}); diff --git a/tests/http/request-builder.test.ts b/tests/http/request-builder.test.ts index 05e0e82..0128be1 100644 --- a/tests/http/request-builder.test.ts +++ b/tests/http/request-builder.test.ts @@ -381,6 +381,75 @@ describe('RequestBuilder fluent API', () => { expect(init.method).toBe('PATCH'); }); + it('sends a raw non-JSON body on POST without throwing', async () => { + const fetchSpy = vi.fn(() => + Promise.resolve( + new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }), + ), + ); + vi.stubGlobal('fetch', fetchSpy); + + const result = await RequestBuilder.post('https://api.example.com/upload') + .header('Content-Type', 'text/plain') + .body('not json at all') + .send(); + + expect(result.ok).toBe(true); + const init = fetchSpy.mock.calls[0]![1] as RequestInit; + expect(init.body).toBe('not json at all'); + expect(init.headers).toEqual({ 'Content-Type': 'text/plain' }); + }); + + it('sends a raw non-JSON body on PUT without throwing', async () => { + const fetchSpy = vi.fn(() => + Promise.resolve( + new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }), + ), + ); + vi.stubGlobal('fetch', fetchSpy); + + const result = await RequestBuilder.put('https://api.example.com/upload') + .body('plain text body') + .send(); + + expect(result.ok).toBe(true); + const init = fetchSpy.mock.calls[0]![1] as RequestInit; + expect(init.body).toBe('plain text body'); + }); + + it('sends a raw non-JSON body on PATCH without throwing', async () => { + const fetchSpy = vi.fn(() => + Promise.resolve( + new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }), + ), + ); + vi.stubGlobal('fetch', fetchSpy); + + const result = await RequestBuilder.patch('https://api.example.com/upload') + .body('plain text body') + .send(); + + expect(result.ok).toBe(true); + const init = fetchSpy.mock.calls[0]![1] as RequestInit; + expect(init.body).toBe('plain text body'); + }); + + it('does not double-encode JSON bodies (no parse/stringify round-trip)', async () => { + const fetchSpy = vi.fn(() => + Promise.resolve( + new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }), + ), + ); + vi.stubGlobal('fetch', fetchSpy); + + await RequestBuilder.post('https://api.example.com/users') + .json({ name: 'Alice' }) + .send(); + + const init = fetchSpy.mock.calls[0]![1] as RequestInit; + expect(init.body).toBe(JSON.stringify({ name: 'Alice' })); + }); + it('falls back to GET for unknown HTTP method (default branch)', async () => { const fetchSpy = vi.fn(() => Promise.resolve( diff --git a/tests/rules/rule-engine.test.ts b/tests/rules/rule-engine.test.ts index a9f549b..b9fec53 100644 --- a/tests/rules/rule-engine.test.ts +++ b/tests/rules/rule-engine.test.ts @@ -256,11 +256,19 @@ describe('RuleEngine.linearTyped', () => { if (!result.ok) expect(result.errors[0]!.code).toBe('Typed.Fail'); }); - it('returns RuleEngine.Empty fallback when all rules pass', () => { + it('returns the last rule value when all rules pass', () => { const rule = RuleEngine.linearTyped(toAge, toAge); const result = rule(validUser); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.errors[0]!.code).toBe('RuleEngine.Empty'); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe(25); + }); + + it('returns the LAST value, not the first, when values differ', () => { + const doubleAge = (ctx: UserContext): Result => Result.success(ctx.age * 2); + const rule = RuleEngine.linearTyped(toAge, doubleAge); + const result = rule(validUser); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe(50); }); it('returns RuleEngine.Empty for empty rules', () => { @@ -283,11 +291,20 @@ describe('RuleEngine.linearTypedAsync', () => { if (!result.ok) expect(result.errors[0]!.code).toBe('Typed.Fail'); }); - it('returns RuleEngine.Empty fallback when all rules pass', async () => { + it('returns the last rule value when all rules pass', async () => { const rule = RuleEngine.linearTypedAsync(toAgeAsync); const result = await rule(validUser); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.errors[0]!.code).toBe('RuleEngine.Empty'); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe(25); + }); + + it('returns the LAST value, not the first, when values differ', async () => { + const doubleAgeAsync = async (ctx: UserContext): Promise> => + Result.success(ctx.age * 2); + const rule = RuleEngine.linearTypedAsync(toAgeAsync, doubleAgeAsync); + const result = await rule(validUser); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe(50); }); it('returns RuleEngine.Empty for empty rules', async () => {