From 13a01bda2b2d0ef1c2d34ee4d90c7db1915b0fd2 Mon Sep 17 00:00:00 2001 From: kishore Date: Sat, 18 Jul 2026 20:19:06 -0400 Subject: [PATCH 1/3] Add: design changes for taxonomy --- .gitignore | 3 + src/core/codes/README.md | 267 +++++++++------- .../src/commonMain/kotlin/kiit/codes/Codes.kt | 302 ++++++++++-------- .../commonMain/kotlin/kiit/codes/Status.kt | 205 ++++++------ .../kotlin/kiit/codes/StatusException.kt | 15 +- 5 files changed, 452 insertions(+), 340 deletions(-) diff --git a/.gitignore b/.gitignore index b5178feb8..cd0343fd6 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ src_managed/ project/boot/ project/plugins/project/ +# kotlin +.kotlin/ +.gradle # test/ diff --git a/src/core/codes/README.md b/src/core/codes/README.md index d4ca2172e..2b2f26f86 100644 --- a/src/core/codes/README.md +++ b/src/core/codes/README.md @@ -15,7 +15,7 @@ Every `Status` can be represented as a structured response, for example as an AP ```json { "name" : "TOKEN_EXPIRED", - "type" : "Denied", + "type" : "denied", "code" : 400009, "success": false, "message": "Session token expired" @@ -28,7 +28,7 @@ Every `Status` can be represented as a structured response, for example as an AP 1. **Universal** — Usable at any layer: service, background job, route handler, CLI command. 2. **Hierarchy** — Logical grouping of successes and failures for branching and aggregation. 3. **Standard** — Precise, consistent status representation across all layers and targets. -4. **Compliant** — Convertible to HTTP status codes via `Codes.toHttp(status)`. +4. **Compliant** — Convertible to HTTP status codes via a `CodeLookup` implementation such as `CodesToHttp`. 5. **Reusable** — A single status instance can be shared across many call sites. 6. **Extensible** — Create domain codes by constructing `Passed.*` or `Failed.*` subtypes directly. 7. **Searchable** — `name` and `type` are stable, unique keys suitable for log queries. @@ -38,25 +38,29 @@ Every `Status` can be represented as a structured response, for example as an AP --- ## Hierarchy +Categories are closed/sealed and fixed by design, to enforce a consistent taxonomy across every +consumer. Individual codes *within* a category are open — create new domain codes by constructing +a `Passed` or `Failed` subtype directly (see [Built-in Codes](#built-in-codes) for the built-in set). + ``` -Status = Passed | Failed -Passed = Succeeded | Pending | Filtered | Ignored -Failed = Denied | Invalid | Errored | Unknown +Status = Passed | Failed +Passed = Succeeded | Pending | Filtered | Information +Failed = Denied | Invalid | Errored | Unserviceable ``` ```mermaid graph TD - classDef statusNode fill:#3b82f6,stroke:#1d4ed8,color:#ffffff,font-weight:bold - classDef passedNode fill:#86efac,stroke:#16a34a,color:#14532d,font-weight:bold - classDef succeededNode fill:#22c55e,stroke:#15803d,color:#ffffff,font-weight:bold - classDef pendingNode fill:#fde047,stroke:#ca8a04,color:#713f12,font-weight:bold - classDef filteredNode fill:#9ca3af,stroke:#6b7280,color:#ffffff,font-weight:bold - classDef ignoredNode fill:#9ca3af,stroke:#6b7280,color:#ffffff,font-weight:bold - classDef failedNode fill:#fca5a5,stroke:#f87171,color:#7f1d1d,font-weight:bold - classDef deniedNode fill:#111827,stroke:#000000,color:#ffffff,font-weight:bold - classDef invalidNode fill:#f97316,stroke:#c2410c,color:#ffffff,font-weight:bold - classDef erroredNode fill:#dc2626,stroke:#b91c1c,color:#ffffff,font-weight:bold - classDef unknownNode fill:#7f1d1d,stroke:#450a0a,color:#ffffff,font-weight:bold + classDef statusNode fill:#3b82f6,stroke:#1d4ed8,color:#ffffff,font-weight:bold + classDef passedNode fill:#86efac,stroke:#16a34a,color:#14532d,font-weight:bold + classDef succeededNode fill:#22c55e,stroke:#15803d,color:#ffffff,font-weight:bold + classDef pendingNode fill:#fde047,stroke:#ca8a04,color:#713f12,font-weight:bold + classDef filteredNode fill:#9ca3af,stroke:#6b7280,color:#ffffff,font-weight:bold + classDef informationNode fill:#38bdf8,stroke:#0284c7,color:#0c4a6e,font-weight:bold + classDef failedNode fill:#fca5a5,stroke:#f87171,color:#7f1d1d,font-weight:bold + classDef deniedNode fill:#111827,stroke:#000000,color:#ffffff,font-weight:bold + classDef invalidNode fill:#f97316,stroke:#c2410c,color:#ffffff,font-weight:bold + classDef erroredNode fill:#dc2626,stroke:#b91c1c,color:#ffffff,font-weight:bold + classDef unserviceableNode fill:#7f1d1d,stroke:#450a0a,color:#ffffff,font-weight:bold Status["Status
name / code / message / success"]:::statusNode @@ -66,40 +70,40 @@ graph TD Succeeded["Succeeded
type: succeeded"]:::succeededNode Pending["Pending
type: pending"]:::pendingNode Filtered["Filtered
type: filtered"]:::filteredNode - Ignored["Ignored
type: ignored"]:::ignoredNode + Information["Information
type: information"]:::informationNode Denied["Denied
type: denied"]:::deniedNode Invalid["Invalid
type: invalid"]:::invalidNode Errored["Errored
type: errored"]:::erroredNode - Unknown["Unknown
type: unknown"]:::unknownNode + Unserviceable["Unserviceable
type: unserviceable"]:::unserviceableNode Status --> Passed Status --> Failed Passed --> Succeeded Passed --> Pending Passed --> Filtered - Passed --> Ignored + Passed --> Information Failed --> Denied Failed --> Invalid Failed --> Errored - Failed --> Unknown + Failed --> Unserviceable ``` --- ## Grouping -| Parent | Type | Level | `success` | Purpose | -|----------|-------------|--------|-----------|----------------------------------------| -| `Status` | `Passed` | Parent | — | Parent of all non-failure statuses | -| `Passed` | `Succeeded` | Child | `true` | Operation completed successfully | -| `Passed` | `Pending` | Child | `true` | Accepted but not yet fully processed | -| `Passed` | `Filtered` | Child | `true` | Intentionally excluded from processing | -| `Passed` | `Ignored` | Child | `true` | Processed but result discarded | -| `Status` | `Failed` | Parent | — | Parent of all failure statuses | -| `Failed` | `Denied` | Child | `false` | Security / access-control failure | -| `Failed` | `Invalid` | Child | `false` | Malformed or invalid input data | -| `Failed` | `Errored` | Child | `false` | Known business-rule failure | -| `Failed` | `Unknown` | Child | `false` | Unexpected or unhandled failure | +| Parent | Type | Level | `success` | Purpose | +|----------|-----------------|--------|-----------|------------------------------------------------------------------| +| `Status` | `Passed` | Parent | — | Parent of all non-failure statuses | +| `Passed` | `Succeeded` | Child | `true` | Operation's primary purpose completed | +| `Passed` | `Pending` | Child | `true` | Accepted but not yet fully processed | +| `Passed` | `Filtered` | Child | `true` | Excluded from normal output — not processed, or processed and discarded | +| `Passed` | `Information` | Child | `true` | Informational / metadata response, no primary operation performed | +| `Status` | `Failed` | Parent | — | Parent of all failure statuses | +| `Failed` | `Denied` | Child | `false` | Security / access-control failure | +| `Failed` | `Invalid` | Child | `false` | The request as given cannot be satisfied — bad or missing input | +| `Failed` | `Errored` | Child | `false` | Known, expected business-rule failure | +| `Failed` | `Unserviceable` | Child | `false` | Valid & permitted, but can't be serviced right now — capacity, timeout, unimplemented, or truly unexpected | --- @@ -107,95 +111,138 @@ graph TD Every `Status` carries the following fields: -| Field | Property | Purpose | -|-----------|-----------|-----------------------------------------------------------------------------------------| -| `name` | `name` | Unique domain label, e.g. `TOKEN_EXPIRED`, `RATE_LIMITED`. Stable — used as a log key. | -| `code` | `code` | Numeric code. Defaults align with HTTP ranges; convert via `Codes.toHttp(status)`. | -| `message` | `message` | Human-readable description. Must be a constant — never constructed from runtime data. | -| `success` | `success` | Boolean shortcut for callers that don't need to narrow the sealed type. | +| Field | Property | Purpose | +|-----------|-----------|---------------------------------------------------------------------------------------------------| +| `name` | `name` | Unique domain label, e.g. `TOKEN_EXPIRED`, `RATE_LIMITED`. Stable — used as a log key. | +| `code` | `code` | Numeric code. Grouped by category by convention, but NOT a literal HTTP code — convert via a `CodeLookup` (e.g. `CodesToHttp`). | +| `message` | `message` | Human-readable description. Must be a constant — never constructed from runtime data. | +| `success` | `success` | Boolean shortcut for callers that don't need to narrow the sealed type. | --- ## Built-in Codes The `Codes` object provides a standard registry. All codes are optional — create domain-specific -codes by constructing any `Passed` or `Failed` subtype directly. - -### Succeeded (200xxx) - -| Code | Value | HTTP | -|-----------------|--------|------| -| `SUCCESS` | 200001 | 200 | -| `CREATED` | 200002 | 201 | -| `UPDATED` | 200003 | 200 | -| `FETCHED` | 200004 | 200 | -| `PATCHED` | 200005 | 200 | -| `DELETED` | 200006 | 200 | -| `HANDLED` | 200007 | 204 | -| `EXIT` | 600002 | 503 | -| `HELP` | 600003 | 200 | -| `ABOUT` | 600004 | 200 | -| `VERSION` | 600005 | 200 | - -### Pending (200xxx) - -| Code | Value | HTTP | -|------------|--------|------| -| `PENDING` | 200008 | 202 | -| `QUEUED` | 200009 | 202 | -| `CONFIRM` | 200010 | 200 | -| `ACTIVE` | 200101 | 200 | -| `INACTIVE` | 200102 | 200 | -| `STARTING` | 200103 | 200 | -| `WAITING` | 200104 | 200 | -| `RUNNING` | 200105 | 200 | -| `PAUSED` | 200106 | 200 | -| `STOPPED` | 200107 | 200 | -| `COMPLETE` | 200108 | 200 | - -### Passed — Filtered / Ignored (200xxx) - -| Code | Value | HTTP | -|------------|--------|------| -| `FILTERED` | 200204 | 200 | -| `IGNORED` | 200204 | 200 | - -### Denied (400xxx) - -| Code | Value | HTTP | -|-------------------|--------|------| -| `DENIED` | 400005 | 401 | -| `UNSUPPORTED` | 400006 | 501 | -| `UNIMPLEMENTED` | 400007 | 501 | -| `UNAVAILABLE` | 400008 | 503 | -| `UNAUTHENTICATED` | 400009 | 401 | -| `UNAUTHORIZED` | 400010 | 401 | - -### Invalid (400xxx) +codes by constructing any `Passed` or `Failed` subtype directly. Every code's uniqueness is +enforced at object-init time — a duplicate code fails loudly the first time `Codes` is touched. + +The `HTTP` column below reflects the default mapping from `CodesToHttp` — a category default, +unless a specific code has an override (see [HTTP Conversion](#http-conversion)). + +### Succeeded (200000-200099) + +| Code | Value | HTTP | +|-----------|--------|------| +| `SUCCESS` | 200001 | 200 | +| `CREATED` | 200002 | 201 | +| `UPDATED` | 200003 | 200 | +| `FETCHED` | 200004 | 200 | +| `PATCHED` | 200005 | 200 | +| `DELETED` | 200006 | 200 | +| `HANDLED` | 200007 | 204 | + +### Pending (200100-200199) + +| Code | Value | HTTP | +|-----------|--------|------| +| `PENDING` | 200101 | 202 | +| `QUEUED` | 200102 | 202 | +| `CONFIRM` | 200103 | 200 | + +### Filtered (200200-200299) + +Covers both "not processed at all" (`SKIPPED`) and "processed, then the result was deliberately +discarded" (`DISCARDED`) — the distinction is carried by name/code, not by separate types. + +| Code | Value | HTTP | +|-------------|--------|------| +| `SKIPPED` | 200201 | 200 | +| `DISCARDED` | 200202 | 200 | + +### Information (200300-200399) + +| Code | Value | HTTP | +|-----------|--------|------| +| `HELP` | 200301 | 200 | +| `ABOUT` | 200302 | 200 | +| `VERSION` | 200303 | 200 | +| `EXIT` | 200304 | 200 | + +### Denied (400000-400099) — security / access-control + +| Code | Value | HTTP | +|--------------------|--------|------| +| `DENIED` | 400001 | 401 | +| `UNAUTHENTICATED` | 400002 | 401 | +| `UNAUTHORIZED` | 400003 | 401 | + +### Invalid (400100-400199) — bad input | Code | Value | HTTP | |---------------|--------|------| -| `BAD_REQUEST` | 400002 | 400 | -| `INVALID` | 400003 | 400 | -| `NOT_FOUND` | 400004 | 404 | +| `BAD_REQUEST` | 400101 | 400 | +| `INVALID` | 400102 | 400 | +| `NOT_FOUND` | 400103 | 404 | -### Errored (500xxx) +### Errored (500000-500099) — known, expected business-rule failure | Code | Value | HTTP | |--------------|--------|------| -| `MISSING` | 500002 | 400 | -| `FORBIDDEN` | 500003 | 403 | -| `CONFLICT` | 500004 | 409 | -| `DEPRECATED` | 500005 | 426 | -| `TIMEOUT` | 500006 | 408 | -| `ERRORED` | 500007 | 500 | -| `LIMITED` | 500009 | 500 | +| `MISSING` | 500001 | 400 | +| `FORBIDDEN` | 500002 | 403 | +| `CONFLICT` | 500003 | 409 | +| `DEPRECATED` | 500004 | 426 | +| `ERRORED` | 500005 | 500 | + +### Unserviceable (500100-500199) — valid & permitted, can't be serviced right now + +| Code | Value | HTTP | +|----------------------|--------|------| +| `UNIMPLEMENTED` | 500101 | 501 | +| `UNSUPPORTED` | 500102 | 501 | +| `TIMEOUT` | 500103 | 408 | +| `RATE_LIMITED` | 500104 | 429 | +| `UNREACHABLE` | 500105 | 503 | +| `UNDER_MAINTENANCE` | 500106 | 503 | +| `UNEXPECTED` | 500107 | 500 | -### Unknown (500xxx) +--- -| Code | Value | HTTP | -|--------------|--------|------| -| `UNEXPECTED` | 500008 | 500 | +## HTTP Conversion + +`CodeLookup` is a direction-explicit, bidirectional conversion between a `Status` and a target +protocol's status code (e.g. HTTP) — `toCode(status): Int` / `toStatus(code): Status?` — so the +two code spaces (internal registry code vs. HTTP code) can never be silently confused at a call +site. + +`CodesToHttp` is the default HTTP implementation. `toCode` is an exhaustive `when` over `Status`'s +categories (a missing category is a compile error, not a silent fallback), layered with a small +overrides map for the handful of codes that differ from their category's default (e.g. `CREATED` +-> 201 vs. `Succeeded`'s default 200). `toStatus` is derived from `toCode`, so the two directions +can never drift apart from each other. An unrecognized HTTP code returns `null` — the caller +decides the fallback, rather than the library guessing one from a numeric range. + +```kotlin +val http = CodesToHttp() +http.toCode(Codes.CREATED) // 201 +http.toCode(Codes.DENIED) // 401 (category default) +http.toStatus(404)?.name // "NOT_FOUND" +http.toStatus(999) // null — unrecognized code, no guessed fallback +``` + +`CompositeLookup` composes a base `CodeLookup` with client-supplied extensions, without +subclassing `CodesToHttp` — composition over inheritance. It's keyed by the actual `Status` +instance (not just its code), so custom statuses outside the `Codes` registry are also +reverse-lookupable via `toStatus`: + +```kotlin +val PAYMENT_DECLINED = Failed.Errored("PAYMENT_DECLINED", 700123, "Payment declined") +val lookup = CompositeLookup(base = CodesToHttp(), extensions = mapOf(PAYMENT_DECLINED to 402)) + +lookup.toCode(PAYMENT_DECLINED) // 402 +lookup.toStatus(402) // PAYMENT_DECLINED +lookup.toCode(Codes.DENIED) // 401 — falls back to the base lookup +``` --- @@ -217,9 +264,11 @@ try { // ... } catch (e: StatusException) { when (e.status) { - is Failed.Denied -> // handle auth failure - is Failed.Errored -> // handle business error - else -> // ... + is Failed.Denied -> // handle auth failure + is Failed.Invalid -> // handle bad input + is Failed.Errored -> // handle known business-rule failure + is Failed.Unserviceable -> // handle capacity / timeout / unimplemented / unexpected + is Passed -> // n/a — Passed statuses aren't normally thrown } } ``` diff --git a/src/core/codes/src/commonMain/kotlin/kiit/codes/Codes.kt b/src/core/codes/src/commonMain/kotlin/kiit/codes/Codes.kt index 9e90cd7d0..c318e17d2 100644 --- a/src/core/codes/src/commonMain/kotlin/kiit/codes/Codes.kt +++ b/src/core/codes/src/commonMain/kotlin/kiit/codes/Codes.kt @@ -12,155 +12,197 @@ package kiit.codes /** - * Built-in registry of standard [Status] codes covering the most common operation outcomes. + * Built-in registry of standard [Status] codes covering common operation outcomes. * - * Using these is optional — they are provided for convenience and as defaults for kiit-result - * builder methods. Custom codes can be created by constructing any [Passed] or [Failed] subtype - * directly. + * Using these is optional — they're provided as sensible defaults and for kiit-result builder + * methods. Custom domain codes can be created by constructing any [Passed] or [Failed] subtype + * directly; only the four categories under each are fixed/closed (see [Status]). * - * Numeric codes default to HTTP-compatible ranges: - * - 200xxx → success / pending - * - 400xxx → client / validation failures - * - 500xxx → server / unexpected failures - * - 600xxx → interactive / metadata - * - * HTTP conversion is available via [toHttp]. Each code maps to the closest semantic HTTP status. + * Numeric code ranges (conceptual grouping only — see the NOTE on [Status.code]): + * 200000-200099 Succeeded 200100-200199 Pending + * 200200-200299 Filtered 200300-200399 Information + * 400000-400099 Denied 400100-400199 Invalid + * 500000-500099 Errored 500100-500199 Unserviceable * + * Uniqueness of every code in this registry is enforced at object-init time (see the `init` + * block below) — a duplicate code will fail loudly the first time [Codes] is touched, rather + * than silently producing a wrong HTTP mapping. */ object Codes { - // Success: 200000 + range ( useful for CRUD operations ) + // ---- Succeeded (200000-200099) ---- val SUCCESS = Passed.Succeeded("SUCCESS", 200001, "Success") val CREATED = Passed.Succeeded("CREATED", 200002, "Created") val UPDATED = Passed.Succeeded("UPDATED", 200003, "Updated") val FETCHED = Passed.Succeeded("FETCHED", 200004, "Fetched") - val PATCHED = Passed.Succeeded("PATCHED", 200005, "Patched") // E.g. Update a small subset of info + val PATCHED = Passed.Succeeded("PATCHED", 200005, "Patched") val DELETED = Passed.Succeeded("DELETED", 200006, "Deleted") - val HANDLED = Passed.Succeeded("HANDLED", 200007, "Handled") // E.g. A silent ok ( similar to http 204 ) - val PENDING = Passed.Pending("PENDING", 200008, "Pending") - val QUEUED = Passed.Pending("QUEUED", 200009, "Queued") - val CONFIRM = Passed.Pending("CONFIRM", 200010, "Confirm") - val FILTERED = Passed.Filtered("FILTERED", 200204, "Filtered") // E.g. Ignored, not exactly an error - val IGNORED = Passed.Ignored("IGNORED", 200204, "Ignored") // E.g. Ignored, not exactly an error - - // Success: 200000 + range ( useful for JOB States ) - val ACTIVE = Passed.Pending("ACTIVE", 200101, "Active") - val INACTIVE = Passed.Pending("INACTIVE", 200102, "Inactive") - val STARTING = Passed.Pending("STARTING", 200103, "Starting") - val WAITING = Passed.Pending("WAITING", 200104, "Waiting") - val RUNNING = Passed.Pending("RUNNING", 200105, "Running") - val PAUSED = Passed.Pending("PAUSED", 200106, "Paused") - val STOPPED = Passed.Pending("STOPPED", 200107, "Stopped") - val COMPLETE = Passed.Pending("COMPLETE", 200108, "Complete") - - // Invalid: 400000 + range - val BAD_REQUEST = Failed.Invalid("BAD_REQUEST", 400002, "Bad Request") // E.g. Invalid JSON - val INVALID = Failed.Invalid("INVALID", 400003, "Invalid") // E.g. Valid JSON but invalid values - val NOT_FOUND = Failed.Invalid("NOT_FOUND", 400004, "Not found") // E.g. Resource/End point not found - - // Security related - val DENIED = Failed.Denied("DENIED", 400005, "Denied") // Presumes a checked condition - val UNSUPPORTED = Failed.Denied("UNSUPPORTED", 400006, "Not supported") // Presumes a checked condition - val UNIMPLEMENTED = Failed.Denied("UNIMPLEMENTED", 400007, "Not implemented") // Presumes a checked condition - val UNAVAILABLE = Failed.Denied("UNAVAILABLE", 400008, "Not available") // Presumes a checked condition - val UNAUTHENTICATED = Failed.Denied("UNAUTHENTICATED", 400009, "Unauthenticated") // Presumes a checked condition - val UNAUTHORIZED = Failed.Denied("UNAUTHORIZED", 400010, "Unauthorized") // Presumes a checked condition - - // Expected errors: 500000 + range - val MISSING = Failed.Errored("MISSING", 500002, "Missing item") // E.g. Domain model not found - val FORBIDDEN = Failed.Errored("FORBIDDEN", 500003, "Forbidden") - val CONFLICT = Failed.Errored("CONFLICT", 500004, "Conflict") - val DEPRECATED = Failed.Errored("DEPRECATED", 500005, "Deprecated") - val TIMEOUT = Failed.Errored("TIMEOUT", 500006, "Timeout") - val ERRORED = Failed.Errored("ERRORED", 500007, "Errored") // General purpose use - val LIMITED = Failed.Errored("LIMITED", 500009, "Limited") - - // Unexpected - val UNEXPECTED = Failed.Unknown("UNEXPECTED", 500008, "Unexpected") - - // Success ( Interactive / Metadata ) - val EXIT = Passed.Succeeded("EXIT", 600002, "Exiting") - val HELP = Passed.Succeeded("HELP", 600003, "Help") - val ABOUT = Passed.Succeeded("ABOUT", 600004, "About") - val VERSION = Passed.Succeeded("VERSION", 600005, "Version") - - private val mappings = + val HANDLED = Passed.Succeeded("HANDLED", 200007, "Handled") // e.g. a silent OK, similar to HTTP 204 + + // ---- Pending (200100-200199) ---- + val PENDING = Passed.Pending("PENDING", 200101, "Pending") + val QUEUED = Passed.Pending("QUEUED", 200102, "Queued") + val CONFIRM = Passed.Pending("CONFIRM", 200103, "Confirm") + + // ---- Filtered (200200-200299) ---- + val SKIPPED = Passed.Filtered("SKIPPED", 200201, "Skipped") // not processed at all + val DISCARDED = Passed.Filtered("DISCARDED", 200202, "Discarded") // processed, result thrown away + + // ---- Information (200300-200399) ---- + val HELP = Passed.Information("HELP", 200301, "Help") + val ABOUT = Passed.Information("ABOUT", 200302, "About") + val VERSION = Passed.Information("VERSION", 200303, "Version") + val EXIT = Passed.Information("EXIT", 200304, "Exiting") + + // ---- Denied (400000-400099) — security / access-control ---- + val DENIED = Failed.Denied("DENIED", 400001, "Denied") + val UNAUTHENTICATED = Failed.Denied("UNAUTHENTICATED", 400002, "Unauthenticated") + val UNAUTHORIZED = Failed.Denied("UNAUTHORIZED", 400003, "Unauthorized") + + // ---- Invalid (400100-400199) — bad input ---- + val BAD_REQUEST = Failed.Invalid("BAD_REQUEST", 400101, "Bad request") // e.g. malformed JSON + val INVALID = Failed.Invalid("INVALID", 400102, "Invalid") // e.g. well-formed but invalid values + val NOT_FOUND = Failed.Invalid("NOT_FOUND", 400103, "Not found") // e.g. resource/endpoint not found + + // ---- Errored (500000-500099) — known, expected business-rule failure ---- + val MISSING = Failed.Errored("MISSING", 500001, "Missing item") // e.g. domain model not found + val FORBIDDEN = Failed.Errored("FORBIDDEN", 500002, "Forbidden") + val CONFLICT = Failed.Errored("CONFLICT", 500003, "Conflict") + val DEPRECATED = Failed.Errored("DEPRECATED", 500004, "Deprecated") + val ERRORED = Failed.Errored("ERRORED", 500005, "Errored") // general purpose use + + // ---- Unserviceable (500100-500199) — valid & permitted, can't be serviced right now ---- + val UNIMPLEMENTED = Failed.Unserviceable("UNIMPLEMENTED", 500101, "Not implemented") + val UNSUPPORTED = Failed.Unserviceable("UNSUPPORTED", 500102, "Not supported") + val TIMEOUT = Failed.Unserviceable("TIMEOUT", 500103, "Timeout") + val RATE_LIMITED = Failed.Unserviceable("RATE_LIMITED", 500104, "Rate limited") + val UNREACHABLE = Failed.Unserviceable("UNREACHABLE", 500105, "Unreachable") // e.g. dependency down + val UNDER_MAINTENANCE = Failed.Unserviceable("UNDER_MAINTENANCE", 500106, "Under maintenance") + val UNEXPECTED = Failed.Unserviceable("UNEXPECTED", 500107, "Unexpected") // unhandled/uncaught path + + /** All built-in codes. Used for reverse lookups — see [CodesToHttp], [CompositeLookup]. */ + val all: List = listOf( - // CRUD - Triple(SUCCESS.code, SUCCESS, 200), - Triple(CREATED.code, CREATED, 201), - Triple(UPDATED.code, UPDATED, 200), - Triple(FETCHED.code, FETCHED, 200), - Triple(PATCHED.code, PATCHED, 200), - Triple(DELETED.code, DELETED, 200), - Triple(PENDING.code, PENDING, 202), - Triple(QUEUED.code, QUEUED, 202), - Triple(HANDLED.code, HANDLED, 204), - Triple(CONFIRM.code, CONFIRM, 200), - // JOB States - Triple(ACTIVE.code, ACTIVE, 200), - Triple(INACTIVE.code, INACTIVE, 200), - Triple(STARTING.code, STARTING, 200), - Triple(WAITING.code, WAITING, 200), - Triple(RUNNING.code, RUNNING, 200), - Triple(PAUSED.code, PAUSED, 200), - Triple(STOPPED.code, STOPPED, 200), - Triple(COMPLETE.code, COMPLETE, 200), - // Info - Triple(HELP.code, HELP, 200), - Triple(ABOUT.code, ABOUT, 200), - Triple(VERSION.code, VERSION, 200), - // Invalid - Triple(IGNORED.code, IGNORED, 400), - Triple(BAD_REQUEST.code, BAD_REQUEST, 400), - Triple(INVALID.code, INVALID, 400), - Triple(UNSUPPORTED.code, UNSUPPORTED, 501), - Triple(UNIMPLEMENTED.code, UNIMPLEMENTED, 501), - Triple(UNAVAILABLE.code, UNAVAILABLE, 503), - // Errors - Triple(MISSING.code, MISSING, 400), - Triple(NOT_FOUND.code, NOT_FOUND, 404), - Triple(DENIED.code, DENIED, 401), - Triple(UNAUTHENTICATED.code, UNAUTHENTICATED, 401), - Triple(UNAUTHORIZED.code, UNAUTHORIZED, 401), - Triple(FORBIDDEN.code, FORBIDDEN, 403), - Triple(TIMEOUT.code, TIMEOUT, 408), - Triple(CONFLICT.code, CONFLICT, 409), - Triple(DEPRECATED.code, DEPRECATED, 426), - Triple(ERRORED.code, ERRORED, 500), - Triple(UNEXPECTED.code, UNEXPECTED, 500), - Triple(EXIT.code, EXIT, 503), + SUCCESS, CREATED, UPDATED, FETCHED, PATCHED, DELETED, HANDLED, + PENDING, QUEUED, CONFIRM, + SKIPPED, DISCARDED, + HELP, ABOUT, VERSION, EXIT, + DENIED, UNAUTHENTICATED, UNAUTHORIZED, + BAD_REQUEST, INVALID, NOT_FOUND, + MISSING, FORBIDDEN, CONFLICT, DEPRECATED, ERRORED, + UNIMPLEMENTED, UNSUPPORTED, TIMEOUT, RATE_LIMITED, UNREACHABLE, UNDER_MAINTENANCE, UNEXPECTED, ) - private val lookupByCode = mappings.associateBy { it.first } - private val lookupByHttp = mappings.associateBy { it.third } + private val byCode: Map = all.associateBy { it.code } - fun contains(code: Int): Boolean = lookupByHttp.containsKey(code) + init { + check(byCode.size == all.size) { + val dupes = all.groupBy { it.code }.filterValues { it.size > 1 }.keys + "Duplicate Status codes detected in Codes registry: $dupes" + } + } - /** - * Converts a status to a compatible HTTP status code. - * TODO: HttpCode support to be added when kiit-codes gains an HttpCode dependency. - */ - fun toHttp(status: Status): Pair { - val entry = lookupByCode[status.code] - return if (entry != null) Pair(entry.third, status) else Pair(status.code, status) + /** Looks up a built-in [Status] by its internal registry code (e.g. 400001). Null if unknown. */ + fun statusForCode(code: Int): Status? = byCode[code] +} + +/** + * Bidirectional conversion between a [Status] and a target protocol's status code (e.g. HTTP). + * + * Implementations should be exhaustive over [Status]'s categories ([Passed]/[Failed] subtypes), + * typically via a `when` with no `else` branch, so a newly added category is caught at compile + * time. Individual codes within a category do not need an exhaustive mapping — they can be + * handled via a small overrides table layered on top of the category default (see [CodesToHttp]). + */ +interface CodeLookup { + /** Converts a [Status] to the target protocol's code. */ + fun toCode(status: Status): Int + + /** Converts a target protocol [code] to a matching [Status], or null if there is no match. */ + fun toStatus(code: Int): Status? +} + +/** + * Default [CodeLookup] implementation mapping [Status] to HTTP status codes. + * + * Category -> HTTP default: + * Succeeded / Filtered / Information -> 200 Pending -> 202 + * Denied -> 401 Invalid -> 400 Errored -> 500 Unserviceable -> 503 + * + * Individual codes can differ from their category's default via [overrides] (e.g. CREATED -> 201, + * NOT_FOUND -> 404). [toStatus] is derived from [toCode] rather than a separately maintained + * reverse table, so the two directions can never drift out of sync with each other. + * + * Clients needing additional/custom codes should compose with [CompositeLookup] rather than + * subclassing this type directly — see [CompositeLookup] for why. + */ +open class CodesToHttp( + private val overrides: Map = DEFAULT_OVERRIDES, +) : CodeLookup { + override fun toCode(status: Status): Int { + overrides[status.code]?.let { return it } + return when (status) { + is Passed.Succeeded -> 200 + is Passed.Pending -> 202 + is Passed.Filtered -> 200 + is Passed.Information -> 200 + is Failed.Denied -> 401 + is Failed.Invalid -> 400 + is Failed.Errored -> 500 + is Failed.Unserviceable -> 503 + } } /** - * Converts an HTTP status code to a matching [Status], or null if not found. + * Reverse lookup, derived from [toCode] over the built-in [Codes.all] registry. Note this + * only finds statuses registered in [Codes] — a caller's own custom [Status] instances that + * were never added to that registry won't be found here even if they'd resolve to [code] + * via [toCode]. Use [CompositeLookup] if you need custom statuses to also be reverse-lookupable. */ - fun toStatus(code: Int): Status? = lookupByCode[code]?.second + override fun toStatus(code: Int): Status? = Codes.all.firstOrNull { toCode(it) == code } - /** - * Converts a numeric code to its matching [Status]. - */ - fun ofCode(code: Int): Status { - val entry = lookupByHttp[code] - return when { - entry != null -> entry.second - code in 1..999 -> Passed.Succeeded(SUCCESS.name, code, SUCCESS.message) - code in 2000..2999 -> Failed.Invalid(INVALID.name, code, INVALID.message) - code >= 3000 -> Failed.Errored(ERRORED.name, code, ERRORED.message) - else -> Failed.Errored(UNEXPECTED.name, code, "Unexpected") - } + companion object { + val DEFAULT_OVERRIDES: Map = + mapOf( + Codes.CREATED.code to 201, + Codes.HANDLED.code to 204, + Codes.CONFIRM.code to 200, + Codes.NOT_FOUND.code to 404, + Codes.MISSING.code to 400, + Codes.FORBIDDEN.code to 403, + Codes.CONFLICT.code to 409, + Codes.DEPRECATED.code to 426, + Codes.UNIMPLEMENTED.code to 501, + Codes.UNSUPPORTED.code to 501, + Codes.TIMEOUT.code to 408, + Codes.RATE_LIMITED.code to 429, + Codes.UNEXPECTED.code to 500, + ) + } +} + +/** + * Composes a [base] [CodeLookup] with client-supplied [extensions], without modifying or + * subclassing the base implementation (composition over inheritance). [extensions] take + * precedence over [base] for both directions. + * + * [extensions] is keyed by the actual [Status] instance (not just its numeric code) so that + * [toStatus] can be answered correctly for custom statuses that aren't part of the [Codes.all] + * registry — a plain `Map` of code-to-code can't support that, since it never holds + * a reference to the actual custom Status object to return. + * + * ```kotlin + * val MY_DOMAIN_CODE = Failed.Errored("PAYMENT_DECLINED", 700123, "Payment declined") + * val lookup = CompositeLookup(CodesToHttp(), mapOf(MY_DOMAIN_CODE to 402)) + * ``` + */ +class CompositeLookup( + private val base: CodeLookup, + private val extensions: Map, +) : CodeLookup { + override fun toCode(status: Status): Int = extensions[status] ?: base.toCode(status) + + override fun toStatus(code: Int): Status? { + val extended = extensions.entries.firstOrNull { it.value == code }?.key + return extended ?: base.toStatus(code) } } diff --git a/src/core/codes/src/commonMain/kotlin/kiit/codes/Status.kt b/src/core/codes/src/commonMain/kotlin/kiit/codes/Status.kt index cb5dc3bb3..990a4b27f 100644 --- a/src/core/codes/src/commonMain/kotlin/kiit/codes/Status.kt +++ b/src/core/codes/src/commonMain/kotlin/kiit/codes/Status.kt @@ -12,172 +12,183 @@ package kiit.codes /** - * Platform-agnostic status type describing the outcome of any operation. + * Platform-agnostic status type describing the outcome of any operation — a service call, + * a background job step, an API request, or a CLI command. * * Shape (maps directly to JSON / API error responses): * { "name": "TOKEN_EXPIRED", "code": 400009, "message": "Session token expired", "success": false } * - * Hierarchy: - * Status = Passed | Failed - * Passed = Succeeded | Pending | Filtered | Ignored - * Failed = Denied | Invalid | Errored | Unknown + * Hierarchy. Categories are closed/sealed and fixed by design, to enforce a consistent taxonomy + * across every consumer. Individual codes *within* a category are open — create new domain codes + * by constructing a [Passed] or [Failed] subtype directly (see [Codes] for the built-in set): + * + * Status = Passed | Failed + * Passed = Succeeded | Pending | Filtered | Information + * Failed = Denied | Invalid | Errored | Unserviceable + * + * NOTE: [code] ranges are grouped conceptually the way HTTP groups 2xx/4xx/5xx, but this is a + * conceptual similarity only — [code] is NOT a literal HTTP status. Always convert via a + * [CodeLookup] implementation such as [CodesToHttp] to obtain a real HTTP status code; never + * infer one from the numeric prefix. */ -interface Status { +sealed interface Status { /** * Unique domain label, e.g. "TOKEN_EXPIRED", "RATE_LIMITED". - * Should be SCREAMING_SNAKE_CASE and stable — used as a searchable key in logs. + * SCREAMING_SNAKE_CASE, stable — used as a searchable/aggregable key in logs and metrics. */ val name: String /** - * Numeric code whose defaults align with HTTP status codes (200, 400, 500 ranges). - * Flexible for non-HTTP runtimes — convert via [Codes.toHttp]. + * Numeric code. Grouped by category by convention (see [Codes]) but NOT a literal HTTP code. */ val code: Int /** - * Human-readable description of this status. - * Must be a constant — never constructed from runtime data. + * Human-readable, constant description — never constructed from runtime data. Per-instance / + * runtime detail (e.g. "field X was invalid because...") belongs on whatever wraps this + * Status (an error/result type one layer up), not here — that keeps [message] safe to use + * as an aggregation key across every occurrence of this status. */ val message: String /** - * Boolean shortcut — true for [Passed.Succeeded] and [Passed.Pending], - * false for [Passed.Filtered], [Passed.Ignored], and all [Failed] subtypes. - * Callers that don't need to narrow the sealed type can use this directly. + * True for all [Passed] subtypes, false for all [Failed] subtypes. Callers that don't need + * to narrow the sealed type can branch on this directly instead of pattern matching. */ val success: Boolean + /** Returns a copy of this status with an updated [msg], preserving name and code. */ fun copyMessage(msg: String): Status + /** Returns a copy of this status with an updated [msg] and [code], preserving name. */ fun copyAll(msg: String, code: Int): Status companion object { /** - * Returns a copy of [defaultStatus] with an updated [msg] and/or [code]. - * Optimised to return the original instance when nothing would change. + * Returns a copy of [defaultStatus] with [msg] and/or [code] overridden, or the original + * instance unchanged if neither override actually differs from the default. A null or + * blank [msg] is treated as "no message override" (so it can be safely omitted by callers + * without extra null-handling). */ @Suppress("UNCHECKED_CAST") fun ofCode(msg: String?, code: Int?, defaultStatus: T): T { - if (code == null && msg == null || msg == "") return defaultStatus - if (code == defaultStatus.code && msg == null) return defaultStatus - if (code == defaultStatus.code && msg == defaultStatus.message) return defaultStatus - return defaultStatus.copyAll(msg ?: defaultStatus.message, code ?: defaultStatus.code) as T + val resolvedMsg = msg.takeUnless { it.isNullOrEmpty() } ?: defaultStatus.message + val resolvedCode = code ?: defaultStatus.code + + return if (resolvedMsg == defaultStatus.message && resolvedCode == defaultStatus.code) { + defaultStatus + } else { + defaultStatus.copyAll(resolvedMsg, resolvedCode) as T + } } /** - * Returns a copy of [status] with an updated [msg] and/or the override from [rawStatus]. - * Optimised to return the original instance when nothing would change. + * Resolves a status from an optional [msg] override and an optional [rawStatus] override, + * falling back to [status] when neither is supplied. [rawStatus], if present, is used as + * the base instead of [status]; [msg], if present, is then applied on top of that base. */ @Suppress("UNCHECKED_CAST") fun ofStatus(msg: String?, rawStatus: T?, status: T): T { - if (msg == null && rawStatus == null) return status - if (msg == null && rawStatus != null) return rawStatus - if (msg != null && rawStatus == null) return status.copyMessage(msg) as T - if (msg != null && rawStatus != null) return rawStatus.copyMessage(msg) as T - return status + val base = rawStatus ?: status + return if (msg == null) base else base.copyMessage(msg) as T } - fun toType(status: Status): String { - return when (status) { + /** Returns the lowercase category discriminant for a status, e.g. "denied", "errored". */ + fun toType(status: Status): String = + when (status) { is Passed.Succeeded -> "succeeded" is Passed.Pending -> "pending" is Passed.Filtered -> "filtered" - is Passed.Ignored -> "ignored" + is Passed.Information -> "information" is Failed.Denied -> "denied" is Failed.Invalid -> "invalid" is Failed.Errored -> "errored" - is Failed.Unknown -> "unknown" - else -> "unknown" + is Failed.Unserviceable -> "unserviceable" } - } } } /** - * Parent sealed type for all non-failure statuses. - * Subtypes: [Succeeded], [Pending], [Filtered], [Ignored]. + * Parent sealed type for all non-failure statuses (success = true for every subtype). + * Subtypes: [Succeeded], [Pending], [Filtered], [Information]. */ sealed class Passed : Status { - /** Operation completed successfully. */ - data class Succeeded(override val name: String, override val code: Int, override val message: String) : Passed() { - override val success = true - } + final override val success: Boolean get() = true - /** Operation accepted but not yet fully processed (e.g. queued, waiting). */ - data class Pending(override val name: String, override val code: Int, override val message: String) : Passed() { - override val success = true - } + /** Operation's primary purpose completed (e.g. a value was created, fetched, updated). */ + data class Succeeded(override val name: String, override val code: Int, override val message: String) : Passed() - /** Item was intentionally excluded from processing (e.g. deduplicated, out of scope). */ - data class Filtered(override val name: String, override val code: Int, override val message: String) : Passed() { - override val success = true - } + /** Operation accepted but not yet fully processed (e.g. queued, waiting, confirmed). */ + data class Pending(override val name: String, override val code: Int, override val message: String) : Passed() - /** Item was processed but its result was deliberately discarded or suppressed. */ - data class Ignored(override val name: String, override val code: Int, override val message: String) : Passed() { - override val success = true - } + /** + * Item was excluded from the operation's normal output. Covers both: + * - not processed at all (e.g. SKIPPED — screened out before any work happened), and + * - processed, then its result was deliberately discarded (e.g. DISCARDED). + * The distinction is carried by [name]/[code], not by separate types — see [Codes.SKIPPED] + * and [Codes.DISCARDED]. + */ + data class Filtered(override val name: String, override val code: Int, override val message: String) : Passed() - override fun copyAll(msg: String, code: Int): Status { - return when (this) { - is Succeeded -> this.copy(code = code, message = msg) - is Pending -> this.copy(code = code, message = msg) - is Filtered -> this.copy(code = code, message = msg) - is Ignored -> this.copy(code = code, message = msg) + /** + * Informational / metadata response — no primary operation was performed. + * E.g. HELP, ABOUT, VERSION output from a CLI command. + */ + data class Information(override val name: String, override val code: Int, override val message: String) : Passed() + + override fun copyAll(msg: String, code: Int): Status = + when (this) { + is Succeeded -> copy(code = code, message = msg) + is Pending -> copy(code = code, message = msg) + is Filtered -> copy(code = code, message = msg) + is Information -> copy(code = code, message = msg) } - } - override fun copyMessage(msg: String): Status { - return when (this) { - is Succeeded -> this.copy(message = msg) - is Pending -> this.copy(message = msg) - is Filtered -> this.copy(message = msg) - is Ignored -> this.copy(message = msg) + override fun copyMessage(msg: String): Status = + when (this) { + is Succeeded -> copy(message = msg) + is Pending -> copy(message = msg) + is Filtered -> copy(message = msg) + is Information -> copy(message = msg) } - } } /** - * Parent sealed type for all failure statuses (success = false for all subtypes). - * Subtypes: [Denied], [Invalid], [Errored], [Unknown]. + * Parent sealed type for all failure statuses (success = false for every subtype). + * Subtypes: [Denied], [Invalid], [Errored], [Unserviceable]. */ sealed class Failed : Status { - /** Security / access-control failure — the caller is not permitted to perform this action. */ - data class Denied(override val name: String, override val code: Int, override val message: String) : Failed() { - override val success = false - } + final override val success: Boolean get() = false - /** The input data is malformed or fails validation rules. */ - data class Invalid(override val name: String, override val code: Int, override val message: String) : Failed() { - override val success = false - } + /** Security / access-control failure — the caller is not permitted to perform this action. */ + data class Denied(override val name: String, override val code: Int, override val message: String) : Failed() - /** A known business-rule failure — expected, handled, and recoverable. */ - data class Errored(override val name: String, override val code: Int, override val message: String) : Failed() { - override val success = false - } + /** The request as given cannot be satisfied — malformed input, invalid values, or not found. */ + data class Invalid(override val name: String, override val code: Int, override val message: String) : Failed() - /** An unexpected or unhandled failure — equivalent to an uncaught exception path. */ - data class Unknown(override val name: String, override val code: Int, override val message: String) : Failed() { - override val success = false - } + /** A known, expected business-rule failure — understood and handled by the caller. */ + data class Errored(override val name: String, override val code: Int, override val message: String) : Failed() - override fun copyAll(msg: String, code: Int): Status { - return when (this) { - is Denied -> this.copy(name = name, code = code, message = msg) - is Invalid -> this.copy(name = name, code = code, message = msg) - is Errored -> this.copy(name = name, code = code, message = msg) - is Unknown -> this.copy(name = name, code = code, message = msg) + /** + * The request is valid and permitted, but cannot be serviced right now for reasons unrelated + * to what was sent — capacity, timeout, an unimplemented/unsupported capability, planned + * maintenance, or a genuinely unexpected/unhandled failure (see [Codes.UNEXPECTED]). + */ + data class Unserviceable(override val name: String, override val code: Int, override val message: String) : Failed() + + override fun copyAll(msg: String, code: Int): Status = + when (this) { + is Denied -> copy(code = code, message = msg) + is Invalid -> copy(code = code, message = msg) + is Errored -> copy(code = code, message = msg) + is Unserviceable -> copy(code = code, message = msg) } - } - override fun copyMessage(msg: String): Status { - return when (this) { - is Denied -> this.copy(message = msg) - is Invalid -> this.copy(message = msg) - is Errored -> this.copy(message = msg) - is Unknown -> this.copy(message = msg) + override fun copyMessage(msg: String): Status = + when (this) { + is Denied -> copy(message = msg) + is Invalid -> copy(message = msg) + is Errored -> copy(message = msg) + is Unserviceable -> copy(message = msg) } - } } diff --git a/src/core/codes/src/commonMain/kotlin/kiit/codes/StatusException.kt b/src/core/codes/src/commonMain/kotlin/kiit/codes/StatusException.kt index 1cad6b702..c4d9ab1f1 100644 --- a/src/core/codes/src/commonMain/kotlin/kiit/codes/StatusException.kt +++ b/src/core/codes/src/commonMain/kotlin/kiit/codes/StatusException.kt @@ -9,11 +9,18 @@ package kiit.codes * ```kotlin * throw StatusException(Codes.UNAUTHORIZED) * - * try { ... } catch (e: StatusException) { + * // with a cause + * throw StatusException(Codes.TIMEOUT, cause = ioException) + * + * try { + * // ... + * } catch (e: StatusException) { * when (e.status) { - * is Failed.Denied -> // handle auth failure - * is Failed.Errored -> // handle business error - * else -> // ... + * is Failed.Denied -> // handle auth failure + * is Failed.Invalid -> // handle bad input + * is Failed.Errored -> // handle known business-rule failure + * is Failed.Unserviceable -> // handle capacity / timeout / unimplemented / unexpected + * is Passed -> // n/a — Passed statuses aren't normally thrown * } * } * ``` From b3dfc3d0a72d29b24a7b755570d3233c69ba87a8 Mon Sep 17 00:00:00 2001 From: kishore Date: Sat, 18 Jul 2026 20:20:05 -0400 Subject: [PATCH 2/3] update: unit-tests --- .../commonTest/kotlin/kiit/codes/CodesTest.kt | 208 ++++++++++++------ .../kotlin/kiit/codes/StatusExceptionTest.kt | 14 +- .../kotlin/kiit/codes/StatusTest.kt | 80 ++++--- 3 files changed, 212 insertions(+), 90 deletions(-) diff --git a/src/core/codes/src/commonTest/kotlin/kiit/codes/CodesTest.kt b/src/core/codes/src/commonTest/kotlin/kiit/codes/CodesTest.kt index 33bd29024..6c8d47f62 100644 --- a/src/core/codes/src/commonTest/kotlin/kiit/codes/CodesTest.kt +++ b/src/core/codes/src/commonTest/kotlin/kiit/codes/CodesTest.kt @@ -8,11 +8,11 @@ import kotlin.test.assertNull import kotlin.test.assertSame import kotlin.test.assertTrue -class CodesTest { - // ------------------------------------------------------------------------- - // Spot-check a few built-in code values - // ------------------------------------------------------------------------- +// ================================================================================================= +// CodesTest — the built-in registry +// ================================================================================================= +class CodesTest { @Test fun successHasCorrectValues() { assertEquals("SUCCESS", Codes.SUCCESS.name) @@ -24,116 +24,196 @@ class CodesTest { @Test fun deniedHasCorrectValues() { assertEquals("DENIED", Codes.DENIED.name) - assertEquals(400005, Codes.DENIED.code) + assertEquals(400001, Codes.DENIED.code) assertFalse(Codes.DENIED.success) } @Test - fun filteredAndIgnoredHaveSuccessTrue() { - assertTrue(Codes.FILTERED.success) - assertTrue(Codes.IGNORED.success) + fun skippedAndDiscardedHaveDistinctCodesAndSuccessTrue() { + // Regression test for the original bug: FILTERED and IGNORED both = 200204. + assertTrue(Codes.SKIPPED.success) + assertTrue(Codes.DISCARDED.success) + assertNotEqualsCode(Codes.SKIPPED, Codes.DISCARDED) + } + + @Test + fun informationCodesHaveSuccessTrue() { + assertTrue(Codes.HELP.success) + assertTrue(Codes.ABOUT.success) + assertTrue(Codes.VERSION.success) + assertTrue(Codes.EXIT.success) } + @Test + fun registryHasNoDuplicateCodes() { + // Codes' init block already enforces this the moment the object is touched (which every + // test in this file does) — this test just names the invariant explicitly for clarity. + val codes = Codes.all.map { it.code } + assertEquals(codes.size, codes.toSet().size, "Duplicate codes found in Codes.all: $codes") + } + + @Test + fun statusForCodeReturnsMatchForKnownInternalCode() { + val status = Codes.statusForCode(Codes.SUCCESS.code) + assertNotNull(status) + assertEquals(Codes.SUCCESS.name, status.name) + } + + @Test + fun statusForCodeReturnsNullForUnknownCode() { + assertNull(Codes.statusForCode(999999)) + } + + private fun assertNotEqualsCode(a: Status, b: Status) { + assertTrue(a.code != b.code, "Expected distinct codes but both were ${a.code}") + } +} + +// ================================================================================================= +// CodesToHttpTest / CompositeLookupTest — CodeLookup implementations +// ================================================================================================= + +class CodesToHttpTest { + private val http = CodesToHttp() + // ------------------------------------------------------------------------- - // toHttp — converts a Status to an HTTP status code + // toCode — category defaults // ------------------------------------------------------------------------- - @Test fun toHttpSuccess() { - assertEquals(200, Codes.toHttp(Codes.SUCCESS).first) + @Test fun categoryDefaultSucceeded() { + assertEquals(200, http.toCode(Codes.SUCCESS)) + assertEquals(200, http.toCode(Codes.UPDATED)) } - @Test fun toHttpCreated() { - assertEquals(201, Codes.toHttp(Codes.CREATED).first) + @Test fun categoryDefaultPending() { + assertEquals(202, http.toCode(Codes.PENDING)) + assertEquals(202, http.toCode(Codes.QUEUED)) } - @Test fun toHttpDenied() { - assertEquals(401, Codes.toHttp(Codes.DENIED).first) + @Test fun categoryDefaultFiltered() { + assertEquals(200, http.toCode(Codes.SKIPPED)) + assertEquals(200, http.toCode(Codes.DISCARDED)) } - @Test fun toHttpNotFound() { - assertEquals(404, Codes.toHttp(Codes.NOT_FOUND).first) + @Test fun categoryDefaultInformation() { + assertEquals(200, http.toCode(Codes.ABOUT)) } - @Test fun toHttpUnexpected() { - assertEquals(500, Codes.toHttp(Codes.UNEXPECTED).first) + @Test fun categoryDefaultDenied() { + assertEquals(401, http.toCode(Codes.DENIED)) + assertEquals(401, http.toCode(Codes.UNAUTHENTICATED)) } - @Test - fun toHttpPreservesOriginalStatus() { - val (_, status) = Codes.toHttp(Codes.SUCCESS) - assertSame(Codes.SUCCESS, status) + @Test fun categoryDefaultInvalid() { + assertEquals(400, http.toCode(Codes.BAD_REQUEST)) + assertEquals(400, http.toCode(Codes.INVALID)) } - @Test - fun toHttpFallsBackToStatusCodeForUnknownStatus() { - val custom = Failed.Errored("CUSTOM", 599, "Custom error") - val (httpCode, _) = Codes.toHttp(custom) - assertEquals(599, httpCode) + @Test fun categoryDefaultErrored() { + assertEquals(500, http.toCode(Codes.ERRORED)) + } + + @Test fun categoryDefaultUnserviceable() { + assertEquals(503, http.toCode(Codes.UNREACHABLE)) + assertEquals(503, http.toCode(Codes.UNDER_MAINTENANCE)) } // ------------------------------------------------------------------------- - // toStatus — reverse lookup by kiit numeric code + // toCode — per-code overrides // ------------------------------------------------------------------------- - @Test - fun toStatusReturnsMatchForKnownCode() { - val status = Codes.toStatus(Codes.SUCCESS.code) - assertNotNull(status) - assertEquals(Codes.SUCCESS.name, status.name) + @Test fun overrideCreated() { + assertEquals(201, http.toCode(Codes.CREATED)) + } + + @Test fun overrideHandled() { + assertEquals(204, http.toCode(Codes.HANDLED)) + } + + @Test fun overrideNotFound() { + assertEquals(404, http.toCode(Codes.NOT_FOUND)) + } + + @Test fun overrideForbidden() { + assertEquals(403, http.toCode(Codes.FORBIDDEN)) + } + + @Test fun overrideConflict() { + assertEquals(409, http.toCode(Codes.CONFLICT)) + } + + @Test fun overrideTimeout() { + assertEquals(408, http.toCode(Codes.TIMEOUT)) } + @Test fun overrideRateLimited() { + assertEquals(429, http.toCode(Codes.RATE_LIMITED)) + } + + @Test fun overrideUnexpected() { + assertEquals(500, http.toCode(Codes.UNEXPECTED)) + } + + /** + * A custom, unregistered status still resolves via its category's default rather than a + * guessed/literal fallback — this replaces the old (buggy) behavior of returning the + * status's own internal code as if it were a valid HTTP code. + */ @Test - fun toStatusReturnsNullForUnknownCode() { - assertNull(Codes.toStatus(99999)) + fun toCodeFallsBackToCategoryDefaultForCustomStatus() { + val custom = Failed.Errored("CUSTOM", 700123, "Custom error") + assertEquals(500, http.toCode(custom)) // Errored's category default, not 700123 } // ------------------------------------------------------------------------- - // ofCode — maps an HTTP code to a Status, with range-based fallbacks + // toStatus — reverse lookup, derived from toCode // ------------------------------------------------------------------------- @Test - fun ofCodeReturnsKnownStatusForRegisteredHttpCode() { - // 201 is unique in the table so the name is deterministic - assertEquals(Codes.CREATED.name, Codes.ofCode(201).name) - // 200/500 have multiple mappings — just verify success flag and type - assertTrue(Codes.ofCode(200).success) - assertFalse(Codes.ofCode(500).success) - assertFalse(Codes.ofCode(404).success) + fun toStatusFindsRegisteredStatusForUniqueHttpCode() { + val status = http.toStatus(201) + assertNotNull(status) + assertEquals(Codes.CREATED.name, status.name) } @Test - fun ofCodeFallsBackToSucceededForRange1To999() { - val status = Codes.ofCode(42) - assertTrue(status is Passed.Succeeded) - assertEquals(42, status.code) + fun toStatusReturnsNullForUnrecognizedHttpCode() { + // No guessed range fallback — an unrecognized code is honestly null, caller decides the default. + assertNull(http.toStatus(999)) } @Test - fun ofCodeFallsBackToInvalidForRange2000To2999() { - val status = Codes.ofCode(2001) - assertTrue(status is Failed.Invalid) - assertEquals(2001, status.code) + fun toStatusRoundTripsForOverriddenCode() { + val status = http.toStatus(404) + assertNotNull(status) + assertEquals(Codes.NOT_FOUND.name, status.name) } +} + +class CompositeLookupTest { + private val customCode = Failed.Errored("PAYMENT_DECLINED", 700123, "Payment declined") + private val lookup = CompositeLookup(base = CodesToHttp(), extensions = mapOf(customCode to 402)) @Test - fun ofCodeFallsBackToErroredForCodeAbove3000() { - val status = Codes.ofCode(9999) - assertTrue(status is Failed.Errored) - assertEquals(9999, status.code) + fun extensionTakesPrecedenceForToCode() { + assertEquals(402, lookup.toCode(customCode)) } - // ------------------------------------------------------------------------- - // contains — checks whether an HTTP code is in the mapping table - // ------------------------------------------------------------------------- + @Test + fun extensionSupportsReverseLookup() { + val status = lookup.toStatus(402) + assertNotNull(status) + assertSame(customCode, status) + } @Test - fun containsReturnsTrueForKnownHttpCode() { - assertTrue(Codes.contains(200)) - assertTrue(Codes.contains(404)) + fun fallsBackToBaseForRegisteredCodes() { + assertEquals(401, lookup.toCode(Codes.DENIED)) + assertSame(Codes.CREATED, lookup.toStatus(201)) } @Test - fun containsReturnsFalseForUnknownCode() { - assertFalse(Codes.contains(99999)) + fun fallsBackToBaseNullWhenNeitherKnows() { + assertNull(lookup.toStatus(999)) } } diff --git a/src/core/codes/src/commonTest/kotlin/kiit/codes/StatusExceptionTest.kt b/src/core/codes/src/commonTest/kotlin/kiit/codes/StatusExceptionTest.kt index bd46598c5..385c8c608 100644 --- a/src/core/codes/src/commonTest/kotlin/kiit/codes/StatusExceptionTest.kt +++ b/src/core/codes/src/commonTest/kotlin/kiit/codes/StatusExceptionTest.kt @@ -5,6 +5,11 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertSame +import kotlin.test.assertTrue + +// ================================================================================================= +// StatusExceptionTest +// ================================================================================================= class StatusExceptionTest { @Test @@ -47,9 +52,16 @@ class StatusExceptionTest { @Test fun worksWithCustomStatus() { - val custom = Failed.Errored("RATE_LIMITED", 500099, "Rate limited") + val custom = Failed.Errored("RATE_LIMITED", 500199, "Rate limited") val ex = StatusException(custom) assertEquals("Rate limited", ex.message) assertSame(custom, ex.status) } + + @Test + fun worksWithUnserviceableStatus() { + val ex = StatusException(Codes.UNREACHABLE) + assertEquals(Codes.UNREACHABLE.message, ex.message) + assertTrue(ex.status is Failed.Unserviceable) + } } diff --git a/src/core/codes/src/commonTest/kotlin/kiit/codes/StatusTest.kt b/src/core/codes/src/commonTest/kotlin/kiit/codes/StatusTest.kt index 76d8973b2..24fb5a9d1 100644 --- a/src/core/codes/src/commonTest/kotlin/kiit/codes/StatusTest.kt +++ b/src/core/codes/src/commonTest/kotlin/kiit/codes/StatusTest.kt @@ -7,9 +7,13 @@ import kotlin.test.assertNotSame import kotlin.test.assertSame import kotlin.test.assertTrue +// ================================================================================================= +// StatusTest — Passed/Failed subtypes, copy helpers, ofCode/ofStatus companion functions +// ================================================================================================= + class StatusTest { // ------------------------------------------------------------------------- - // success flag — Passed subtypes + // success flag — Passed subtypes (hoisted onto Passed itself; see Passed.success) // ------------------------------------------------------------------------- @Test fun succeededHasSuccessTrue() { @@ -17,35 +21,35 @@ class StatusTest { } @Test fun pendingHasSuccessTrue() { - assertTrue(Passed.Pending("P", 200008, "P").success) + assertTrue(Passed.Pending("P", 200101, "P").success) } @Test fun filteredHasSuccessTrue() { - assertTrue(Passed.Filtered("F", 200204, "F").success) + assertTrue(Passed.Filtered("F", 200201, "F").success) } - @Test fun ignoredHasSuccessTrue() { - assertTrue(Passed.Ignored("I", 200204, "I").success) + @Test fun informationHasSuccessTrue() { + assertTrue(Passed.Information("I", 200301, "I").success) } // ------------------------------------------------------------------------- - // success flag — Failed subtypes + // success flag — Failed subtypes (hoisted onto Failed itself; see Failed.success) // ------------------------------------------------------------------------- @Test fun deniedHasSuccessFalse() { - assertFalse(Failed.Denied("D", 400005, "D").success) + assertFalse(Failed.Denied("D", 400001, "D").success) } @Test fun invalidHasSuccessFalse() { - assertFalse(Failed.Invalid("I", 400003, "I").success) + assertFalse(Failed.Invalid("I", 400102, "I").success) } @Test fun erroredHasSuccessFalse() { - assertFalse(Failed.Errored("E", 500007, "E").success) + assertFalse(Failed.Errored("E", 500005, "E").success) } - @Test fun unknownHasSuccessFalse() { - assertFalse(Failed.Unknown("U", 500008, "U").success) + @Test fun unserviceableHasSuccessFalse() { + assertFalse(Failed.Unserviceable("U", 500107, "U").success) } // ------------------------------------------------------------------------- @@ -64,7 +68,7 @@ class StatusTest { @Test fun copyMessageOnPending() { - val s = Passed.Pending("PENDING", 200008, "Pending") + val s = Passed.Pending("PENDING", 200101, "Pending") val copy = s.copyMessage("Custom") assertEquals("Custom", copy.message) assertEquals(s.code, copy.code) @@ -72,21 +76,22 @@ class StatusTest { @Test fun copyMessageOnFiltered() { - val s = Passed.Filtered("FILTERED", 200204, "Filtered") + val s = Passed.Filtered("SKIPPED", 200201, "Skipped") val copy = s.copyMessage("Custom") assertEquals("Custom", copy.message) } @Test - fun copyMessageOnIgnored() { - val s = Passed.Ignored("IGNORED", 200204, "Ignored") + fun copyMessageOnInformation() { + val s = Passed.Information("HELP", 200301, "Help") val copy = s.copyMessage("Custom") assertEquals("Custom", copy.message) + assertTrue(copy.success) } @Test fun copyMessageOnDenied() { - val s = Failed.Denied("DENIED", 400005, "Denied") + val s = Failed.Denied("DENIED", 400001, "Denied") val copy = s.copyMessage("Custom") assertEquals("Custom", copy.message) assertFalse(copy.success) @@ -94,23 +99,24 @@ class StatusTest { @Test fun copyMessageOnInvalid() { - val s = Failed.Invalid("INVALID", 400003, "Invalid") + val s = Failed.Invalid("INVALID", 400102, "Invalid") val copy = s.copyMessage("Custom") assertEquals("Custom", copy.message) } @Test fun copyMessageOnErrored() { - val s = Failed.Errored("ERRORED", 500007, "Errored") + val s = Failed.Errored("ERRORED", 500005, "Errored") val copy = s.copyMessage("Custom") assertEquals("Custom", copy.message) } @Test - fun copyMessageOnUnknown() { - val s = Failed.Unknown("UNKNOWN", 500008, "Unknown") + fun copyMessageOnUnserviceable() { + val s = Failed.Unserviceable("UNEXPECTED", 500107, "Unexpected") val copy = s.copyMessage("Custom") assertEquals("Custom", copy.message) + assertFalse(copy.success) } // ------------------------------------------------------------------------- @@ -128,15 +134,24 @@ class StatusTest { @Test fun copyAllOnDenied() { - val s = Failed.Denied("DENIED", 400005, "Denied") + val s = Failed.Denied("DENIED", 400001, "Denied") val copy = s.copyAll("Custom", 403) assertEquals("Custom", copy.message) assertEquals(403, copy.code) assertEquals(s.name, copy.name) } + @Test + fun copyAllOnUnserviceable() { + val s = Failed.Unserviceable("TIMEOUT", 500103, "Timeout") + val copy = s.copyAll("Custom", 408) + assertEquals("Custom", copy.message) + assertEquals(408, copy.code) + assertEquals(s.name, copy.name) + } + // ------------------------------------------------------------------------- - // toType — returns lowercase discriminant string + // toType — returns lowercase category discriminant, exhaustive over all 8 subtypes // ------------------------------------------------------------------------- @Test @@ -144,15 +159,15 @@ class StatusTest { assertEquals("succeeded", Status.toType(Passed.Succeeded("S", 1, "S"))) assertEquals("pending", Status.toType(Passed.Pending("P", 2, "P"))) assertEquals("filtered", Status.toType(Passed.Filtered("F", 3, "F"))) - assertEquals("ignored", Status.toType(Passed.Ignored("I", 4, "I"))) + assertEquals("information", Status.toType(Passed.Information("N", 4, "N"))) assertEquals("denied", Status.toType(Failed.Denied("D", 5, "D"))) assertEquals("invalid", Status.toType(Failed.Invalid("I", 6, "I"))) assertEquals("errored", Status.toType(Failed.Errored("E", 7, "E"))) - assertEquals("unknown", Status.toType(Failed.Unknown("U", 8, "U"))) + assertEquals("unserviceable", Status.toType(Failed.Unserviceable("U", 8, "U"))) } // ------------------------------------------------------------------------- - // ofCode — returns same instance when nothing changes (optimisation) + // ofCode — returns same instance when nothing changes; applies overrides independently // ------------------------------------------------------------------------- @Test @@ -197,6 +212,21 @@ class StatusTest { assertEquals(default.message, result.message) } + /** + * Regression test for the original operator-precedence bug: + * `if (code == null && msg == null || msg == "") return defaultStatus` + * bound as `(code == null && msg == null) || (msg == "")`, so any call with msg == "" + * returned defaultStatus regardless of a supplied code, silently dropping the override. + */ + @Test + fun ofCodeAppliesCodeOverrideEvenWhenMsgIsEmptyString() { + val default = Codes.DENIED + val result = Status.ofCode("", 401099, default) + assertNotSame(default, result) + assertEquals(401099, result.code) + assertEquals(default.message, result.message) + } + // ------------------------------------------------------------------------- // ofStatus — selects correct instance based on msg / rawStatus nullability // ------------------------------------------------------------------------- From f0929415cead9107ec56ed717e0b6c0526ed3a86 Mon Sep 17 00:00:00 2001 From: kishore Date: Sat, 18 Jul 2026 20:23:55 -0400 Subject: [PATCH 3/3] Delete Example_Codes.kt --- .../src/commonTest/kotlin/kiit/codes/Example_Codes.kt | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 src/core/codes/src/commonTest/kotlin/kiit/codes/Example_Codes.kt diff --git a/src/core/codes/src/commonTest/kotlin/kiit/codes/Example_Codes.kt b/src/core/codes/src/commonTest/kotlin/kiit/codes/Example_Codes.kt deleted file mode 100644 index ebd4fb155..000000000 --- a/src/core/codes/src/commonTest/kotlin/kiit/codes/Example_Codes.kt +++ /dev/null @@ -1,8 +0,0 @@ -package kiit.codes - -// -// import kiit.codes.Code -// - -// -//