From c985679b53c1fa10c405a8b46906f0671e1bcd90 Mon Sep 17 00:00:00 2001 From: Frank Kilcommins Date: Fri, 10 Apr 2026 14:59:46 +0100 Subject: [PATCH 01/14] feat(repo): add python and typescript implementations --- .gitignore | 71 + LICENSE | 201 ++ NOTICE | 25 + README.md | 146 +- python/.gitignore | 43 + python/README.md | 181 ++ python/pyproject.toml | 56 + python/src/jentic/__init__.py | 2 + python/src/jentic/problem_details/__init__.py | 58 + python/src/jentic/problem_details/models.py | 181 ++ .../src/jentic/problem_details/responses.py | 140 + python/tests/test_models.py | 171 ++ python/tests/test_responses.py | 178 ++ typescript/.gitignore | 20 + typescript/README.md | 274 ++ typescript/package-lock.json | 2298 +++++++++++++++++ typescript/package.json | 54 + typescript/src/errors.ts | 196 ++ typescript/src/index.ts | 36 + typescript/src/types.ts | 147 ++ typescript/tests/errors.test.ts | 210 ++ typescript/tests/types.test.ts | 177 ++ typescript/tsconfig.json | 26 + typescript/vitest.config.ts | 14 + 24 files changed, 4892 insertions(+), 13 deletions(-) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 NOTICE create mode 100644 python/.gitignore create mode 100644 python/README.md create mode 100644 python/pyproject.toml create mode 100644 python/src/jentic/__init__.py create mode 100644 python/src/jentic/problem_details/__init__.py create mode 100644 python/src/jentic/problem_details/models.py create mode 100644 python/src/jentic/problem_details/responses.py create mode 100644 python/tests/test_models.py create mode 100644 python/tests/test_responses.py create mode 100644 typescript/.gitignore create mode 100644 typescript/README.md create mode 100644 typescript/package-lock.json create mode 100644 typescript/package.json create mode 100644 typescript/src/errors.ts create mode 100644 typescript/src/index.ts create mode 100644 typescript/src/types.ts create mode 100644 typescript/tests/errors.test.ts create mode 100644 typescript/tests/types.test.ts create mode 100644 typescript/tsconfig.json create mode 100644 typescript/vitest.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e9ce7a --- /dev/null +++ b/.gitignore @@ -0,0 +1,71 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +venv/ +ENV/ +env/ +.venv/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +coverage/ +.hypothesis/ + +# TypeScript/JavaScript +node_modules/ +dist/ +*.tsbuildinfo +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*.sublime-project +*.sublime-workspace + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Type checking +.mypy_cache/ +.dmypy.json +dmypy.json +.pytype/ + +# Build outputs +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..04f9d13 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 Jentic + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..d9b0be2 --- /dev/null +++ b/NOTICE @@ -0,0 +1,25 @@ +Jentic API Problem Details +Copyright 2025 Jentic + +This product includes software developed at Jentic (https://jentic.com/). + +This software implements RFC 9457 "Problem Details for HTTP APIs" +as specified by the Internet Engineering Task Force (IETF). +See: https://www.rfc-editor.org/rfc/rfc9457.html + +-------------------------------------------------------------------------------- + +The following third-party dependencies are used by the Python package: + +- Pydantic (MIT License) + Copyright (c) 2017 Samuel Colvin + https://github.com/pydantic/pydantic + +- FastAPI (MIT License) [optional] + Copyright (c) 2018 Sebastián Ramírez + https://github.com/tiangolo/fastapi + +-------------------------------------------------------------------------------- + +The TypeScript package has no runtime dependencies. +Development dependencies are listed in typescript/package.json. diff --git a/README.md b/README.md index f75b7b2..3a1562c 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,18 @@ Reusable [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html) Problem Details ## Purpose -All Jentic APIs use `application/problem+json` for error responses. Rather than inlining schemas and responses in every API, all Jentic OpenAPI descriptions reference this repository's `openapi-domain.yaml` directly. +All Jentic APIs use `application/problem+json` for error responses. This repository provides: + +1. **OpenAPI schemas and response definitions** — for referencing in API specifications +2. **Python package** (`jentic-problem-details`) — Pydantic models and FastAPI utilities +3. **TypeScript package** (`@jentic/problem-details`) — Type definitions and utilities for frontend applications + +Rather than inlining schemas and responses in every API, all Jentic OpenAPI descriptions reference this repository's components directly. ## Usage +### OpenAPI Specifications + Reference components directly from your OpenAPI description: ```yaml @@ -32,13 +40,114 @@ responses: $ref: 'https://raw.githubusercontent.com/jentic/api-problem-details/refs/heads/main/responses/503-service-unavailable.yaml' ``` +### Python / FastAPI + +Install the package: + +```bash +pip install jentic-problem-details +``` + +Use in your FastAPI application: + +```python +from fastapi import FastAPI +from jentic.problem_details import ( + BadRequest, + NotFound, + ProblemDetailException, + problem_detail_exception_handler, +) + +app = FastAPI() +app.add_exception_handler(ProblemDetailException, problem_detail_exception_handler) + +@app.get("/users/{user_id}") +async def get_user(user_id: str): + user = await db.get_user(user_id) + if not user: + raise NotFound( + detail=f"User '{user_id}' not found", + instance=f"/users/{user_id}" + ) + return user + +@app.post("/users") +async def create_user(request: Request): + data = await request.json() + + errors = [] + if not data.get("name"): + errors.append({"detail": "Field 'name' is required", "pointer": "#/name"}) + if not data.get("email"): + errors.append({"detail": "Field 'email' is required", "pointer": "#/email"}) + + if errors: + raise BadRequest( + detail="The request body is missing required fields", + instance="/users", + errors=errors + ) + + return await db.create_user(data) +``` + +See the [Python package README](./python/README.md) for complete documentation. + +### TypeScript / JavaScript + +Install the package: + +```bash +npm install @jentic/problem-details +``` + +Use in your application: + +```typescript +import { createProblemDetail, ProblemDetailError } from '@jentic/problem-details'; + +// Create a problem detail +const problem = createProblemDetail.notFound('User not found', { + instance: '/api/users/123', +}); + +// Handle errors from fetch +try { + const response = await fetch('/api/users/123'); + if (!response.ok) { + throw await ProblemDetailError.fromResponse(response); + } + return await response.json(); +} catch (err) { + if (err instanceof ProblemDetailError) { + console.error('API Error:', err.problemDetail); + // Access structured error data + if (err.problemDetail.errors) { + err.problemDetail.errors.forEach(error => { + console.log(`${error.pointer}: ${error.detail}`); + }); + } + } +} +``` + +See the [TypeScript package README](./typescript/README.md) for complete documentation. + ## Problem Types Jentic uses `about:blank` as the `type` for most problem responses, per RFC 9457 guidance. When `type` is `about:blank`, the `title` SHOULD be the standard HTTP status phrase and `detail` MUST provide a human-readable explanation specific to the occurrence. Where a specific IANA-registered problem type applies, it SHOULD be used. See the [IANA HTTP Problem Types Registry](https://www.iana.org/assignments/http-problem-types/http-problem-types.xhtml). -## Structure +## Packages + +| Package | Language | Status | Installation | +|---------|----------|--------|--------------| +| [`jentic-problem-details`](./python) | Python | ✅ Available | `pip install jentic-problem-details` | +| [`@jentic/problem-details`](./typescript) | TypeScript | ✅ Available | `npm install @jentic/problem-details` | + +## Repository Structure ``` openapi-domain.yaml # Primary artifact — all components bundled @@ -46,17 +155,28 @@ schemas/ problem-details.yaml # ProblemDetails schema error-item.yaml # ErrorItem schema (errors[] array entries) responses/ - 400.yaml - 401.yaml - 403.yaml - 404.yaml - 409.yaml - 422.yaml - 429.yaml - 500.yaml - 503.yaml -headers/ - common.yaml # Deprecation, Sunset, RateLimit headers + 400-bad-request.yaml + 401-unauthorized.yaml + 403-forbidden.yaml + 404-not-found.yaml + 409-conflict.yaml + 422-validation-error.yaml + 429-too-many-requests.yaml + 500-server-error.yaml + 503-service-unavailable.yaml +python/ # Python package (jentic-problem-details) + src/jentic/problem_details/ + tests/ + pyproject.toml + README.md +typescript/ # TypeScript package (@jentic/problem-details) + src/ + tests/ + package.json + tsconfig.json + README.md +LICENSE # Apache 2.0 License +NOTICE # Copyright and attribution notices ``` ## Standards References diff --git a/python/.gitignore b/python/.gitignore new file mode 100644 index 0000000..ab32ec7 --- /dev/null +++ b/python/.gitignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +ENV/ +env/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo + +# Type checking +.mypy_cache/ +.dmypy.json +dmypy.json diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..9fd29f7 --- /dev/null +++ b/python/README.md @@ -0,0 +1,181 @@ +# jentic-problem-details (Python) + +RFC 9457 Problem Details models for Jentic APIs. + +## Installation + +```bash +pip install jentic-problem-details +``` + +For FastAPI integration: +```bash +pip install jentic-problem-details[fastapi] +``` + +## Quick Start + +### Basic Usage + +```python +from jentic.problem_details import ProblemDetail, ErrorItem + +# Create a problem detail response +problem = ProblemDetail( + status=400, + title="Bad Request", + detail="The request body is missing required field 'name'.", + instance="/v2/capability-sets", + errors=[ + ErrorItem( + detail="Field 'name' is required.", + pointer="#/name" + ) + ] +) + +print(problem.model_dump_json(exclude_none=True)) +``` + +### FastAPI Integration + +```python +from fastapi import FastAPI, Request +from jentic.problem_details import ( + BadRequest, + NotFound, + ProblemDetailException, + problem_detail_exception_handler, +) + +app = FastAPI() + +# Register the exception handler +app.add_exception_handler(ProblemDetailException, problem_detail_exception_handler) + +@app.get("/users/{user_id}") +async def get_user(user_id: str): + # Input validation + if not user_id: + raise BadRequest( + detail="User ID is required", + instance="/users", + code="JENTIC-4001" + ) + + # Resource not found + user = await db.get_user(user_id) + if not user: + raise NotFound( + detail=f"User '{user_id}' not found", + instance=f"/users/{user_id}" + ) + + return user + +@app.post("/users") +async def create_user(request: Request): + data = await request.json() + + # Validation with multiple errors + errors = [] + if not data.get("name"): + errors.append({"detail": "Field 'name' is required", "pointer": "#/name"}) + if not data.get("email"): + errors.append({"detail": "Field 'email' is required", "pointer": "#/email"}) + + if errors: + raise BadRequest( + detail="The request body is missing required fields", + instance="/users", + errors=errors + ) + + user = await db.create_user(data) + return user +``` + +### Response Format + +All exceptions produce responses with `Content-Type: application/problem+json`: + +```json +{ + "type": "about:blank", + "status": 400, + "title": "Bad Request", + "detail": "The request body is missing required fields", + "instance": "/users", + "errors": [ + { + "detail": "Field 'name' is required", + "pointer": "#/name" + } + ] +} +``` + +## Available Exceptions + +| Exception | Status | Description | +|-----------|--------|-------------| +| `BadRequest` | 400 | Client error (malformed syntax, invalid parameters) | +| `Unauthorized` | 401 | Authentication required or failed | +| `Forbidden` | 403 | Server refuses to authorize the request | +| `NotFound` | 404 | Resource does not exist | +| `Conflict` | 409 | Request conflicts with current state | +| `ValidationError` | 422 | Request is well-formed but semantically invalid | +| `TooManyRequests` | 429 | Rate limit exceeded | +| `ServerError` | 500 | Unexpected server error | +| `ServiceUnavailable` | 503 | Server temporarily unable to handle request | + +All exceptions extend `ProblemDetailException` which accepts: + +- `detail` (required): Human-readable explanation +- `type`: URI identifying the problem type (default: "about:blank") +- `title`: Short summary (default: standard HTTP phrase) +- `instance`: Request path or URI reference +- `code`: Provider-specific error code +- `errors`: List of `ErrorItem` for granular validation errors +- `headers`: Additional HTTP headers + +## Error Items + +For validation errors with multiple fields, use the `errors` array: + +```python +from jentic.problem_details import ValidationError, ErrorItem + +raise ValidationError( + detail="Multiple validation errors occurred", + instance="/api/resources", + errors=[ + ErrorItem(detail="Must be a positive integer", parameter="limit"), + ErrorItem(detail="Must be 'asc' or 'desc'", parameter="order"), + ErrorItem(detail="Must be a valid email", pointer="#/email"), + ErrorItem(detail="Authorization header is malformed", header="Authorization"), + ] +) +``` + +## Development + +```bash +# Install dependencies +pip install -e ".[dev]" + +# Run tests +pytest + +# Type checking +mypy src/ +``` + +## Standards + +- [RFC 9457 — Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc9457.html) +- [IANA HTTP Problem Types Registry](https://www.iana.org/assignments/http-problem-types/http-problem-types.xhtml) + +## License + +MIT diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 0000000..b4b760b --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,56 @@ +[project] +name = "jentic-problem-details" +version = "1.0.0" +description = "RFC 9457 Problem Details models for Jentic APIs" +readme = "README.md" +requires-python = ">=3.11" +license = {text = "MIT"} +authors = [ + {name = "Jentic", email = "hello@jentic.com"} +] +keywords = ["rfc9457", "problem-details", "fastapi", "pydantic", "openapi"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Framework :: FastAPI", + "Framework :: Pydantic", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = [ + "pydantic>=2.9.0", +] + +[project.optional-dependencies] +fastapi = [ + "fastapi>=0.100.0", +] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.24.0", + "fastapi>=0.100.0", +] + +[project.urls] +Homepage = "https://github.com/jentic/api-problem-details" +Repository = "https://github.com/jentic/api-problem-details" +Documentation = "https://github.com/jentic/api-problem-details#readme" +Issues = "https://github.com/jentic/api-problem-details/issues" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/jentic"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = "test_*.py" +python_functions = "test_*" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" diff --git a/python/src/jentic/__init__.py b/python/src/jentic/__init__.py new file mode 100644 index 0000000..98ea7e1 --- /dev/null +++ b/python/src/jentic/__init__.py @@ -0,0 +1,2 @@ +"""Jentic namespace package.""" +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/python/src/jentic/problem_details/__init__.py b/python/src/jentic/problem_details/__init__.py new file mode 100644 index 0000000..d07da0d --- /dev/null +++ b/python/src/jentic/problem_details/__init__.py @@ -0,0 +1,58 @@ +"""RFC 9457 Problem Details models for Jentic APIs. + +This package provides Pydantic models and FastAPI utilities for standardized +error responses following RFC 9457 (Problem Details for HTTP APIs). + +Basic usage: + from jentic.problem_details import ProblemDetail, BadRequest + + # Raise an error in FastAPI + raise BadRequest( + detail="Missing required field 'name'", + instance="/v2/capability-sets", + errors=[{"detail": "Field 'name' is required", "pointer": "#/name"}] + ) + +FastAPI integration: + from fastapi import FastAPI + from jentic.problem_details import ProblemDetailException, problem_detail_exception_handler + + app = FastAPI() + app.add_exception_handler(ProblemDetailException, problem_detail_exception_handler) +""" + +__version__ = "1.0.0" + +from .models import ErrorItem, ProblemDetail +from .responses import ( + BadRequest, + Conflict, + Forbidden, + NotFound, + ProblemDetailException, + ServerError, + ServiceUnavailable, + TooManyRequests, + Unauthorized, + ValidationError, + problem_detail_exception_handler, +) + +__all__ = [ + # Models + "ProblemDetail", + "ErrorItem", + # Exceptions + "ProblemDetailException", + "BadRequest", + "Unauthorized", + "Forbidden", + "NotFound", + "Conflict", + "ValidationError", + "TooManyRequests", + "ServerError", + "ServiceUnavailable", + # Utilities + "problem_detail_exception_handler", +] diff --git a/python/src/jentic/problem_details/models.py b/python/src/jentic/problem_details/models.py new file mode 100644 index 0000000..ec4f55e --- /dev/null +++ b/python/src/jentic/problem_details/models.py @@ -0,0 +1,181 @@ +"""RFC 9457 Problem Details models for Jentic APIs. + +These Pydantic models mirror the OpenAPI schemas in the parent repository. +""" +from typing import Annotated + +from pydantic import BaseModel, Field, HttpUrl + + +class ErrorItem(BaseModel): + """A granular error detail entry within the errors[] array of a ProblemDetails response. + + At least one of pointer, parameter, or header SHOULD be present to identify the error source. + """ + + detail: Annotated[ + str, + Field( + max_length=4096, + description="A human-readable explanation of this specific error. Be precise — name the field, parameter, or header involved.", + examples=["Field 'name' must not be blank."], + ), + ] + + pointer: Annotated[ + str | None, + Field( + default=None, + max_length=1024, + description="A JSON Pointer (RFC 6901) to the specific request body property that is the source of this error.", + examples=["#/name"], + ), + ] = None + + parameter: Annotated[ + str | None, + Field( + default=None, + max_length=1024, + description="The name of the query or path parameter that is the source of this error.", + examples=["limit"], + ), + ] = None + + header: Annotated[ + str | None, + Field( + default=None, + max_length=1024, + description="The name of the request header that is the source of this error.", + examples=["Authorization"], + ), + ] = None + + code: Annotated[ + str | None, + Field( + default=None, + max_length=50, + description="An optional provider-specific code identifying this error in internal taxonomy or documentation.", + examples=["JENTIC-V-001"], + ), + ] = None + + +class ProblemDetail(BaseModel): + """RFC 9457 Problem Details for HTTP APIs. + + This is the standard error response format for all Jentic APIs. + Content-Type: application/problem+json + + See: https://www.rfc-editor.org/rfc/rfc9457.html + """ + + type: Annotated[ + str, + Field( + default="about:blank", + max_length=1024, + description=( + "A URI reference identifying the problem type. When set to 'about:blank', " + "the title SHOULD be the standard HTTP status phrase. Use an IANA-registered " + "type URI where one applies." + ), + examples=["about:blank"], + ), + ] = "about:blank" + + status: Annotated[ + int | None, + Field( + default=None, + ge=100, + le=599, + description="The HTTP status code for this occurrence of the problem.", + examples=[400], + ), + ] = None + + title: Annotated[ + str | None, + Field( + default=None, + max_length=1024, + description=( + "A short, human-readable summary of the problem type. Should not change " + "between occurrences except for localisation purposes." + ), + examples=["Bad Request"], + ), + ] = None + + detail: Annotated[ + str, + Field( + max_length=4096, + description=( + "A human-readable explanation specific to this occurrence of the problem. " + "MUST be present. Provide actionable information where possible." + ), + examples=["The request body is missing required field 'name'."], + ), + ] + + instance: Annotated[ + str | None, + Field( + default=None, + max_length=1024, + description=( + "A URI reference identifying the specific occurrence of the problem. " + "Typically the request path." + ), + examples=["/v2/capability-sets"], + ), + ] = None + + code: Annotated[ + str | None, + Field( + default=None, + max_length=50, + description=( + "An optional provider-specific code for internal error taxonomy and " + "observability correlation." + ), + examples=["JENTIC-4001"], + ), + ] = None + + errors: Annotated[ + list[ErrorItem] | None, + Field( + default=None, + max_length=1000, + description=( + "An array of granular error details. Use when multiple validation errors " + "or field-level problems need to be surfaced in a single response." + ), + ), + ] = None + + model_config = { + "json_schema_extra": { + "examples": [ + { + "type": "about:blank", + "status": 400, + "title": "Bad Request", + "detail": "The request body is missing one or more required fields.", + "instance": "/v2/capability-sets", + "errors": [ + { + "detail": "Field 'name' is required.", + "pointer": "#/name", + } + ], + } + ] + } + } diff --git a/python/src/jentic/problem_details/responses.py b/python/src/jentic/problem_details/responses.py new file mode 100644 index 0000000..492a755 --- /dev/null +++ b/python/src/jentic/problem_details/responses.py @@ -0,0 +1,140 @@ +"""FastAPI response helpers for RFC 9457 Problem Details. + +Provides exception classes and utilities for raising standardized error responses. +""" +from typing import Any + +from fastapi import HTTPException, Request +from fastapi.responses import JSONResponse + +from .models import ErrorItem, ProblemDetail + + +class ProblemDetailException(HTTPException): + """Base exception for Problem Details responses. + + Raises an HTTPException that FastAPI will serialize to application/problem+json. + """ + + def __init__( + self, + status_code: int, + detail: str, + *, + type: str = "about:blank", + title: str | None = None, + instance: str | None = None, + code: str | None = None, + errors: list[ErrorItem | dict[str, Any]] | None = None, + headers: dict[str, str] | None = None, + ): + """Create a Problem Details exception. + + Args: + status_code: HTTP status code (e.g., 400, 404, 500) + detail: Human-readable explanation of this specific occurrence + type: URI identifying the problem type (default: "about:blank") + title: Short summary of the problem type (default: standard HTTP phrase) + instance: URI reference to this specific occurrence (e.g., request path) + code: Provider-specific error code for taxonomy/observability + errors: Array of granular error details (for validation errors) + headers: Additional HTTP headers to include in the response + """ + problem = ProblemDetail( + type=type, + status=status_code, + title=title, + detail=detail, + instance=instance, + code=code, + errors=[ErrorItem(**e) if isinstance(e, dict) else e for e in errors] if errors else None, + ) + super().__init__( + status_code=status_code, + detail=problem.model_dump(mode="json", exclude_none=True), + headers=headers, + ) + + +# Common HTTP error shortcuts +class BadRequest(ProblemDetailException): + """400 Bad Request — client error (malformed syntax, invalid parameters, missing required fields).""" + + def __init__(self, detail: str, **kwargs): + super().__init__(400, detail, title=kwargs.pop("title", "Bad Request"), **kwargs) + + +class Unauthorized(ProblemDetailException): + """401 Unauthorized — authentication is required and has failed or has not been provided.""" + + def __init__(self, detail: str, **kwargs): + super().__init__(401, detail, title=kwargs.pop("title", "Unauthorized"), **kwargs) + + +class Forbidden(ProblemDetailException): + """403 Forbidden — server understood the request but refuses to authorize it.""" + + def __init__(self, detail: str, **kwargs): + super().__init__(403, detail, title=kwargs.pop("title", "Forbidden"), **kwargs) + + +class NotFound(ProblemDetailException): + """404 Not Found — the requested resource does not exist.""" + + def __init__(self, detail: str, **kwargs): + super().__init__(404, detail, title=kwargs.pop("title", "Not Found"), **kwargs) + + +class Conflict(ProblemDetailException): + """409 Conflict — request conflicts with current state (duplicate resource, concurrent modification).""" + + def __init__(self, detail: str, **kwargs): + super().__init__(409, detail, title=kwargs.pop("title", "Conflict"), **kwargs) + + +class ValidationError(ProblemDetailException): + """422 Unprocessable Content — request is well-formed but contains semantic errors.""" + + def __init__(self, detail: str, **kwargs): + super().__init__(422, detail, title=kwargs.pop("title", "Validation Error"), **kwargs) + + +class TooManyRequests(ProblemDetailException): + """429 Too Many Requests — rate limit exceeded.""" + + def __init__(self, detail: str, **kwargs): + super().__init__(429, detail, title=kwargs.pop("title", "Too Many Requests"), **kwargs) + + +class ServerError(ProblemDetailException): + """500 Internal Server Error — unexpected server error.""" + + def __init__(self, detail: str, **kwargs): + super().__init__(500, detail, title=kwargs.pop("title", "Internal Server Error"), **kwargs) + + +class ServiceUnavailable(ProblemDetailException): + """503 Service Unavailable — server is temporarily unable to handle the request.""" + + def __init__(self, detail: str, **kwargs): + super().__init__(503, detail, title=kwargs.pop("title", "Service Unavailable"), **kwargs) + + +async def problem_detail_exception_handler(request: Request, exc: ProblemDetailException) -> JSONResponse: + """FastAPI exception handler for ProblemDetailException. + + Converts ProblemDetailException to a properly formatted application/problem+json response. + + Usage: + from fastapi import FastAPI + from jentic.problem_details.responses import problem_detail_exception_handler, ProblemDetailException + + app = FastAPI() + app.add_exception_handler(ProblemDetailException, problem_detail_exception_handler) + """ + return JSONResponse( + status_code=exc.status_code, + content=exc.detail, + headers=exc.headers, + media_type="application/problem+json", + ) diff --git a/python/tests/test_models.py b/python/tests/test_models.py new file mode 100644 index 0000000..c2bee7d --- /dev/null +++ b/python/tests/test_models.py @@ -0,0 +1,171 @@ +"""Tests for Problem Details models.""" +import pytest +from pydantic import ValidationError + +from jentic.problem_details import ErrorItem, ProblemDetail + + +def test_problem_detail_minimal(): + """Test creating a minimal ProblemDetail with only required fields.""" + problem = ProblemDetail(detail="Something went wrong") + + assert problem.detail == "Something went wrong" + assert problem.type == "about:blank" + assert problem.status is None + assert problem.title is None + assert problem.instance is None + assert problem.code is None + assert problem.errors is None + + +def test_problem_detail_full(): + """Test creating a ProblemDetail with all fields.""" + problem = ProblemDetail( + type="about:blank", + status=400, + title="Bad Request", + detail="The request body is missing required field 'name'.", + instance="/v2/capability-sets", + code="JENTIC-4001", + errors=[ + ErrorItem( + detail="Field 'name' is required.", + pointer="#/name" + ) + ] + ) + + assert problem.type == "about:blank" + assert problem.status == 400 + assert problem.title == "Bad Request" + assert problem.detail == "The request body is missing required field 'name'." + assert problem.instance == "/v2/capability-sets" + assert problem.code == "JENTIC-4001" + assert len(problem.errors) == 1 + assert problem.errors[0].detail == "Field 'name' is required." + + +def test_problem_detail_json_serialization(): + """Test that ProblemDetail serializes to JSON correctly.""" + problem = ProblemDetail( + status=400, + title="Bad Request", + detail="Invalid input", + instance="/test" + ) + + json_data = problem.model_dump(mode="json", exclude_none=True) + + assert json_data["type"] == "about:blank" + assert json_data["status"] == 400 + assert json_data["title"] == "Bad Request" + assert json_data["detail"] == "Invalid input" + assert json_data["instance"] == "/test" + assert "code" not in json_data # Should be excluded when None + assert "errors" not in json_data # Should be excluded when None + + +def test_error_item_minimal(): + """Test creating an ErrorItem with only required fields.""" + error = ErrorItem(detail="Field 'name' is required") + + assert error.detail == "Field 'name' is required" + assert error.pointer is None + assert error.parameter is None + assert error.header is None + assert error.code is None + + +def test_error_item_with_pointer(): + """Test ErrorItem with JSON pointer.""" + error = ErrorItem( + detail="Field 'email' must be a valid email address", + pointer="#/email" + ) + + assert error.detail == "Field 'email' must be a valid email address" + assert error.pointer == "#/email" + + +def test_error_item_with_parameter(): + """Test ErrorItem with query parameter.""" + error = ErrorItem( + detail="Must be a positive integer between 1 and 100", + parameter="limit" + ) + + assert error.detail == "Must be a positive integer between 1 and 100" + assert error.parameter == "limit" + + +def test_error_item_with_header(): + """Test ErrorItem with request header.""" + error = ErrorItem( + detail="Authorization header is malformed", + header="Authorization", + code="AUTH-001" + ) + + assert error.detail == "Authorization header is malformed" + assert error.header == "Authorization" + assert error.code == "AUTH-001" + + +def test_problem_detail_multiple_errors(): + """Test ProblemDetail with multiple validation errors.""" + problem = ProblemDetail( + status=422, + title="Validation Error", + detail="Multiple validation errors occurred", + instance="/api/resources", + errors=[ + ErrorItem(detail="Field 'name' is required", pointer="#/name"), + ErrorItem(detail="Field 'email' must be valid", pointer="#/email"), + ErrorItem(detail="Parameter 'limit' must be positive", parameter="limit"), + ] + ) + + assert len(problem.errors) == 3 + assert problem.errors[0].pointer == "#/name" + assert problem.errors[1].pointer == "#/email" + assert problem.errors[2].parameter == "limit" + + +def test_problem_detail_status_code_validation(): + """Test that status code is validated within HTTP range.""" + # Valid status codes + ProblemDetail(detail="Test", status=200) + ProblemDetail(detail="Test", status=599) + + # Invalid status codes should raise validation error + with pytest.raises(ValidationError): + ProblemDetail(detail="Test", status=99) + + with pytest.raises(ValidationError): + ProblemDetail(detail="Test", status=600) + + +def test_problem_detail_max_length_validation(): + """Test that max_length constraints are enforced.""" + # detail exceeds max_length + with pytest.raises(ValidationError): + ProblemDetail(detail="x" * 4097) + + # title exceeds max_length + with pytest.raises(ValidationError): + ProblemDetail(detail="Test", title="x" * 1025) + + # code exceeds max_length + with pytest.raises(ValidationError): + ProblemDetail(detail="Test", code="x" * 51) + + +def test_error_item_max_length_validation(): + """Test that ErrorItem max_length constraints are enforced.""" + # detail exceeds max_length + with pytest.raises(ValidationError): + ErrorItem(detail="x" * 4097) + + # pointer exceeds max_length + with pytest.raises(ValidationError): + ErrorItem(detail="Test", pointer="x" * 1025) diff --git a/python/tests/test_responses.py b/python/tests/test_responses.py new file mode 100644 index 0000000..a7673bc --- /dev/null +++ b/python/tests/test_responses.py @@ -0,0 +1,178 @@ +"""Tests for FastAPI response helpers.""" +import pytest + +from jentic.problem_details import ( + BadRequest, + Conflict, + Forbidden, + NotFound, + ProblemDetailException, + ServerError, + ServiceUnavailable, + TooManyRequests, + Unauthorized, + ValidationError, +) + + +def test_problem_detail_exception_basic(): + """Test basic ProblemDetailException creation.""" + exc = ProblemDetailException( + status_code=400, + detail="Invalid input", + ) + + assert exc.status_code == 400 + assert isinstance(exc.detail, dict) + assert exc.detail["detail"] == "Invalid input" + assert exc.detail["type"] == "about:blank" + assert exc.detail["status"] == 400 + + +def test_problem_detail_exception_with_errors(): + """Test ProblemDetailException with validation errors.""" + exc = ProblemDetailException( + status_code=422, + detail="Validation failed", + errors=[ + {"detail": "Field 'name' is required", "pointer": "#/name"}, + {"detail": "Field 'email' is invalid", "pointer": "#/email"}, + ] + ) + + assert exc.status_code == 422 + assert len(exc.detail["errors"]) == 2 + assert exc.detail["errors"][0]["detail"] == "Field 'name' is required" + + +def test_problem_detail_exception_with_code(): + """Test ProblemDetailException with custom error code.""" + exc = ProblemDetailException( + status_code=400, + detail="Custom error", + code="JENTIC-4001", + instance="/v2/test" + ) + + assert exc.detail["code"] == "JENTIC-4001" + assert exc.detail["instance"] == "/v2/test" + + +def test_bad_request(): + """Test BadRequest exception.""" + exc = BadRequest(detail="Missing required field") + + assert exc.status_code == 400 + assert exc.detail["title"] == "Bad Request" + assert exc.detail["detail"] == "Missing required field" + + +def test_unauthorized(): + """Test Unauthorized exception.""" + exc = Unauthorized(detail="Invalid credentials") + + assert exc.status_code == 401 + assert exc.detail["title"] == "Unauthorized" + + +def test_forbidden(): + """Test Forbidden exception.""" + exc = Forbidden(detail="Access denied") + + assert exc.status_code == 403 + assert exc.detail["title"] == "Forbidden" + + +def test_not_found(): + """Test NotFound exception.""" + exc = NotFound(detail="Resource not found") + + assert exc.status_code == 404 + assert exc.detail["title"] == "Not Found" + + +def test_conflict(): + """Test Conflict exception.""" + exc = Conflict(detail="Resource already exists") + + assert exc.status_code == 409 + assert exc.detail["title"] == "Conflict" + + +def test_validation_error(): + """Test ValidationError exception.""" + exc = ValidationError(detail="Invalid input format") + + assert exc.status_code == 422 + assert exc.detail["title"] == "Validation Error" + + +def test_too_many_requests(): + """Test TooManyRequests exception.""" + exc = TooManyRequests(detail="Rate limit exceeded") + + assert exc.status_code == 429 + assert exc.detail["title"] == "Too Many Requests" + + +def test_server_error(): + """Test ServerError exception.""" + exc = ServerError(detail="Unexpected error") + + assert exc.status_code == 500 + assert exc.detail["title"] == "Internal Server Error" + + +def test_service_unavailable(): + """Test ServiceUnavailable exception.""" + exc = ServiceUnavailable(detail="Database connection lost") + + assert exc.status_code == 503 + assert exc.detail["title"] == "Service Unavailable" + + +def test_custom_title_override(): + """Test that custom title can override default.""" + exc = BadRequest(detail="Test", title="Custom Bad Request") + + assert exc.detail["title"] == "Custom Bad Request" + + +def test_headers(): + """Test that custom headers are preserved.""" + exc = TooManyRequests( + detail="Rate limit exceeded", + headers={"Retry-After": "60"} + ) + + assert exc.headers == {"Retry-After": "60"} + + +def test_exclude_none_in_serialization(): + """Test that None values are excluded from the response.""" + exc = BadRequest(detail="Test error") + + # Should not include None fields + assert "code" not in exc.detail + assert "errors" not in exc.detail + assert "instance" not in exc.detail + + +@pytest.mark.asyncio +async def test_exception_handler_integration(): + """Test that exception handler can be used with FastAPI (requires fastapi installed).""" + pytest.importorskip("fastapi") + + from fastapi import Request + from jentic.problem_details import problem_detail_exception_handler + + # Mock request + class MockRequest: + pass + + exc = BadRequest(detail="Test error", instance="/test") + response = await problem_detail_exception_handler(MockRequest(), exc) + + assert response.status_code == 400 + assert response.media_type == "application/problem+json" + assert response.body is not None diff --git a/typescript/.gitignore b/typescript/.gitignore new file mode 100644 index 0000000..cfca7a5 --- /dev/null +++ b/typescript/.gitignore @@ -0,0 +1,20 @@ +# Build output +dist/ +*.tsbuildinfo + +# Dependencies +node_modules/ + +# Testing +coverage/ +.vitest/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db diff --git a/typescript/README.md b/typescript/README.md new file mode 100644 index 0000000..7555020 --- /dev/null +++ b/typescript/README.md @@ -0,0 +1,274 @@ +# @jentic/problem-details (TypeScript) + +RFC 9457 Problem Details types for Jentic APIs. + +## Installation + +```bash +npm install @jentic/problem-details +``` + +## Quick Start + +### Type Definitions + +```typescript +import type { ProblemDetail, ErrorItem } from '@jentic/problem-details'; + +// Use as response types +interface ApiError extends ProblemDetail { + // Add custom fields if needed +} + +// Type-safe error handling +function handleError(error: ProblemDetail) { + console.log(`${error.status}: ${error.title}`); + console.log(error.detail); + + if (error.errors) { + error.errors.forEach(err => { + if (err.pointer) { + console.log(` - ${err.pointer}: ${err.detail}`); + } + }); + } +} +``` + +### Creating Problem Details + +```typescript +import { createProblemDetail, createErrorItem } from '@jentic/problem-details'; + +// Simple error +const notFound = createProblemDetail.notFound('User not found', { + instance: '/api/users/123', +}); + +// Validation error with multiple fields +const validation = createProblemDetail.validationError( + 'Multiple validation errors occurred', + { + instance: '/api/users', + errors: [ + createErrorItem("Field 'name' is required", { pointer: '#/name' }), + createErrorItem("Field 'email' must be valid", { pointer: '#/email' }), + ], + } +); +``` + +### Error Handling with Fetch + +```typescript +import { ProblemDetailError } from '@jentic/problem-details'; + +async function fetchUser(id: string) { + const response = await fetch(`/api/users/${id}`); + + if (!response.ok) { + throw await ProblemDetailError.fromResponse(response); + } + + return response.json(); +} + +// Usage +try { + const user = await fetchUser('123'); +} catch (err) { + if (err instanceof ProblemDetailError) { + console.error('API Error:', err.problemDetail); + + // Access structured error data + if (err.problemDetail.status === 404) { + console.log('User not found'); + } + + // Show validation errors to user + if (err.problemDetail.errors) { + err.problemDetail.errors.forEach(error => { + console.log(`${error.pointer}: ${error.detail}`); + }); + } + } else { + console.error('Unexpected error:', err); + } +} +``` + +### Type Guards + +```typescript +import { isProblemDetail, isErrorItem } from '@jentic/problem-details'; + +// Check if response is a ProblemDetail +const response = await fetch('/api/users'); +const data = await response.json(); + +if (isProblemDetail(data)) { + console.error('API returned error:', data.detail); + + if (data.errors && data.errors.every(isErrorItem)) { + // TypeScript knows these are ErrorItems + data.errors.forEach(err => console.log(err.detail)); + } +} +``` + +### React Example + +```tsx +import { useState } from 'react'; +import { ProblemDetailError, type ProblemDetail } from '@jentic/problem-details'; + +function UserForm() { + const [error, setError] = useState(null); + + async function handleSubmit(data: FormData) { + try { + const response = await fetch('/api/users', { + method: 'POST', + body: JSON.stringify(data), + }); + + if (!response.ok) { + throw await ProblemDetailError.fromResponse(response); + } + + // Success + } catch (err) { + if (err instanceof ProblemDetailError) { + setError(err.problemDetail); + } + } + } + + return ( +
+ {error && ( +
+

{error.title}

+

{error.detail}

+ {error.errors && ( +
    + {error.errors.map((err, i) => ( +
  • + {err.pointer && {err.pointer}:} {err.detail} +
  • + ))} +
+ )} +
+ )} + {/* Form fields */} +
+ ); +} +``` + +## API Reference + +### Types + +#### `ProblemDetail` + +```typescript +interface ProblemDetail { + type?: string; // URI identifying problem type (default: "about:blank") + status?: number; // HTTP status code + title?: string; // Short summary of the problem + detail: string; // Human-readable explanation (required) + instance?: string; // URI reference to this occurrence + code?: string; // Provider-specific error code + errors?: ErrorItem[]; // Array of granular error details +} +``` + +#### `ErrorItem` + +```typescript +interface ErrorItem { + detail: string; // Human-readable error explanation (required) + pointer?: string; // JSON Pointer to request body property + parameter?: string;// Query/path parameter name + header?: string; // Request header name + code?: string; // Provider-specific code +} +``` + +### Functions + +#### `createProblemDetail` + +Factory functions for common HTTP errors: + +- `createProblemDetail.badRequest(detail, options?)` — 400 +- `createProblemDetail.unauthorized(detail, options?)` — 401 +- `createProblemDetail.forbidden(detail, options?)` — 403 +- `createProblemDetail.notFound(detail, options?)` — 404 +- `createProblemDetail.conflict(detail, options?)` — 409 +- `createProblemDetail.validationError(detail, options?)` — 422 +- `createProblemDetail.tooManyRequests(detail, options?)` — 429 +- `createProblemDetail.serverError(detail, options?)` — 500 +- `createProblemDetail.serviceUnavailable(detail, options?)` — 503 + +#### `createErrorItem(detail, options?)` + +Helper to create validation error items. + +#### `isProblemDetail(obj)` + +Type guard to check if an object is a `ProblemDetail`. + +#### `isErrorItem(obj)` + +Type guard to check if an object is an `ErrorItem`. + +### Classes + +#### `ProblemDetailError extends Error` + +Custom error class carrying RFC 9457 Problem Details data. + +**Constructor:** +```typescript +new ProblemDetailError(problemDetail: ProblemDetail) +``` + +**Properties:** +- `problemDetail: ProblemDetail` — The problem detail data + +**Static Methods:** +- `ProblemDetailError.fromResponse(response: Response): Promise` — Create from a fetch Response + +**Methods:** +- `toJSON(): ProblemDetail` — Serialize to JSON + +## Development + +```bash +# Install dependencies +npm install + +# Build +npm run build + +# Run tests +npm test + +# Type checking +npm run typecheck + +# Coverage +npm run test:coverage +``` + +## Standards + +- [RFC 9457 — Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc9457.html) +- [IANA HTTP Problem Types Registry](https://www.iana.org/assignments/http-problem-types/http-problem-types.xhtml) + +## License + +Apache-2.0 diff --git a/typescript/package-lock.json b/typescript/package-lock.json new file mode 100644 index 0000000..50be95f --- /dev/null +++ b/typescript/package-lock.json @@ -0,0 +1,2298 @@ +{ + "name": "@jentic/problem-details", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@jentic/problem-details", + "version": "1.0.0", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^2.0.0", + "typescript": "^5.6.0", + "vitest": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", + "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.12", + "magicast": "^0.3.5", + "std-env": "^3.8.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "2.1.9", + "vitest": "2.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/typescript/package.json b/typescript/package.json new file mode 100644 index 0000000..5c2c1e8 --- /dev/null +++ b/typescript/package.json @@ -0,0 +1,54 @@ +{ + "name": "@jentic/problem-details", + "version": "1.0.0", + "description": "RFC 9457 Problem Details types for Jentic APIs", + "license": "Apache-2.0", + "author": "Jentic ", + "homepage": "https://github.com/jentic/api-problem-details#readme", + "repository": { + "type": "git", + "url": "https://github.com/jentic/api-problem-details.git", + "directory": "typescript" + }, + "bugs": { + "url": "https://github.com/jentic/api-problem-details/issues" + }, + "keywords": [ + "rfc9457", + "problem-details", + "typescript", + "error-handling", + "http", + "api" + ], + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc", + "test": "vitest", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage", + "typecheck": "tsc --noEmit", + "prepublishOnly": "npm run build" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^2.0.0", + "typescript": "^5.6.0", + "vitest": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/typescript/src/errors.ts b/typescript/src/errors.ts new file mode 100644 index 0000000..39dba70 --- /dev/null +++ b/typescript/src/errors.ts @@ -0,0 +1,196 @@ +/** + * Error utilities for creating and working with RFC 9457 Problem Details. + */ +import type { ErrorItem, ProblemDetail } from './types.js'; + +/** + * Custom error class that carries RFC 9457 Problem Details data. + * + * Useful for throwing errors in frontend code that can be serialized + * to problem+json format or displayed to users. + */ +export class ProblemDetailError extends Error { + public readonly problemDetail: ProblemDetail; + + constructor(problemDetail: ProblemDetail) { + super(problemDetail.detail); + this.name = 'ProblemDetailError'; + this.problemDetail = problemDetail; + + // Maintains proper stack trace for where our error was thrown (only available on V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, ProblemDetailError); + } + } + + /** + * Serialize to JSON (for logging or API responses). + */ + toJSON(): ProblemDetail { + return this.problemDetail; + } + + /** + * Create a ProblemDetailError from a fetch Response. + * + * @example + * try { + * const response = await fetch('/api/users'); + * if (!response.ok) { + * throw await ProblemDetailError.fromResponse(response); + * } + * } catch (err) { + * if (err instanceof ProblemDetailError) { + * console.log(err.problemDetail); + * } + * } + */ + static async fromResponse(response: Response): Promise { + const contentType = response.headers.get('content-type') || ''; + + if (contentType.includes('application/problem+json')) { + const problemDetail = (await response.json()) as ProblemDetail; + return new ProblemDetailError(problemDetail); + } + + // Fallback for non-problem+json error responses + const text = await response.text(); + return new ProblemDetailError({ + status: response.status, + title: response.statusText, + detail: text || `HTTP ${response.status} ${response.statusText}`, + }); + } +} + +/** + * Factory functions for common HTTP error Problem Details. + */ +export const createProblemDetail = { + /** + * 400 Bad Request — client error (malformed syntax, invalid parameters, missing required fields). + */ + badRequest(detail: string, options?: Partial>): ProblemDetail { + return { + type: 'about:blank', + status: 400, + title: 'Bad Request', + detail, + ...options, + }; + }, + + /** + * 401 Unauthorized — authentication is required and has failed or has not been provided. + */ + unauthorized(detail: string, options?: Partial>): ProblemDetail { + return { + type: 'about:blank', + status: 401, + title: 'Unauthorized', + detail, + ...options, + }; + }, + + /** + * 403 Forbidden — server understood the request but refuses to authorize it. + */ + forbidden(detail: string, options?: Partial>): ProblemDetail { + return { + type: 'about:blank', + status: 403, + title: 'Forbidden', + detail, + ...options, + }; + }, + + /** + * 404 Not Found — the requested resource does not exist. + */ + notFound(detail: string, options?: Partial>): ProblemDetail { + return { + type: 'about:blank', + status: 404, + title: 'Not Found', + detail, + ...options, + }; + }, + + /** + * 409 Conflict — request conflicts with current state (duplicate resource, concurrent modification). + */ + conflict(detail: string, options?: Partial>): ProblemDetail { + return { + type: 'about:blank', + status: 409, + title: 'Conflict', + detail, + ...options, + }; + }, + + /** + * 422 Unprocessable Content — request is well-formed but contains semantic errors. + */ + validationError(detail: string, options?: Partial>): ProblemDetail { + return { + type: 'about:blank', + status: 422, + title: 'Validation Error', + detail, + ...options, + }; + }, + + /** + * 429 Too Many Requests — rate limit exceeded. + */ + tooManyRequests(detail: string, options?: Partial>): ProblemDetail { + return { + type: 'about:blank', + status: 429, + title: 'Too Many Requests', + detail, + ...options, + }; + }, + + /** + * 500 Internal Server Error — unexpected server error. + */ + serverError(detail: string, options?: Partial>): ProblemDetail { + return { + type: 'about:blank', + status: 500, + title: 'Internal Server Error', + detail, + ...options, + }; + }, + + /** + * 503 Service Unavailable — server is temporarily unable to handle the request. + */ + serviceUnavailable(detail: string, options?: Partial>): ProblemDetail { + return { + type: 'about:blank', + status: 503, + title: 'Service Unavailable', + detail, + ...options, + }; + }, +}; + +/** + * Helper to create an ErrorItem for validation errors. + */ +export function createErrorItem(detail: string, options?: Partial>): ErrorItem { + return { + detail, + ...options, + }; +} diff --git a/typescript/src/index.ts b/typescript/src/index.ts new file mode 100644 index 0000000..5e410cb --- /dev/null +++ b/typescript/src/index.ts @@ -0,0 +1,36 @@ +/** + * @jentic/problem-details + * + * RFC 9457 Problem Details types and utilities for Jentic APIs. + * + * @example + * ```typescript + * import { createProblemDetail, ProblemDetailError } from '@jentic/problem-details'; + * + * // Create a problem detail + * const problem = createProblemDetail.badRequest('Missing required field', { + * instance: '/api/users', + * errors: [ + * { detail: "Field 'name' is required", pointer: '#/name' } + * ] + * }); + * + * // Handle errors from fetch + * try { + * const response = await fetch('/api/users'); + * if (!response.ok) { + * throw await ProblemDetailError.fromResponse(response); + * } + * } catch (err) { + * if (err instanceof ProblemDetailError) { + * console.log(err.problemDetail); + * } + * } + * ``` + * + * @see https://www.rfc-editor.org/rfc/rfc9457.html + */ + +export type { ProblemDetail, ErrorItem } from './types.js'; +export { isProblemDetail, isErrorItem } from './types.js'; +export { ProblemDetailError, createProblemDetail, createErrorItem } from './errors.js'; diff --git a/typescript/src/types.ts b/typescript/src/types.ts new file mode 100644 index 0000000..621f0b5 --- /dev/null +++ b/typescript/src/types.ts @@ -0,0 +1,147 @@ +/** + * RFC 9457 Problem Details for HTTP APIs. + * + * This is the standard error response format for all Jentic APIs. + * Content-Type: application/problem+json + * + * @see https://www.rfc-editor.org/rfc/rfc9457.html + */ +export interface ProblemDetail { + /** + * A URI reference identifying the problem type. When set to "about:blank", + * the title SHOULD be the standard HTTP status phrase. Use an IANA-registered + * type URI where one applies. + * + * @default "about:blank" + * @maxLength 1024 + * @example "about:blank" + */ + type?: string; + + /** + * The HTTP status code for this occurrence of the problem. + * + * @minimum 100 + * @maximum 599 + * @example 400 + */ + status?: number; + + /** + * A short, human-readable summary of the problem type. Should not change + * between occurrences except for localisation purposes. + * + * @maxLength 1024 + * @example "Bad Request" + */ + title?: string; + + /** + * A human-readable explanation specific to this occurrence of the problem. + * MUST be present. Provide actionable information where possible. + * + * @maxLength 4096 + * @example "The request body is missing required field 'name'." + */ + detail: string; + + /** + * A URI reference identifying the specific occurrence of the problem. + * Typically the request path. + * + * @maxLength 1024 + * @example "/v2/capability-sets" + */ + instance?: string; + + /** + * An optional provider-specific code for internal error taxonomy and + * observability correlation. + * + * @maxLength 50 + * @example "JENTIC-4001" + */ + code?: string; + + /** + * An array of granular error details. Use when multiple validation errors + * or field-level problems need to be surfaced in a single response. + * + * @maxItems 1000 + */ + errors?: ErrorItem[]; +} + +/** + * A granular error detail entry within the errors[] array of a ProblemDetail response. + * + * At least one of pointer, parameter, or header SHOULD be present to identify the error source. + */ +export interface ErrorItem { + /** + * A human-readable explanation of this specific error. Be precise — + * name the field, parameter, or header involved. + * + * @maxLength 4096 + * @example "Field 'name' must not be blank." + */ + detail: string; + + /** + * A JSON Pointer (RFC 6901) to the specific request body property + * that is the source of this error. + * + * @maxLength 1024 + * @example "#/name" + */ + pointer?: string; + + /** + * The name of the query or path parameter that is the source of this error. + * + * @maxLength 1024 + * @example "limit" + */ + parameter?: string; + + /** + * The name of the request header that is the source of this error. + * + * @maxLength 1024 + * @example "Authorization" + */ + header?: string; + + /** + * An optional provider-specific code identifying this error in internal + * taxonomy or documentation. + * + * @maxLength 50 + * @example "JENTIC-V-001" + */ + code?: string; +} + +/** + * Type guard to check if an object is a ProblemDetail. + */ +export function isProblemDetail(obj: unknown): obj is ProblemDetail { + return ( + typeof obj === 'object' && + obj !== null && + 'detail' in obj && + typeof (obj as ProblemDetail).detail === 'string' + ); +} + +/** + * Type guard to check if an object is an ErrorItem. + */ +export function isErrorItem(obj: unknown): obj is ErrorItem { + return ( + typeof obj === 'object' && + obj !== null && + 'detail' in obj && + typeof (obj as ErrorItem).detail === 'string' + ); +} diff --git a/typescript/tests/errors.test.ts b/typescript/tests/errors.test.ts new file mode 100644 index 0000000..f5e7b8b --- /dev/null +++ b/typescript/tests/errors.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi } from 'vitest'; +import { ProblemDetailError, createProblemDetail, createErrorItem } from '../src/errors.js'; + +describe('ProblemDetailError', () => { + it('should create error with problem detail', () => { + const problem = { + status: 400, + title: 'Bad Request', + detail: 'Invalid input', + }; + + const error = new ProblemDetailError(problem); + + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('ProblemDetailError'); + expect(error.message).toBe('Invalid input'); + expect(error.problemDetail).toEqual(problem); + }); + + it('should serialize to JSON', () => { + const problem = { + status: 404, + title: 'Not Found', + detail: 'Resource not found', + instance: '/api/users/123', + }; + + const error = new ProblemDetailError(problem); + const json = JSON.stringify(error); + const parsed = JSON.parse(json); + + expect(parsed.status).toBe(404); + expect(parsed.title).toBe('Not Found'); + expect(parsed.detail).toBe('Resource not found'); + expect(parsed.instance).toBe('/api/users/123'); + }); + + it('should create from Response with problem+json content-type', async () => { + const problemData = { + type: 'about:blank', + status: 400, + title: 'Bad Request', + detail: 'Invalid request body', + }; + + const response = new Response(JSON.stringify(problemData), { + status: 400, + headers: { 'content-type': 'application/problem+json' }, + }); + + const error = await ProblemDetailError.fromResponse(response); + + expect(error).toBeInstanceOf(ProblemDetailError); + expect(error.problemDetail.status).toBe(400); + expect(error.problemDetail.detail).toBe('Invalid request body'); + }); + + it('should create from Response without problem+json content-type', async () => { + const response = new Response('Not Found', { + status: 404, + statusText: 'Not Found', + headers: { 'content-type': 'text/plain' }, + }); + + const error = await ProblemDetailError.fromResponse(response); + + expect(error).toBeInstanceOf(ProblemDetailError); + expect(error.problemDetail.status).toBe(404); + expect(error.problemDetail.title).toBe('Not Found'); + expect(error.problemDetail.detail).toBe('Not Found'); + }); + + it('should handle empty response body', async () => { + const response = new Response('', { + status: 500, + statusText: 'Internal Server Error', + }); + + const error = await ProblemDetailError.fromResponse(response); + + expect(error.problemDetail.status).toBe(500); + expect(error.problemDetail.detail).toBe('HTTP 500 Internal Server Error'); + }); +}); + +describe('createProblemDetail', () => { + it('should create badRequest', () => { + const problem = createProblemDetail.badRequest('Missing required field'); + + expect(problem.status).toBe(400); + expect(problem.title).toBe('Bad Request'); + expect(problem.detail).toBe('Missing required field'); + expect(problem.type).toBe('about:blank'); + }); + + it('should create badRequest with options', () => { + const problem = createProblemDetail.badRequest('Invalid input', { + instance: '/api/users', + code: 'JENTIC-4001', + errors: [{ detail: "Field 'name' is required", pointer: '#/name' }], + }); + + expect(problem.status).toBe(400); + expect(problem.instance).toBe('/api/users'); + expect(problem.code).toBe('JENTIC-4001'); + expect(problem.errors).toHaveLength(1); + }); + + it('should create unauthorized', () => { + const problem = createProblemDetail.unauthorized('Invalid credentials'); + + expect(problem.status).toBe(401); + expect(problem.title).toBe('Unauthorized'); + expect(problem.detail).toBe('Invalid credentials'); + }); + + it('should create forbidden', () => { + const problem = createProblemDetail.forbidden('Access denied'); + + expect(problem.status).toBe(403); + expect(problem.title).toBe('Forbidden'); + expect(problem.detail).toBe('Access denied'); + }); + + it('should create notFound', () => { + const problem = createProblemDetail.notFound('Resource not found'); + + expect(problem.status).toBe(404); + expect(problem.title).toBe('Not Found'); + expect(problem.detail).toBe('Resource not found'); + }); + + it('should create conflict', () => { + const problem = createProblemDetail.conflict('Resource already exists'); + + expect(problem.status).toBe(409); + expect(problem.title).toBe('Conflict'); + expect(problem.detail).toBe('Resource already exists'); + }); + + it('should create validationError', () => { + const problem = createProblemDetail.validationError('Invalid input format'); + + expect(problem.status).toBe(422); + expect(problem.title).toBe('Validation Error'); + expect(problem.detail).toBe('Invalid input format'); + }); + + it('should create tooManyRequests', () => { + const problem = createProblemDetail.tooManyRequests('Rate limit exceeded'); + + expect(problem.status).toBe(429); + expect(problem.title).toBe('Too Many Requests'); + expect(problem.detail).toBe('Rate limit exceeded'); + }); + + it('should create serverError', () => { + const problem = createProblemDetail.serverError('Unexpected error'); + + expect(problem.status).toBe(500); + expect(problem.title).toBe('Internal Server Error'); + expect(problem.detail).toBe('Unexpected error'); + }); + + it('should create serviceUnavailable', () => { + const problem = createProblemDetail.serviceUnavailable('Database connection lost'); + + expect(problem.status).toBe(503); + expect(problem.title).toBe('Service Unavailable'); + expect(problem.detail).toBe('Database connection lost'); + }); +}); + +describe('createErrorItem', () => { + it('should create basic error item', () => { + const error = createErrorItem("Field 'name' is required"); + + expect(error.detail).toBe("Field 'name' is required"); + expect(error.pointer).toBeUndefined(); + }); + + it('should create error item with pointer', () => { + const error = createErrorItem("Field 'email' must be valid", { + pointer: '#/email', + }); + + expect(error.detail).toBe("Field 'email' must be valid"); + expect(error.pointer).toBe('#/email'); + }); + + it('should create error item with parameter', () => { + const error = createErrorItem('Must be positive', { + parameter: 'limit', + code: 'VAL-001', + }); + + expect(error.detail).toBe('Must be positive'); + expect(error.parameter).toBe('limit'); + expect(error.code).toBe('VAL-001'); + }); + + it('should create error item with header', () => { + const error = createErrorItem('Authorization header malformed', { + header: 'Authorization', + }); + + expect(error.detail).toBe('Authorization header malformed'); + expect(error.header).toBe('Authorization'); + }); +}); diff --git a/typescript/tests/types.test.ts b/typescript/tests/types.test.ts new file mode 100644 index 0000000..4d06e7f --- /dev/null +++ b/typescript/tests/types.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect } from 'vitest'; +import { isProblemDetail, isErrorItem } from '../src/types.js'; +import type { ProblemDetail, ErrorItem } from '../src/types.js'; + +describe('ProblemDetail type', () => { + it('should allow creating a minimal problem detail', () => { + const problem: ProblemDetail = { + detail: 'Something went wrong', + }; + + expect(problem.detail).toBe('Something went wrong'); + expect(problem.type).toBeUndefined(); + expect(problem.status).toBeUndefined(); + }); + + it('should allow creating a full problem detail', () => { + const problem: ProblemDetail = { + type: 'about:blank', + status: 400, + title: 'Bad Request', + detail: "The request body is missing required field 'name'.", + instance: '/v2/capability-sets', + code: 'JENTIC-4001', + errors: [ + { + detail: "Field 'name' is required.", + pointer: '#/name', + }, + ], + }; + + expect(problem.type).toBe('about:blank'); + expect(problem.status).toBe(400); + expect(problem.title).toBe('Bad Request'); + expect(problem.detail).toBe("The request body is missing required field 'name'."); + expect(problem.instance).toBe('/v2/capability-sets'); + expect(problem.code).toBe('JENTIC-4001'); + expect(problem.errors).toHaveLength(1); + expect(problem.errors?.[0]?.detail).toBe("Field 'name' is required."); + }); + + it('should serialize to JSON correctly', () => { + const problem: ProblemDetail = { + type: 'about:blank', + status: 400, + title: 'Bad Request', + detail: 'Invalid input', + instance: '/test', + }; + + const json = JSON.stringify(problem); + const parsed = JSON.parse(json); + + expect(parsed.type).toBe('about:blank'); + expect(parsed.status).toBe(400); + expect(parsed.title).toBe('Bad Request'); + expect(parsed.detail).toBe('Invalid input'); + expect(parsed.instance).toBe('/test'); + }); +}); + +describe('ErrorItem type', () => { + it('should allow creating a minimal error item', () => { + const error: ErrorItem = { + detail: "Field 'name' is required", + }; + + expect(error.detail).toBe("Field 'name' is required"); + expect(error.pointer).toBeUndefined(); + expect(error.parameter).toBeUndefined(); + expect(error.header).toBeUndefined(); + expect(error.code).toBeUndefined(); + }); + + it('should allow creating error item with pointer', () => { + const error: ErrorItem = { + detail: "Field 'email' must be a valid email address", + pointer: '#/email', + }; + + expect(error.detail).toBe("Field 'email' must be a valid email address"); + expect(error.pointer).toBe('#/email'); + }); + + it('should allow creating error item with parameter', () => { + const error: ErrorItem = { + detail: 'Must be a positive integer between 1 and 100', + parameter: 'limit', + }; + + expect(error.detail).toBe('Must be a positive integer between 1 and 100'); + expect(error.parameter).toBe('limit'); + }); + + it('should allow creating error item with header', () => { + const error: ErrorItem = { + detail: 'Authorization header is malformed', + header: 'Authorization', + code: 'AUTH-001', + }; + + expect(error.detail).toBe('Authorization header is malformed'); + expect(error.header).toBe('Authorization'); + expect(error.code).toBe('AUTH-001'); + }); +}); + +describe('isProblemDetail', () => { + it('should return true for valid ProblemDetail', () => { + const problem: ProblemDetail = { + detail: 'Test error', + status: 400, + }; + + expect(isProblemDetail(problem)).toBe(true); + }); + + it('should return false for non-objects', () => { + expect(isProblemDetail(null)).toBe(false); + expect(isProblemDetail(undefined)).toBe(false); + expect(isProblemDetail('string')).toBe(false); + expect(isProblemDetail(123)).toBe(false); + }); + + it('should return false for objects without detail', () => { + expect(isProblemDetail({})).toBe(false); + expect(isProblemDetail({ status: 400 })).toBe(false); + }); + + it('should return false for objects with non-string detail', () => { + expect(isProblemDetail({ detail: 123 })).toBe(false); + expect(isProblemDetail({ detail: null })).toBe(false); + }); +}); + +describe('isErrorItem', () => { + it('should return true for valid ErrorItem', () => { + const error: ErrorItem = { + detail: 'Field error', + pointer: '#/field', + }; + + expect(isErrorItem(error)).toBe(true); + }); + + it('should return false for non-objects', () => { + expect(isErrorItem(null)).toBe(false); + expect(isErrorItem(undefined)).toBe(false); + expect(isErrorItem('string')).toBe(false); + }); + + it('should return false for objects without detail', () => { + expect(isErrorItem({})).toBe(false); + expect(isErrorItem({ pointer: '#/test' })).toBe(false); + }); +}); + +describe('ProblemDetail with multiple errors', () => { + it('should support multiple validation errors', () => { + const problem: ProblemDetail = { + status: 422, + title: 'Validation Error', + detail: 'Multiple validation errors occurred', + instance: '/api/resources', + errors: [ + { detail: "Field 'name' is required", pointer: '#/name' }, + { detail: "Field 'email' must be valid", pointer: '#/email' }, + { detail: "Parameter 'limit' must be positive", parameter: 'limit' }, + ], + }; + + expect(problem.errors).toHaveLength(3); + expect(problem.errors?.[0]?.pointer).toBe('#/name'); + expect(problem.errors?.[1]?.pointer).toBe('#/email'); + expect(problem.errors?.[2]?.parameter).toBe('limit'); + }); +}); diff --git a/typescript/tsconfig.json b/typescript/tsconfig.json new file mode 100644 index 0000000..d61169a --- /dev/null +++ b/typescript/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020"], + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/typescript/vitest.config.ts b/typescript/vitest.config.ts new file mode 100644 index 0000000..5ad6631 --- /dev/null +++ b/typescript/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts', 'tests/**/*'], + }, + }, +}); From f72deac620b41fbeae00a5a3d0de9a4535f4e304 Mon Sep 17 00:00:00 2001 From: Frank Kilcommins Date: Fri, 10 Apr 2026 18:18:31 +0100 Subject: [PATCH 02/14] additional tests for conformance. CI and release actions --- .github/workflows/ci.yaml | 70 +++++++ .github/workflows/release.yaml | 132 +++++++++++++ python/pyproject.toml | 2 + python/tests/test_schema_conformance.py | 142 ++++++++++++++ typescript/package-lock.json | 59 ++++++ typescript/package.json | 1 + typescript/tests/schema-conformance.test.ts | 202 ++++++++++++++++++++ 7 files changed, 608 insertions(+) create mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/release.yaml create mode 100644 python/tests/test_schema_conformance.py create mode 100644 typescript/tests/schema-conformance.test.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..fe17cad --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,70 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + python: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Install dependencies + working-directory: python + run: | + uv pip install --system -e ".[dev]" + + - name: Run tests + working-directory: python + run: | + pytest tests/ -v --cov=jentic.problem_details --cov-report=term-missing + + typescript: + name: TypeScript + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: typescript/package-lock.json + + - name: Install dependencies + working-directory: typescript + run: npm ci + + - name: Build + working-directory: typescript + run: npm run build + + - name: Run tests + working-directory: typescript + run: npm test + + - name: Type check + working-directory: typescript + run: npm run typecheck diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..0da43d1 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,132 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., 1.0.0)' + required: true + type: string + +concurrency: release + +jobs: + release: + name: Release packages + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + environment: + name: npm-release + url: https://www.npmjs.com/org/jentic + permissions: + contents: write + id-token: write # Required for PyPI OIDC and NPM provenance + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Update Python package version + working-directory: python + run: | + sed -i 's/^version = .*/version = "${{ inputs.version }}"/' pyproject.toml + + - name: Update TypeScript package version + working-directory: typescript + run: | + npm version ${{ inputs.version }} --no-git-tag-version + + - name: Commit version changes and create tag + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git add python/pyproject.toml typescript/package.json typescript/package-lock.json + git commit -m "chore: release v${{ inputs.version }}" + git tag -a "v${{ inputs.version }}" -m "Release v${{ inputs.version }}" + git push origin main --tags + + - name: Install Python dependencies and build + working-directory: python + run: | + uv pip install --system build + python -m build + + - name: Build TypeScript package + working-directory: typescript + run: | + npm ci + npm run build + + - name: Prepare release artifacts + run: | + mkdir -p release-assets + cp python/dist/* release-assets/ + + echo "📦 Release artifacts:" + ls -la release-assets/ + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "🚀 Creating GitHub release..." + VERSION="${{ inputs.version }}" + + # Try to extract changelog for this version + CHANGELOG_CONTENT="$(sed -n '/^## v'$VERSION'/,/^## /p' CHANGELOG.md 2>/dev/null | sed '$d')" + + if [ -z "$CHANGELOG_CONTENT" ]; then + CHANGELOG_CONTENT="Release v$VERSION" + fi + + gh release create "v$VERSION" \ + --title "v$VERSION" \ + --notes "$CHANGELOG_CONTENT" \ + release-assets/* + + - name: Publish Python package to PyPI + id: pypi_publish + continue-on-error: true # TODO: Remove after Trusted Publisher is configured on PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: python/dist/ + attestations: false + + - name: Publish TypeScript package to NPM + id: npm_publish + working-directory: typescript + run: npm publish --access public --provenance + + - name: Release Summary + run: | + echo "🎉 RELEASE COMPLETED" + echo "Released version: ${{ inputs.version }}" + echo "" + if [ "${{ steps.pypi_publish.outcome }}" == "success" ]; then + echo "Published to PyPI: ✓" + else + echo "Published to PyPI: ⚠️ SKIPPED (Trusted Publisher not configured yet)" + fi + echo "Published to NPM: ✓" + echo "GitHub release created: ✓" diff --git a/python/pyproject.toml b/python/pyproject.toml index b4b760b..8fe5122 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -32,7 +32,9 @@ fastapi = [ dev = [ "pytest>=8.0.0", "pytest-asyncio>=0.24.0", + "pytest-cov>=6.0.0", "fastapi>=0.100.0", + "pyyaml>=6.0.0", ] [project.urls] diff --git a/python/tests/test_schema_conformance.py b/python/tests/test_schema_conformance.py new file mode 100644 index 0000000..5146c45 --- /dev/null +++ b/python/tests/test_schema_conformance.py @@ -0,0 +1,142 @@ +""" +Tests to ensure Pydantic models conform to the OpenAPI schemas. +Prevents drift between implementation and specification. +""" + +import json +from pathlib import Path + +import pytest +import yaml +from pydantic import TypeAdapter + +from jentic.problem_details.models import ErrorItem, ProblemDetail + + +def load_schema(name: str) -> dict: + """Load a schema file from the schemas/ directory.""" + # From python/tests/test_schema_conformance.py -> api-problem-details/schemas/ + schema_path = Path(__file__).parent.parent.parent / "schemas" / f"{name}.yaml" + with open(schema_path) as f: + return yaml.safe_load(f) + + +def test_problem_detail_schema_conformance(): + """Verify ProblemDetail model matches problem-details.yaml schema.""" + openapi_schema = load_schema("problem-details") + pydantic_schema = TypeAdapter(ProblemDetail).json_schema() + + # Check required fields match + assert openapi_schema["required"] == ["detail"] + assert "detail" in pydantic_schema["required"] + + # Check properties exist in both + openapi_props = set(openapi_schema["properties"].keys()) + pydantic_props = set(pydantic_schema["properties"].keys()) + assert openapi_props == pydantic_props, f"Property mismatch: OpenAPI={openapi_props}, Pydantic={pydantic_props}" + + # Check specific field constraints + # type field (has default, so not in anyOf) + assert openapi_schema["properties"]["type"]["maxLength"] == 1024 + assert pydantic_schema["properties"]["type"]["maxLength"] == 1024 + assert openapi_schema["properties"]["type"]["default"] == "about:blank" + + # status field (optional, uses anyOf in Pydantic) + assert openapi_schema["properties"]["status"]["minimum"] == 100 + assert openapi_schema["properties"]["status"]["maximum"] == 599 + # Pydantic puts constraints in anyOf for optional fields + status_schema = pydantic_schema["properties"]["status"]["anyOf"][0] # First is non-null type + assert status_schema["minimum"] == 100 + assert status_schema["maximum"] == 599 + + # detail field (required) + assert openapi_schema["properties"]["detail"]["maxLength"] == 4096 + assert pydantic_schema["properties"]["detail"]["maxLength"] == 4096 + + # instance field (optional, uses anyOf in Pydantic) + assert openapi_schema["properties"]["instance"]["maxLength"] == 1024 + instance_schema = pydantic_schema["properties"]["instance"]["anyOf"][0] + assert instance_schema["maxLength"] == 1024 + + # code field (optional, uses anyOf in Pydantic) + assert openapi_schema["properties"]["code"]["maxLength"] == 50 + code_schema = pydantic_schema["properties"]["code"]["anyOf"][0] + assert code_schema["maxLength"] == 50 + + # errors field (optional, uses anyOf in Pydantic) + assert openapi_schema["properties"]["errors"]["maxItems"] == 1000 + errors_schema = pydantic_schema["properties"]["errors"]["anyOf"][0] + assert errors_schema["maxItems"] == 1000 + + +def test_error_item_schema_conformance(): + """Verify ErrorItem model matches error-item.yaml schema.""" + openapi_schema = load_schema("error-item") + pydantic_schema = TypeAdapter(ErrorItem).json_schema() + + # Check required fields match + assert openapi_schema["required"] == ["detail"] + assert "detail" in pydantic_schema["required"] + + # Check properties exist in both + openapi_props = set(openapi_schema["properties"].keys()) + pydantic_props = set(pydantic_schema["properties"].keys()) + assert openapi_props == pydantic_props, f"Property mismatch: OpenAPI={openapi_props}, Pydantic={pydantic_props}" + + # Check specific field constraints + # detail field (required) + assert openapi_schema["properties"]["detail"]["maxLength"] == 4096 + assert pydantic_schema["properties"]["detail"]["maxLength"] == 4096 + + # pointer field (optional, uses anyOf in Pydantic) + assert openapi_schema["properties"]["pointer"]["maxLength"] == 1024 + pointer_schema = pydantic_schema["properties"]["pointer"]["anyOf"][0] + assert pointer_schema["maxLength"] == 1024 + + # parameter field (optional, uses anyOf in Pydantic) + assert openapi_schema["properties"]["parameter"]["maxLength"] == 1024 + parameter_schema = pydantic_schema["properties"]["parameter"]["anyOf"][0] + assert parameter_schema["maxLength"] == 1024 + + # header field (optional, uses anyOf in Pydantic) + assert openapi_schema["properties"]["header"]["maxLength"] == 1024 + header_schema = pydantic_schema["properties"]["header"]["anyOf"][0] + assert header_schema["maxLength"] == 1024 + + # code field (optional, uses anyOf in Pydantic) + assert openapi_schema["properties"]["code"]["maxLength"] == 50 + code_schema = pydantic_schema["properties"]["code"]["anyOf"][0] + assert code_schema["maxLength"] == 50 + + +def test_example_objects_validate_against_schemas(): + """Verify example objects from schemas validate against Pydantic models.""" + problem_details_schema = load_schema("problem-details") + error_item_schema = load_schema("error-item") + + # Test ProblemDetail example + problem_example = { + "type": problem_details_schema["properties"]["type"]["example"], + "status": problem_details_schema["properties"]["status"]["example"], + "title": problem_details_schema["properties"]["title"]["example"], + "detail": problem_details_schema["properties"]["detail"]["example"], + "instance": problem_details_schema["properties"]["instance"]["example"], + "code": problem_details_schema["properties"]["code"]["example"], + } + problem = ProblemDetail(**problem_example) + assert problem.type == "about:blank" + assert problem.status == 400 + assert problem.title == "Bad Request" + + # Test ErrorItem example + error_example = { + "detail": error_item_schema["properties"]["detail"]["example"], + "pointer": error_item_schema["properties"]["pointer"]["example"], + "parameter": error_item_schema["properties"]["parameter"]["example"], + "header": error_item_schema["properties"]["header"]["example"], + "code": error_item_schema["properties"]["code"]["example"], + } + error = ErrorItem(**error_example) + assert error.detail == "Field 'name' must not be blank." + assert error.pointer == "#/name" + assert error.parameter == "limit" diff --git a/typescript/package-lock.json b/typescript/package-lock.json index 50be95f..6891192 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -11,6 +11,7 @@ "devDependencies": { "@types/node": "^22.0.0", "@vitest/coverage-v8": "^2.0.0", + "ajv": "^8.17.0", "typescript": "^5.6.0", "vitest": "^2.0.0" }, @@ -1071,6 +1072,23 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -1310,6 +1328,30 @@ "node": ">=12.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -1501,6 +1543,13 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -1692,6 +1741,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", diff --git a/typescript/package.json b/typescript/package.json index 5c2c1e8..4547cf1 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -45,6 +45,7 @@ "devDependencies": { "@types/node": "^22.0.0", "@vitest/coverage-v8": "^2.0.0", + "ajv": "^8.17.0", "typescript": "^5.6.0", "vitest": "^2.0.0" }, diff --git a/typescript/tests/schema-conformance.test.ts b/typescript/tests/schema-conformance.test.ts new file mode 100644 index 0000000..d983d19 --- /dev/null +++ b/typescript/tests/schema-conformance.test.ts @@ -0,0 +1,202 @@ +/** + * Tests to ensure TypeScript types conform to the OpenAPI schemas. + * Prevents drift between implementation and specification. + */ + +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import Ajv from 'ajv'; +import { describe, it, expect } from 'vitest'; +import type { ProblemDetail, ErrorItem } from '../src/types'; + +const ajv = new Ajv({ strict: false }); + +function loadSchema(name: string): any { + const schemaPath = resolve(__dirname, '../../schemas', `${name}.yaml`); + const yaml = readFileSync(schemaPath, 'utf-8'); + // Simple YAML parser for our schema files (we could use js-yaml but trying to avoid dependencies) + // Since we're just testing, we'll convert the schema files to JSON in the test setup + // For now, let's just parse the essential parts manually + return parseYamlSchema(yaml); +} + +// Minimal YAML parser for our schema structure +function parseYamlSchema(yaml: string): any { + const lines = yaml.split('\n'); + const schema: any = { properties: {}, required: [] }; + + let currentProp: string | null = null; + let inProperties = false; + + for (const line of lines) { + if (line.includes('required:')) { + inProperties = false; + } else if (line.includes('properties:')) { + inProperties = true; + } else if (inProperties && line.match(/^ (\w+):/)) { + const match = line.match(/^ (\w+):/); + if (match) { + currentProp = match[1]; + schema.properties[currentProp] = {}; + } + } else if (currentProp && line.includes('maxLength:')) { + const value = parseInt(line.split(':')[1].trim()); + schema.properties[currentProp].maxLength = value; + } else if (currentProp && line.includes('minimum:')) { + const value = parseInt(line.split(':')[1].trim()); + schema.properties[currentProp].minimum = value; + } else if (currentProp && line.includes('maximum:')) { + const value = parseInt(line.split(':')[1].trim()); + schema.properties[currentProp].maximum = value; + } else if (currentProp && line.includes('maxItems:')) { + const value = parseInt(line.split(':')[1].trim()); + schema.properties[currentProp].maxItems = value; + } else if (line.match(/^ - (\w+)$/)) { + const match = line.match(/^ - (\w+)$/); + if (match) { + schema.required.push(match[1]); + } + } + } + + return schema; +} + +describe('Schema Conformance', () => { + describe('ProblemDetail', () => { + it('should have all properties defined in problem-details.yaml', () => { + const schema = loadSchema('problem-details'); + const expectedProps = Object.keys(schema.properties); + + // TypeScript type checking ensures these exist at compile time + const sampleObject: ProblemDetail = { + type: 'about:blank', + status: 400, + title: 'Bad Request', + detail: 'The request body is missing required field "name".', + instance: '/v2/capability-sets', + code: 'JENTIC-4001', + errors: [], + }; + + const actualProps = Object.keys(sampleObject); + expect(actualProps.sort()).toEqual(expectedProps.sort()); + }); + + it('should respect maxLength constraint on type field', () => { + const schema = loadSchema('problem-details'); + expect(schema.properties.type.maxLength).toBe(1024); + + // TypeScript can't enforce this at compile time, but we document it + const validType = 'a'.repeat(1024); + expect(validType.length).toBe(1024); + }); + + it('should respect status code range', () => { + const schema = loadSchema('problem-details'); + expect(schema.properties.status.minimum).toBe(100); + expect(schema.properties.status.maximum).toBe(599); + }); + + it('should respect maxLength constraint on detail field', () => { + const schema = loadSchema('problem-details'); + expect(schema.properties.detail.maxLength).toBe(4096); + }); + + it('should respect maxItems constraint on errors array', () => { + const schema = loadSchema('problem-details'); + expect(schema.properties.errors.maxItems).toBe(1000); + }); + + it('should match schema examples', () => { + // Verify the example from the spec is a valid ProblemDetail + const example: ProblemDetail = { + type: 'about:blank', + status: 400, + title: 'Bad Request', + detail: "The request body is missing required field 'name'.", + instance: '/v2/capability-sets', + code: 'JENTIC-4001', + }; + + expect(example.detail).toBeDefined(); + expect(example.status).toBeGreaterThanOrEqual(100); + expect(example.status).toBeLessThanOrEqual(599); + }); + }); + + describe('ErrorItem', () => { + it('should have all properties defined in error-item.yaml', () => { + const schema = loadSchema('error-item'); + const expectedProps = Object.keys(schema.properties); + + // TypeScript type checking ensures these exist at compile time + const sampleObject: ErrorItem = { + detail: "Field 'name' must not be blank.", + pointer: '#/name', + parameter: 'limit', + header: 'Authorization', + code: 'JENTIC-V-001', + }; + + const actualProps = Object.keys(sampleObject); + expect(actualProps.sort()).toEqual(expectedProps.sort()); + }); + + it('should respect maxLength constraint on detail field', () => { + const schema = loadSchema('error-item'); + expect(schema.properties.detail.maxLength).toBe(4096); + }); + + it('should respect maxLength constraints on location fields', () => { + const schema = loadSchema('error-item'); + expect(schema.properties.pointer.maxLength).toBe(1024); + expect(schema.properties.parameter.maxLength).toBe(1024); + expect(schema.properties.header.maxLength).toBe(1024); + }); + + it('should respect maxLength constraint on code field', () => { + const schema = loadSchema('error-item'); + expect(schema.properties.code.maxLength).toBe(50); + }); + + it('should match schema examples', () => { + // Verify the example from the spec is a valid ErrorItem + const example: ErrorItem = { + detail: "Field 'name' must not be blank.", + pointer: '#/name', + parameter: 'limit', + header: 'Authorization', + code: 'JENTIC-V-001', + }; + + expect(example.detail).toBeDefined(); + expect(typeof example.detail).toBe('string'); + }); + }); + + describe('Type safety', () => { + it('should require detail field on ProblemDetail', () => { + // This test verifies TypeScript catches missing required fields at compile time + // @ts-expect-error - detail is required + const invalid: ProblemDetail = { + type: 'about:blank', + status: 400, + }; + + // Runtime check would fail + expect(invalid.detail).toBeUndefined(); + }); + + it('should require detail field on ErrorItem', () => { + // This test verifies TypeScript catches missing required fields at compile time + // @ts-expect-error - detail is required + const invalid: ErrorItem = { + pointer: '#/name', + }; + + // Runtime check would fail + expect(invalid.detail).toBeUndefined(); + }); + }); +}); From 4c5c879849f409d1973754b6a9dde62040673330 Mon Sep 17 00:00:00 2001 From: Vladimir Gorej Date: Mon, 13 Apr 2026 17:20:19 +0200 Subject: [PATCH 03/14] fix(python): make FastAPI a lazy optional dependency The responses module imports FastAPI at the top level, but FastAPI is declared as an optional dependency in pyproject.toml. Wrap the import in try/except ImportError so the package can be imported without FastAPI installed. Co-Authored-By: Claude Opus 4.6 (1M context) --- python/src/jentic/problem_details/__init__.py | 59 +++++++++++-------- 1 file changed, 33 insertions(+), 26 deletions(-) diff --git a/python/src/jentic/problem_details/__init__.py b/python/src/jentic/problem_details/__init__.py index d07da0d..72f77b1 100644 --- a/python/src/jentic/problem_details/__init__.py +++ b/python/src/jentic/problem_details/__init__.py @@ -24,35 +24,42 @@ __version__ = "1.0.0" from .models import ErrorItem, ProblemDetail -from .responses import ( - BadRequest, - Conflict, - Forbidden, - NotFound, - ProblemDetailException, - ServerError, - ServiceUnavailable, - TooManyRequests, - Unauthorized, - ValidationError, - problem_detail_exception_handler, -) __all__ = [ # Models "ProblemDetail", "ErrorItem", - # Exceptions - "ProblemDetailException", - "BadRequest", - "Unauthorized", - "Forbidden", - "NotFound", - "Conflict", - "ValidationError", - "TooManyRequests", - "ServerError", - "ServiceUnavailable", - # Utilities - "problem_detail_exception_handler", ] + +try: + from .responses import ( + BadRequest, + Conflict, + Forbidden, + NotFound, + ProblemDetailException, + ServerError, + ServiceUnavailable, + TooManyRequests, + Unauthorized, + ValidationError, + problem_detail_exception_handler, + ) +except ImportError: + pass +else: + __all__ += [ + # Exceptions + "ProblemDetailException", + "BadRequest", + "Unauthorized", + "Forbidden", + "NotFound", + "Conflict", + "ValidationError", + "TooManyRequests", + "ServerError", + "ServiceUnavailable", + # Utilities + "problem_detail_exception_handler", + ] From 94f0b4f0dfff38d9c7c7259852b304f0969de524 Mon Sep 17 00:00:00 2001 From: Vladimir Gorej Date: Mon, 13 Apr 2026 17:29:46 +0200 Subject: [PATCH 04/14] fix(license): align Python package license with repo (Apache-2.0) Python pyproject.toml and README declared MIT license while the repo root LICENSE, openapi-domain.yaml, and TypeScript package all use Apache 2.0. Align Python metadata and classifiers to Apache-2.0. Also add verbose license section to both Python and TypeScript READMEs. Co-Authored-By: Claude Opus 4.6 (1M context) --- python/README.md | 4 +++- python/pyproject.toml | 4 ++-- typescript/README.md | 4 +++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/python/README.md b/python/README.md index 9fd29f7..49f8914 100644 --- a/python/README.md +++ b/python/README.md @@ -178,4 +178,6 @@ mypy src/ ## License -MIT +jentic-problem-details is licensed under [Apache 2.0 license](https://github.com/jentic/api-problem-details/blob/main/LICENSE). +jentic-problem-details comes with an explicit [NOTICE](https://github.com/jentic/api-problem-details/blob/main/NOTICE) file +containing additional legal notices and information. diff --git a/python/pyproject.toml b/python/pyproject.toml index 8fe5122..3d1aac6 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ version = "1.0.0" description = "RFC 9457 Problem Details models for Jentic APIs" readme = "README.md" requires-python = ">=3.11" -license = {text = "MIT"} +license = {text = "Apache-2.0"} authors = [ {name = "Jentic", email = "hello@jentic.com"} ] @@ -12,7 +12,7 @@ keywords = ["rfc9457", "problem-details", "fastapi", "pydantic", "openapi"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", + "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", diff --git a/typescript/README.md b/typescript/README.md index 7555020..fae3a07 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -271,4 +271,6 @@ npm run test:coverage ## License -Apache-2.0 +@jentic/problem-details is licensed under [Apache 2.0 license](https://github.com/jentic/api-problem-details/blob/main/LICENSE). +@jentic/problem-details comes with an explicit [NOTICE](https://github.com/jentic/api-problem-details/blob/main/NOTICE) file +containing additional legal notices and information. From 1f6a365a5144bce1bbcc01a91b79159f0027bb42 Mon Sep 17 00:00:00 2001 From: Vladimir Gorej Date: Mon, 13 Apr 2026 17:31:01 +0200 Subject: [PATCH 05/14] fix(typescript): use ESM-compatible path resolution and remove unused ajv Replace __dirname (not available in ESM) with import.meta.url + fileURLToPath/dirname. Remove unused Ajv import and instantiation. Co-Authored-By: Claude Opus 4.6 (1M context) --- typescript/tests/schema-conformance.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/typescript/tests/schema-conformance.test.ts b/typescript/tests/schema-conformance.test.ts index d983d19..63fe8be 100644 --- a/typescript/tests/schema-conformance.test.ts +++ b/typescript/tests/schema-conformance.test.ts @@ -4,12 +4,13 @@ */ import { readFileSync } from 'fs'; -import { resolve } from 'path'; -import Ajv from 'ajv'; +import { dirname, resolve } from 'path'; +import { fileURLToPath } from 'url'; import { describe, it, expect } from 'vitest'; import type { ProblemDetail, ErrorItem } from '../src/types'; -const ajv = new Ajv({ strict: false }); +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); function loadSchema(name: string): any { const schemaPath = resolve(__dirname, '../../schemas', `${name}.yaml`); From 4c7ec436643cbe9adf86fe4a6f0b92e0839e1453 Mon Sep 17 00:00:00 2001 From: Vladimir Gorej Date: Mon, 13 Apr 2026 17:32:32 +0200 Subject: [PATCH 06/14] fix(typescript): handle malformed JSON in ProblemDetailError.fromResponse Wrap response.json() in try/catch so that empty or malformed bodies with application/problem+json content-type fall back to the text/status-based ProblemDetail instead of throwing. Co-Authored-By: Claude Opus 4.6 (1M context) --- typescript/src/errors.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/typescript/src/errors.ts b/typescript/src/errors.ts index 39dba70..bcfa725 100644 --- a/typescript/src/errors.ts +++ b/typescript/src/errors.ts @@ -49,12 +49,21 @@ export class ProblemDetailError extends Error { const contentType = response.headers.get('content-type') || ''; if (contentType.includes('application/problem+json')) { - const problemDetail = (await response.json()) as ProblemDetail; - return new ProblemDetailError(problemDetail); + try { + const problemDetail = (await response.json()) as ProblemDetail; + return new ProblemDetailError(problemDetail); + } catch { + // Fall through to text-based fallback if JSON parsing fails + } } - // Fallback for non-problem+json error responses - const text = await response.text(); + // Fallback for non-problem+json error responses or malformed JSON + let text: string; + try { + text = await response.text(); + } catch { + text = ''; + } return new ProblemDetailError({ status: response.status, title: response.statusText, From b86f04d05ef97ce29beda77ea62466fc9610594c Mon Sep 17 00:00:00 2001 From: Vladimir Gorej Date: Mon, 13 Apr 2026 17:38:38 +0200 Subject: [PATCH 07/14] fix: use standard HTTP phrase "Unprocessable Content" for 422 title Per RFC 9457, when type is "about:blank" the title SHOULD be the standard HTTP status phrase. RFC 9110 defines 422 as "Unprocessable Content". Align both Python and TypeScript packages with the OpenAPI spec in this repo which already uses the correct phrase. --- python/src/jentic/problem_details/responses.py | 2 +- python/tests/test_models.py | 2 +- python/tests/test_responses.py | 2 +- typescript/src/errors.ts | 2 +- typescript/tests/errors.test.ts | 2 +- typescript/tests/types.test.ts | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/python/src/jentic/problem_details/responses.py b/python/src/jentic/problem_details/responses.py index 492a755..4f6b5f5 100644 --- a/python/src/jentic/problem_details/responses.py +++ b/python/src/jentic/problem_details/responses.py @@ -96,7 +96,7 @@ class ValidationError(ProblemDetailException): """422 Unprocessable Content — request is well-formed but contains semantic errors.""" def __init__(self, detail: str, **kwargs): - super().__init__(422, detail, title=kwargs.pop("title", "Validation Error"), **kwargs) + super().__init__(422, detail, title=kwargs.pop("title", "Unprocessable Content"), **kwargs) class TooManyRequests(ProblemDetailException): diff --git a/python/tests/test_models.py b/python/tests/test_models.py index c2bee7d..952df6e 100644 --- a/python/tests/test_models.py +++ b/python/tests/test_models.py @@ -115,7 +115,7 @@ def test_problem_detail_multiple_errors(): """Test ProblemDetail with multiple validation errors.""" problem = ProblemDetail( status=422, - title="Validation Error", + title="Unprocessable Content", detail="Multiple validation errors occurred", instance="/api/resources", errors=[ diff --git a/python/tests/test_responses.py b/python/tests/test_responses.py index a7673bc..01e37ac 100644 --- a/python/tests/test_responses.py +++ b/python/tests/test_responses.py @@ -104,7 +104,7 @@ def test_validation_error(): exc = ValidationError(detail="Invalid input format") assert exc.status_code == 422 - assert exc.detail["title"] == "Validation Error" + assert exc.detail["title"] == "Unprocessable Content" def test_too_many_requests(): diff --git a/typescript/src/errors.ts b/typescript/src/errors.ts index bcfa725..080955b 100644 --- a/typescript/src/errors.ts +++ b/typescript/src/errors.ts @@ -148,7 +148,7 @@ export const createProblemDetail = { return { type: 'about:blank', status: 422, - title: 'Validation Error', + title: 'Unprocessable Content', detail, ...options, }; diff --git a/typescript/tests/errors.test.ts b/typescript/tests/errors.test.ts index f5e7b8b..6fbe896 100644 --- a/typescript/tests/errors.test.ts +++ b/typescript/tests/errors.test.ts @@ -142,7 +142,7 @@ describe('createProblemDetail', () => { const problem = createProblemDetail.validationError('Invalid input format'); expect(problem.status).toBe(422); - expect(problem.title).toBe('Validation Error'); + expect(problem.title).toBe('Unprocessable Content'); expect(problem.detail).toBe('Invalid input format'); }); diff --git a/typescript/tests/types.test.ts b/typescript/tests/types.test.ts index 4d06e7f..b1153a3 100644 --- a/typescript/tests/types.test.ts +++ b/typescript/tests/types.test.ts @@ -159,7 +159,7 @@ describe('ProblemDetail with multiple errors', () => { it('should support multiple validation errors', () => { const problem: ProblemDetail = { status: 422, - title: 'Validation Error', + title: 'Unprocessable Content', detail: 'Multiple validation errors occurred', instance: '/api/resources', errors: [ From 8df27e459c68b5e7f335a0e825e271e5bb8114e0 Mon Sep 17 00:00:00 2001 From: Vladimir Gorej Date: Mon, 13 Apr 2026 17:40:01 +0200 Subject: [PATCH 08/14] fix(typescript): differentiate isProblemDetail and isErrorItem type guards Both type guards previously checked the same condition (has string detail field), making them indistinguishable at runtime. isProblemDetail now requires status or title. isErrorItem checks for pointer/parameter/header or absence of ProblemDetail-specific fields. --- typescript/src/types.ts | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/typescript/src/types.ts b/typescript/src/types.ts index 621f0b5..01fc446 100644 --- a/typescript/src/types.ts +++ b/typescript/src/types.ts @@ -124,24 +124,34 @@ export interface ErrorItem { /** * Type guard to check if an object is a ProblemDetail. + * + * Distinguishes from ErrorItem by checking for the presence of + * `status` or `title`, which are specific to ProblemDetail. */ export function isProblemDetail(obj: unknown): obj is ProblemDetail { + if (typeof obj !== 'object' || obj === null) return false; + const record = obj as Record; return ( - typeof obj === 'object' && - obj !== null && - 'detail' in obj && - typeof (obj as ProblemDetail).detail === 'string' + typeof record.detail === 'string' && + ('status' in record || 'title' in record) ); } /** * Type guard to check if an object is an ErrorItem. + * + * Distinguishes from ProblemDetail by checking for the presence of + * `pointer`, `parameter`, or `header`, which are specific to ErrorItem. + * Falls back to a minimal check (has `detail`, lacks ProblemDetail-specific fields). */ export function isErrorItem(obj: unknown): obj is ErrorItem { + if (typeof obj !== 'object' || obj === null) return false; + const record = obj as Record; + if (typeof record.detail !== 'string') return false; return ( - typeof obj === 'object' && - obj !== null && - 'detail' in obj && - typeof (obj as ErrorItem).detail === 'string' + 'pointer' in record || + 'parameter' in record || + 'header' in record || + !('status' in record || 'title' in record) ); } From 12373c74b951fabd8b99420ac8226c52ebc5bbf7 Mon Sep 17 00:00:00 2001 From: Vladimir Gorej Date: Mon, 13 Apr 2026 17:40:51 +0200 Subject: [PATCH 09/14] fix(python): remove unused HttpUrl import from models --- python/src/jentic/problem_details/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/src/jentic/problem_details/models.py b/python/src/jentic/problem_details/models.py index ec4f55e..fab3369 100644 --- a/python/src/jentic/problem_details/models.py +++ b/python/src/jentic/problem_details/models.py @@ -4,7 +4,7 @@ """ from typing import Annotated -from pydantic import BaseModel, Field, HttpUrl +from pydantic import BaseModel, Field class ErrorItem(BaseModel): From 768f5f5fd1f59a68a4d801ac887815107dfeef58 Mon Sep 17 00:00:00 2001 From: Vladimir Gorej Date: Mon, 13 Apr 2026 17:42:34 +0200 Subject: [PATCH 10/14] fix(typescript): replace hand-rolled YAML parser with yaml package The regex-based YAML parser was fragile and could silently produce wrong results if the schema format changed. Replace with the yaml package for reliable parsing. Also remove unused ajv devDependency. --- typescript/package-lock.json | 81 ++++++--------------- typescript/package.json | 4 +- typescript/tests/schema-conformance.test.ts | 50 +------------ 3 files changed, 26 insertions(+), 109 deletions(-) diff --git a/typescript/package-lock.json b/typescript/package-lock.json index 6891192..6a657c3 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -11,9 +11,9 @@ "devDependencies": { "@types/node": "^22.0.0", "@vitest/coverage-v8": "^2.0.0", - "ajv": "^8.17.0", "typescript": "^5.6.0", - "vitest": "^2.0.0" + "vitest": "^2.0.0", + "yaml": "^2.8.3" }, "engines": { "node": ">=18.0.0" @@ -922,6 +922,7 @@ "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1072,23 +1073,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -1328,30 +1312,6 @@ "node": ">=12.0.0" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -1543,13 +1503,6 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -1741,16 +1694,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/rollup": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", @@ -2079,6 +2022,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -2162,6 +2106,7 @@ "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", @@ -2352,6 +2297,22 @@ "engines": { "node": ">=8" } + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } } } } diff --git a/typescript/package.json b/typescript/package.json index 4547cf1..5049481 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -45,9 +45,9 @@ "devDependencies": { "@types/node": "^22.0.0", "@vitest/coverage-v8": "^2.0.0", - "ajv": "^8.17.0", "typescript": "^5.6.0", - "vitest": "^2.0.0" + "vitest": "^2.0.0", + "yaml": "^2.8.3" }, "engines": { "node": ">=18.0.0" diff --git a/typescript/tests/schema-conformance.test.ts b/typescript/tests/schema-conformance.test.ts index 63fe8be..be89fb1 100644 --- a/typescript/tests/schema-conformance.test.ts +++ b/typescript/tests/schema-conformance.test.ts @@ -6,6 +6,7 @@ import { readFileSync } from 'fs'; import { dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; +import { parse } from 'yaml'; import { describe, it, expect } from 'vitest'; import type { ProblemDetail, ErrorItem } from '../src/types'; @@ -14,53 +15,8 @@ const __dirname = dirname(__filename); function loadSchema(name: string): any { const schemaPath = resolve(__dirname, '../../schemas', `${name}.yaml`); - const yaml = readFileSync(schemaPath, 'utf-8'); - // Simple YAML parser for our schema files (we could use js-yaml but trying to avoid dependencies) - // Since we're just testing, we'll convert the schema files to JSON in the test setup - // For now, let's just parse the essential parts manually - return parseYamlSchema(yaml); -} - -// Minimal YAML parser for our schema structure -function parseYamlSchema(yaml: string): any { - const lines = yaml.split('\n'); - const schema: any = { properties: {}, required: [] }; - - let currentProp: string | null = null; - let inProperties = false; - - for (const line of lines) { - if (line.includes('required:')) { - inProperties = false; - } else if (line.includes('properties:')) { - inProperties = true; - } else if (inProperties && line.match(/^ (\w+):/)) { - const match = line.match(/^ (\w+):/); - if (match) { - currentProp = match[1]; - schema.properties[currentProp] = {}; - } - } else if (currentProp && line.includes('maxLength:')) { - const value = parseInt(line.split(':')[1].trim()); - schema.properties[currentProp].maxLength = value; - } else if (currentProp && line.includes('minimum:')) { - const value = parseInt(line.split(':')[1].trim()); - schema.properties[currentProp].minimum = value; - } else if (currentProp && line.includes('maximum:')) { - const value = parseInt(line.split(':')[1].trim()); - schema.properties[currentProp].maximum = value; - } else if (currentProp && line.includes('maxItems:')) { - const value = parseInt(line.split(':')[1].trim()); - schema.properties[currentProp].maxItems = value; - } else if (line.match(/^ - (\w+)$/)) { - const match = line.match(/^ - (\w+)$/); - if (match) { - schema.required.push(match[1]); - } - } - } - - return schema; + const content = readFileSync(schemaPath, 'utf-8'); + return parse(content); } describe('Schema Conformance', () => { From 8c100c5fb72c85f667686147d98606fb39ad71c1 Mon Sep 17 00:00:00 2001 From: Vladimir Gorej Date: Mon, 13 Apr 2026 17:44:18 +0200 Subject: [PATCH 11/14] fix(python): source __version__ from package metadata Use importlib.metadata instead of hardcoding version so it stays in sync with pyproject.toml across releases. --- python/src/jentic/problem_details/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/python/src/jentic/problem_details/__init__.py b/python/src/jentic/problem_details/__init__.py index 72f77b1..84854db 100644 --- a/python/src/jentic/problem_details/__init__.py +++ b/python/src/jentic/problem_details/__init__.py @@ -21,7 +21,9 @@ app.add_exception_handler(ProblemDetailException, problem_detail_exception_handler) """ -__version__ = "1.0.0" +from importlib.metadata import version as _pkg_version + +__version__ = _pkg_version("jentic-problem-details") from .models import ErrorItem, ProblemDetail From 08cf64dbb07e8d681b46faaacf62e2c0a9d60144 Mon Sep 17 00:00:00 2001 From: Vladimir Gorej Date: Mon, 13 Apr 2026 17:45:22 +0200 Subject: [PATCH 12/14] fix(docs): add missing Request import in README FastAPI example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3a1562c..c715859 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ pip install jentic-problem-details Use in your FastAPI application: ```python -from fastapi import FastAPI +from fastapi import FastAPI, Request from jentic.problem_details import ( BadRequest, NotFound, From 03cf52106b891e277d5bc0fe6c9257167176a337 Mon Sep 17 00:00:00 2001 From: Vladimir Gorej Date: Mon, 13 Apr 2026 17:58:41 +0200 Subject: [PATCH 13/14] chore: remove duplicate python/.gitignore All patterns are already covered by the root .gitignore. --- python/.gitignore | 43 ------------------------------------------- 1 file changed, 43 deletions(-) delete mode 100644 python/.gitignore diff --git a/python/.gitignore b/python/.gitignore deleted file mode 100644 index ab32ec7..0000000 --- a/python/.gitignore +++ /dev/null @@ -1,43 +0,0 @@ -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg - -# Virtual environments -venv/ -ENV/ -env/ - -# Testing -.pytest_cache/ -.coverage -htmlcov/ -.tox/ - -# IDEs -.vscode/ -.idea/ -*.swp -*.swo - -# Type checking -.mypy_cache/ -.dmypy.json -dmypy.json From 50dc5ad42a2426e5d92bf7e41f82d979100d72fb Mon Sep 17 00:00:00 2001 From: Vladimir Gorej Date: Mon, 13 Apr 2026 17:58:58 +0200 Subject: [PATCH 14/14] feat(python): add py.typed marker for PEP 561 Allows type checkers (mypy, pyright) to recognize this package as providing inline type information. --- python/src/jentic/problem_details/py.typed | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 python/src/jentic/problem_details/py.typed diff --git a/python/src/jentic/problem_details/py.typed b/python/src/jentic/problem_details/py.typed new file mode 100644 index 0000000..e69de29