diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..e5b6d8d --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,8 @@ +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works +with multi-package repos, or single-package repos to help you version and publish your code. You can +find the full documentation for it [in our repository](https://github.com/changesets/changesets) + +We have a quick list of common questions to get you started engaging with this project in +[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..d88011f --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "restricted", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [] +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 8f74f44..0af3639 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,16 +1,20 @@ --- name: Bug report -about: Make a better open-source software with bug report +about: Make a better software with bug report title: '[bug]' labels: bug -assignees: danpacho --- -**Describe the bug** -A clear and concise description of what the bug is. + -**Expected behavior** -A clear and concise description of what you expected to happen. +## 1. Describe the bug -**Additional context** -Add any other context about the problem here. +> A clear and concise description of what the bug is. + +## 2. Expected behavior + +> A clear and concise description of what you expected to happen. + +## 3. Additional context(screenshots, logs, etc.) + +> Add any other context about the problem here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index abbc567..628a541 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -3,7 +3,6 @@ name: Feature request about: Suggest an idea for this project title: '[feature-request] Write your features' labels: feature request -assignees: danpacho --- -## ๐Ÿ“ Description +## 1. Feature description -> Add a brief description +> What does this feature do? (Fix/Feature/Bugfix/Refactor) -## ๐Ÿ’ช Current behavior +## 2. Changes -> Please describe the current behavior that you are modifying +> What it changes were made? -## ๐Ÿฆพ New behavior - -> Please describe the behavior or changes this PR adds - -## ๐Ÿ’ฃ Breaking change (y/n): +## 3. Breaking change (y/n): -## ๐Ÿ“ Additional Information +## 4. Additional Information(context, links, etc.) \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ee05470..b408985 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,19 +1,27 @@ -## Description +## 1. Description -Please provide a meaningful description of what this change will do, or is for. Bonus points for including links to related issues, other PRs, or technical references. +> What does this PR do? (Fix/Feature/Bugfix/Refactor) -Note that by _not_ including a description, you are asking reviewers to do extra work to understand the context of this change, which may lead to your PR taking much longer to review, or result in it not being reviewed at all. +## 2. Changes -## Type of Change +> What it changes were made? + +## 3. Breaking change (y/n): + + + +## 4. Additional Information(context, links, etc.) + +## 5. Type of Change - [ ] Bug Fix -- [ ] Enhancement -- [ ] Breaking API Changes - [ ] Refactor +- [ ] Enhancement - [ ] Documentation +- [ ] Breaking API Changes - [ ] Other (please describe) -## Checklist +## 6. Checklist - [ ] I have verified this change is not present in other open pull requests - [ ] Existing issues have been referenced (where applicable) diff --git a/.github/release-drafter-config.yml b/.github/release-drafter-config.yml index 23d8fe5..7d15852 100644 --- a/.github/release-drafter-config.yml +++ b/.github/release-drafter-config.yml @@ -1,41 +1,49 @@ -name-template: 'v$RESOLVED_VERSION ๐Ÿ”ฎ' +name-template: 'v$RESOLVED_VERSION' tag-template: 'v$RESOLVED_VERSION' categories: - - title: 'New Features ๐Ÿš€' - labels: - - 'feature' - - 'enhancement' - - title: 'Bug Fixes ๐Ÿ›' - labels: - - 'fix' - - 'bugfix' - - 'bug' - - title: 'Breaking ๐Ÿคฏ' - labels: - - 'breaking' - - title: 'Maintenance ๐Ÿฆ' - labels: - - 'maintenance' + - title: 'New Features' + labels: + - 'feature' + + - title: 'Bug Fixes' + labels: + - 'bugfix' + - 'bug' + + - title: 'Breaking Changes' + labels: + - 'breaking' + + - title: 'Maintenance' + labels: + - 'refactor' category-template: '### $TITLE' change-template: '- $TITLE @$AUTHOR (#$NUMBER)' change-title-escapes: '\<*_&' no-changes-template: 'No changes' exclude-labels: - - 'skip-changelog' + - 'skip-changelog' version-resolver: - major: - labels: - - 'breaking' - minor: - labels: - - 'feature' - - 'enhancement' - patch: - labels: - - 'maintenance' - - 'patch' - default: 'patch' + major: + labels: + - 'breaking' + minor: + labels: + - 'feature' + patch: + labels: + - 'refactor' + - 'bugfix' + - 'bug' + default: 'patch' + template: | - $CHANGES + ## Whatโ€™s Changed + + $CHANGES + + ## Contributors + + $CONTRIBUTORS - **Full Changelog**: https://github.com/metal-ts/fetch/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION + **Full Changelog**: https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index be0a80c..2df5c92 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,8 @@ -name: Draft Release +name: Release Drafter on: push: - branches: [master] + branches: [main] pull_request: types: [opened, reopened, synchronize] diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ad51333..ceaf42e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,35 +1,35 @@ -name: Test code +name: CI on: - push: - branches: ['**'] - pull_request: - branches: ['**'] + push: + branches: ["**"] jobs: - total-test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - name: Checkout - - - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version: 20 - - - uses: pnpm/action-setup@v2 - name: Install pnpm - id: pnpm-install - with: - version: 9 - run_install: true - - - name: Format checking - run: pnpm prettier && pnpm eslint - - - name: Build testing for core - run: pnpm build - - - name: Unit testing - run: pnpm test + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + name: Checkout + + - uses: actions/setup-node@v3 + name: Setup Node.js + with: + node-version: 22 + + - uses: pnpm/action-setup@v4 + name: Setup PNPM + with: + # Skip the default install so we can do our own + run_install: false + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Format checking + run: pnpm check + + - name: Build + run: pnpm build + + - name: Test + run: pnpm test diff --git a/.gitignore b/.gitignore index 646bc53..9003ea4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,16 @@ # Local *.local +@resources # Build dist .turbo benchmark +.obsidian +.vite +output.css +.source +.next # Dependencies node_modules/ @@ -18,6 +24,8 @@ node_modules/ coverage ts-perf tsconfig.tsbuildinfo +__snapshots__ +large.* # Logs npm-debug.log* @@ -25,6 +33,15 @@ yarn-debug.log* yarn-error.log* pnpm-debug.log* +# Environment variables +.env +.env.production + +# Build output +dist/ +.astro/ +.store/ +.legacy # Editor !.vscode/extensions.json diff --git a/.hintrc b/.hintrc new file mode 100644 index 0000000..5b02c11 --- /dev/null +++ b/.hintrc @@ -0,0 +1,13 @@ +{ + "extends": [ + "development" + ], + "hints": { + "axe/forms": [ + "default", + { + "label": "off" + } + ] + } +} \ No newline at end of file diff --git a/.lintstagedrc b/.lintstagedrc index 7ede30d..001c5e9 100644 --- a/.lintstagedrc +++ b/.lintstagedrc @@ -1,4 +1,3 @@ { - "**/*.ts": ["prettier --write", "eslint --fix"], - "**/*.{md,json}": ["prettier --write"] + "**/*.ts": ["biome format --write"] } diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 0e3e6f2..0000000 --- a/.prettierignore +++ /dev/null @@ -1,2 +0,0 @@ -/node_modules/* -pnpm-lock.yaml diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 8b1a2a2..0000000 --- a/.prettierrc +++ /dev/null @@ -1,7 +0,0 @@ -{ - "tabWidth": 4, - "trailingComma": "es5", - "useTabs": false, - "semi": false, - "singleQuote": true -} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 16522e9..f3fec06 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,38 +1,110 @@ - +```bash +git clone https://github.com/freestylejs/fetch.git +``` + +### Navigate to project directory + +```bash +cd fetch +``` + +### Create a new Branch + +```bash +git checkout -b {new-branch} +``` + +### Install dependencies + +```bash +pnpm install +``` + +### Start development + +```bash +pnpm dev +``` + +#### Run a workspace + +You can use the `pnpm --filter=[WORKSPACE]` command to start the development process for a workspace. + +> Examples + +1. To run the documentation website: + +```bash +pnpm --filter=@freestylejs/fetch-web dev +``` + +2. To work on the fetch package in watch mode: + +```bash +pnpm --filter=@freestylejs/fetch dev +``` + +## Documentation + +The documentation for this project is located in the `web` workspace. You can run the documentation locally by running the following command: + +```bash +pnpm --filter=@freestylejs/fetch-web dev +``` + +Documentation is written using [MDX](https://mdxjs.com). You can find the documentation files in the `packages/web/content/docs` directory. + +## Commit Convention + +Check [commit convention](./assets/commit.md). + +## Testing + +Tests are written using [Vitest](https://vitest.dev). You can run all the tests from the root of the repository. + +```bash +pnpm test +``` + +Please ensure that the tests are passing when submitting a pull request. If you're adding new features, please include tests. diff --git a/README.md b/README.md index 44ded48..4259e5d 100644 --- a/README.md +++ b/README.md @@ -1,372 +1,15 @@ -
- metal-fetch banner -
+# freestyle/fetch -

metal-fetch

+Ani banner -
- A type-safe, fluent, and chainable wrapper around the native Fetch API, designed for maximum flexibility and developer experience. -
+## Documentation -
- -
- NPM Version - License -
- ---- - -**metal-fetch** provides a robust, immutable builder that empowers you to define API endpoints declaratively. It enforces type safety for request bodies, search parameters, and responses, catching errors at compile time, not runtime. With a powerful middleware system and first-class error handling, it's built for creating resilient and maintainable API layers. - -## Features - -- **โ›“๏ธ Fluent, Chainable API:** Define every aspect of a request with a clean, readable, and chainable interface. -- **๐Ÿ”’ Type-Safe by Default:** Enforces validation for request bodies, path parameters, and responses. If you don't define a validator, you can't send the data. -- **โœจ Immutable Builder:** Every method on the `FetchBuilder` returns a new, cloned instance, preventing side effects and making your API definitions safe to reuse and extend. -- **๐Ÿ”Œ Full-Cycle Middleware:** Intercept and modify requests and responses with a standard `(request, next) => Promise` middleware pattern. Perfect for logging, authentication, caching, and more. -- **๐Ÿ’ช Flexible Response Handling:** Get full control over the raw `Response` object. Inspect headers or check status codes before deciding how to parse the body, preventing crashes from unexpected API responses. -- **๐Ÿšจ Granular Error Handling:** Define separate handlers for typed fetch errors (like a 404 or 500) and unknown network errors. - -## Installation - -```bash -# Using pnpm -pnpm add @metal-box/fetch - -# Using npm -npm install @metal-box/fetch - -# Using yarn -yarn add @metal-box/fetch -``` - -> **Note:** `metal-fetch` is a runtime-agnostic library. For validation, you can bring your own library, such as `zod`, `yup`, `@metal-box/type`, or any other. - ---- - -## Quick Start - -Here's a quick look at defining and using an API endpoint to fetch a user by their ID. - -```typescript -import { f } from '@metal-box/fetch' -import { z } from 'zod' - -const BASE_URL = 'https://api.example.com' - -// Define a validator for the user response -const UserResponse = z.object({ - id: z.string(), - name: z.string(), - email: z.string().email(), -}) - -// 1. Create a reusable API client instance -const apiClient = f - .builder() - .def_url(BASE_URL) - .def_json() // Automatically handle JSON parsing - .def_query_mode('throw') // Throw an error on non-2xx responses - -// 2. Define a specific endpoint -const getUser = apiClient - .def_method('GET') - .def_url(`${BASE_URL}/users/$id`) // Dynamic path parameter - .def_response(async ({ json }) => UserResponse.parse(await json())) - .build() - -// 3. Execute the query -async function fetchUser(userId: string) { - try { - const user = await getUser.query({ - path: { id: userId }, - }) - - console.log('Fetched User:', user) - // Type of `user` is inferred as: - // { id: string; name: string; email: string; } - - return user - } catch (error) { - console.error('Failed to fetch user:', error) - } -} - -fetchUser('123') -``` - -## Core Concepts - -### The Immutable Builder - -The core of `metal-fetch` is the `FetchBuilder`. It's immutable, meaning every method call creates a new, refined builder instance. This allows you to safely create a base client and extend it for specific endpoints without any side effects. - -```typescript -// Base client with shared configuration -const baseClient = f - .builder() - .def_url('https://api.my-service.com') - .def_query_mode('throw') - -// Endpoint for fetching products -const getProducts = baseClient - .def_method('GET') - .def_url('https://api.my-service.com/products') - .build() - -// Endpoint for creating a product (extends the same baseClient) -const createProduct = baseClient - .def_method('POST') - .def_url('https://api.my-service.com/products') - // ... add body definition - .build() -``` - -### Type-Safe Validation - -`metal-fetch` helps you eliminate runtime errors by enforcing compile-time checks. You cannot pass a `body` or `search` object to `.query()` unless you have first defined a validator for it. - -#### Body Validation - -Use `.def_body()` with your favorite validation library. - -```typescript -const ProductRequest = z.object({ - name: z.string(), - price: z.number().positive(), -}) - -const createProduct = apiClient - .def_method('POST') - .def_url(`${BASE_URL}/products`) - .def_body(ProductRequest.parse) // Pass the validation function - .build() - -// This is now type-safe! -await createProduct.query({ - body: { - name: 'Laptop', - price: 1200, - }, -}) - -// This would cause a TypeScript error, as `body` is not defined -// await getProducts.query({ body: { name: 'Laptop' } }); -``` - -#### Response Validation - -Similarly, use `.def_response()` to parse and validate the data you receive. When combined with `.def_json()`, you get a `json()` helper function to safely parse the body. - -```typescript -const ProductResponse = z.object({ id: z.string() /* ... */ }) - -const getProduct = apiClient - .def_method('GET') - .def_url(`${BASE_URL}/products/$id`) - .def_response(async ({ response, json }) => { - // You can inspect the raw response first - if (!response.ok) { - throw new Error(`Request failed with status ${response.status}`) - } - // Then parse the body - return ProductResponse.parse(await json()) - }) - .build() -``` - -### Path and Search Parameters - -- **Path Parameters:** Define dynamic segments in your URL with a `$` prefix (e.g., `/$id`). Pass the corresponding values in the `path` object in `.query()`. -- **Search Parameters:** Use `.def_searchparams()` to define a validator for query strings. Pass the data in the `search` object in `.query()`. - -```typescript -const ProductSearch = z.object({ - category: z.string(), - limit: z.number().optional(), -}) - -const findProducts = apiClient - .def_method('GET') - .def_url(`${BASE_URL}/products`) - .def_searchparams(ProductSearch.parse) - .build() - -await findProducts.query({ - search: { - category: 'electronics', - limit: 10, - }, -}) -// Resulting URL: https://api.example.com/products?category=electronics&limit=10 -``` - -## Declarative API Routers - -While the `FetchBuilder` is perfect for defining individual endpoints, `metal-fetch` also provides a powerful `router` function to define your entire API surface in a single, organized, and type-safe object. This creates a fully-typed client SDK, eliminating guesswork and ensuring your frontend and backend stay in sync. - -The router takes a `baseUrl` and a nested structure of `FetchBuilder` instances. It automatically assigns the URL and HTTP method to each builder based on its position in the tree. - -### Example: Defining a Router - -```typescript -import { f, type GetRouterConfig } from '@metal-box/fetch' -import { z } from 'zod' - -const BASE_URL = 'https://api.example.com' - -// Define your validators -const UserResponse = z.object({ id: z.string(), name: z.string() }) -const UserListResponse = z.array(UserResponse) -const UserRequest = z.object({ name: z.string() }) - -// Define the entire API structure -export const api = f.router(BASE_URL, { - users: { - GET: f - .builder() - .def_json() - .def_response(async ({ json }) => - UserListResponse.parse(await json()) - ), - - POST: f - .builder() - .def_json() - .def_body(UserRequest.parse) - .def_response(async ({ json }) => UserResponse.parse(await json())), - - // Dynamic path: /users/:id - $id: { - GET: f - .builder() - .def_json() - .def_response(async ({ json }) => - UserResponse.parse(await json()) - ), - }, - }, -}) - -// The `api` object is now a fully-typed client -async function main() { - // GET /users - const users = await api.users.GET.query() - - // POST /users - const newUser = await api.users.POST.query({ - body: { name: 'Jane Doe' }, - }) - - // GET /users/123 - const user = await api.users.$id.GET.query({ - path: { id: '123' }, - }) -} -``` - -### End-to-End Type Safety with `GetRouterConfig` - -The most powerful feature of the router is its ability to infer types for your entire API. The `GetRouterConfig` utility creates a type definition that maps directly to your router structure. - -```typescript -// 1. Export the inferred type from your API definition file -export type ApiConfig = GetRouterConfig - -// 2. In another file, you can import and use this type -import { type ApiConfig } from './api' - -// You can now extract the exact response type for any endpoint -type GetUsersResponse = ApiConfig['users']['GET']['response'] -// `GetUsersResponse` is now: -// Array<{ id: string; name: string; }> - -// Or the body type for a POST request -type CreateUserBody = ApiConfig['users']['POST']['body'] -// `CreateUserBody` is now: -// { name: string; } -``` - -This creates a single source of truth for your API's contract, providing autocomplete and compile-time errors if the frontend usage ever diverges from the backend definition. - -## Advanced Usage - -### Middleware - -Middleware allows you to implement cross-cutting concerns. It follows the standard `(request, next) => Promise` pattern. - -```typescript -// An authentication middleware that adds a bearer token -const authMiddleware: f.MiddlewareFunction = async (request, next) => { - const token = localStorage.getItem('auth_token') - if (token) { - request.headers.set('Authorization', `Bearer ${token}`) - } - // Continue to the next middleware or the actual fetch call - return next(request) -} - -const secureApiClient = apiClient.def_middleware(authMiddleware) - -// All endpoints created from `secureApiClient` will now have the auth header -const getMyProfile = secureApiClient - .def_method('GET') - .def_url(`${BASE_URL}/me`) - .build() -``` - -### Error Handling - -`metal-fetch` provides three distinct handlers for processing outcomes: - -- `def_fetch_err_handler`: Handles `FetchResponseError`, which occurs when the server responds with a non-2xx status code (e.g., 404, 500). You get access to the typed error and status code. -- `def_unknown_err_handler`: Catches any other error, such as network failures, DNS issues, or CORS errors. -- `def_final_handler`: A `finally` block that executes after every request, regardless of success or failure. Useful for cleanup logic. - -```typescript -const robustClient = apiClient - .def_fetch_err_handler(({ error, status }) => { - console.error( - `[API Error] Status: ${status}, Message: ${error.statusMessage}` - ) - }) - .def_unknown_err_handler(({ error }) => { - console.error('[Network Error]', error) - }) - .def_final_handler(() => { - console.log('Request finished.') - }) -``` - -### Aborting Requests - -You can easily abort requests using either a timeout or an `AbortSignal`. - -```typescript -const controller = new AbortController() - -// Abort after 2 seconds -setTimeout(() => controller.abort(), 2000) - -try { - await getUser.query({ - path: { id: '123' }, - // Option 1: Timeout in milliseconds - timeout: 5000, - // Option 2: Provide one or more AbortSignals - abortSignal: controller.signal, - }) -} catch (error) { - // Catches the abort error - console.log(error.name) // 'TimeoutError' or 'AbortError' -} -``` +Visit [fetch](https://freestyle-fetch.vercel.app/en) to read the documentation. ## Contributing -Contributions are welcome! Please see the [CONTRIBUTING.md](./CONTRIBUTING.md) file for guidelines. +Please read [contributing guide](./CONTRIBUTING.md). ## License -This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for details. +Licensed under the [MIT](./LICENSE) License. \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 034e848..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,21 +0,0 @@ -# Security Policy - -## Supported Versions - -Use this section to tell people about which versions of your project are -currently being supported with security updates. - -| Version | Supported | -| ------- | ------------------ | -| 5.1.x | :white_check_mark: | -| 5.0.x | :x: | -| 4.0.x | :white_check_mark: | -| < 4.0 | :x: | - -## Reporting a Vulnerability - -Use this section to tell people how to report a vulnerability. - -Tell them where to go, how often they can expect to get an update on a -reported vulnerability, what to expect if the vulnerability is accepted or -declined, etc. diff --git a/assets/commit.md b/assets/commit.md new file mode 100644 index 0000000..886d74b --- /dev/null +++ b/assets/commit.md @@ -0,0 +1,75 @@ +## Commit Convention ๐Ÿ–‹๏ธ + +### **Overview** + +Primary purpose is to create an **explicit and structured commit history**, which is essential for writing automated tools, generating release notes, and, critically, aligning directly with **Semantic Versioning (SemVer)** principles. + +By adhering to this convention, your commit history clearly communicates the nature, scope, and impact of every change. + +--- + +### **Commit Message Structure (The Formula)** + +Every conventional commit **MUST** follow a clear, three-part structure: a header, an optional body, and optional footers. + +$$\text{}[\text{optional scope}]\text{!}:\text{}$$ +$$[\text{optional body}]$$ +$$[\text{optional footer(s)}]$$ + +#### **Key Structural Elements** + +| Element | Rule | Description | +| :-------------- | :----------- | :------------------------------------------------------------------------------------------------------------ | +| **type** | **REQUIRED** | A noun defining the nature of the change (e.g., `feat`, `fix`). | +| **scope** | _OPTIONAL_ | A noun in parentheses providing context for a section of the codebase (e.g., `feat(parser)`). | +| **\!** | _OPTIONAL_ | Placed immediately before the colon to **flag a Breaking Change** in the subject line. | +| **description** | **REQUIRED** | A concise, short summary of the code change, max 50 characters recommended. | +| **body** | _OPTIONAL_ | A longer, free-form, and detailed explanation of the change, separated from the header by **one blank line**. | +| **footer(s)** | _OPTIONAL_ | Used for metadata like issue references (`Closes #123`) and, most importantly, **BREAKING CHANGE** details. | + +--- + +### **Commit Prefix Types & SemVer Impact** + +While only `feat` and `fix` directly correlate with SemVer bumps, using the full set of recommended types provides comprehensive documentation. + +| Type | SemVer Implication | Description | Example | +| :------------------ | :----------------- | :------------------------------------------------------------------------------------- | :-------------------------------------------------------- | +| **feat** | **MINOR** | A new feature or capability. | `feat(api): add support for pagination` | +| **fix** | **PATCH** | A bug fix. | `fix(login): correct username validation error` | +| **BREAKING CHANGE** | **MAJOR** | Indicates an incompatible API change (via `!` or `BREAKING CHANGE:` footer). | `feat!: remove legacy config option` | +| **docs** | None | Changes to documentation files (e.g., README, inline comments). | `docs: update installation instructions` | +| **style** | None | Changes that do not affect code logic (whitespace, formatting). | `style: reformat code with prettier` | +| **refactor** | None | A code change that neither fixes a bug nor adds a feature (restructuring). | `refactor: extract utility functions` | +| **perf** | None | A code change that improves performance. | `perf(query): optimize DB query speed` | +| **test** | None | Adding missing tests or correcting existing tests. | `test(auth): add unit tests for token validation` | +| **build** | None | Changes that affect the build system or external dependencies (e.g., npm, Dockerfile). | `build: update webpack configuration` | +| **ci** | None | Changes to CI configuration files and scripts. | `ci: configure Jenkins pipeline` | +| **chore** | None | Other changes that don't modify source or test files (e.g., updating `.gitignore`). | `chore: reorganize folder structure` | +| **revert** | Tooling Dependent | Reverts a previous commit. | `revert: let us never again speak of the noodle incident` | + +--- + +### **Breaking Change Rules (Major Bump)** + +A breaking change **MUST** be explicitly and unambiguously noted in a Conventional Commit to justify a **MAJOR** SemVer version bump. + +You have two primary ways to signal a breaking change: + +1. **Via the Prefix Indicator (`!`)** + + - The `!` **MUST** be placed immediately before the colon (`:`) in the header. + - _Example:_ `feat(api)!: remove user registration endpoint` + +2. **Via the Footer** + + - The footer **MUST** contain the uppercase text `BREAKING CHANGE:`, followed by a detailed description of the breaking change. + - _Example:_ + + ``` + feat: upgrade Node version + + BREAKING CHANGE: use JavaScript features not available in Node 6. + ``` + +> **Note:** If the `!` is used in the prefix, the `BREAKING CHANGE:` footer **MAY** be omitted, but the commit body **SHALL** be used to describe the nature and migration path of the breaking change. diff --git a/assets/fetch-banner.png b/assets/fetch-banner.png new file mode 100644 index 0000000..04e4af9 Binary files /dev/null and b/assets/fetch-banner.png differ diff --git a/assets/metal-fetch.png b/assets/metal-fetch.png deleted file mode 100644 index e3651cf..0000000 Binary files a/assets/metal-fetch.png and /dev/null differ diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..c223af0 --- /dev/null +++ b/biome.json @@ -0,0 +1,3 @@ +{ + "extends": ["@freestylejs/config/biome"] +} diff --git a/eslint.config.mjs b/eslint.config.mjs deleted file mode 100644 index f201332..0000000 --- a/eslint.config.mjs +++ /dev/null @@ -1,78 +0,0 @@ -// eslint.config.js -'use strict' -import typescriptEslintPlugin from '@typescript-eslint/eslint-plugin' -import tsParser from '@typescript-eslint/parser' -import importPlugin from 'eslint-plugin-import' -import prettierPlugin from 'eslint-plugin-prettier' -import globals from 'globals' - -export default [ - // Global ignore patterns - { - ignores: [ - '**/dist/**', - '**/node_modules/**', - '**/test/**', - '**/*.js', - 'site', - 'examples/*', - ], - }, - // Configuration for TypeScript files - { - files: ['packages/**/*.ts'], - languageOptions: { - parser: tsParser, - globals: { - ...globals.browser, - ...globals.node, - }, - }, - plugins: { - prettier: prettierPlugin, - import: importPlugin, - '@typescript-eslint': typescriptEslintPlugin, - }, - rules: { - // Prettier and TypeScript rules - 'prettier/prettier': ['error', { endOfLine: 'auto' }], - '@typescript-eslint/no-unused-vars': 'warn', - - // Custom rules - eqeqeq: 'error', - 'no-var': 'error', - 'prefer-const': 'error', - 'no-console': 'warn', - 'import/order': [ - 'error', - { - alphabetize: { order: 'asc', caseInsensitive: true }, - groups: [ - 'builtin', - 'external', - 'internal', - 'parent', - 'sibling', - 'index', - 'object', - ], - 'newlines-between': 'never', - pathGroupsExcludedImportTypes: ['builtin'], - }, - ], - 'sort-imports': [ - 'error', - { - ignoreDeclarationSort: true, - }, - ], - }, - settings: { - 'import/resolver': { - node: { - extensions: ['.js', '.ts'], - }, - }, - }, - }, -] diff --git a/package.json b/package.json index 6961773..a4066f8 100644 --- a/package.json +++ b/package.json @@ -1,69 +1,57 @@ { - "name": "metal-fetch-root-repository", + "name": "freestyle-fetch-root", "version": "1.0.0", - "description": "Root repository of metal fetch", + "description": "Root repository of freestylejs fetch", "private": false, "author": "danpacho", "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/metal-ts/fetch" + "url": "https://github.com/freestylejs/fetch" }, "type": "module", "scripts": { - "dev": "turbo run dev", - "build": "turbo run build && gzip -c ./packages/fetch/dist/index.mjs | wc -c", - "bench": "pnpm --filter=\"benchmark\" start run", + "dev": "turbo run build && turbo run dev", + "build": "pnpm reset:dist && turbo run build --no-cache --force", + "build:fast": "pnpm reset:dist && turbo run build:fast", "start": "turbo run start", - "clean": "turbo run clean", "test": "vitest --run", "test:watch": "vitest --watch -u", "test:coverage": "vitest run --coverage", - "test:ci": "pnpm test:coverage && pnpm prettier && pnpm ts:typecheck && pnpm build", - "ts:typecheck": "tsc --noEmit --allowImportingTsExtensions --skipLibCheck", - "ts:performance": "rimraf ts-perf && tsc --noEmit --generateTrace ts-perf", - "reset": "pnpm clean && pnpm -r --parallel exec rimraf node_modules && rimraf node_modules", - "prepublish": "pnpm test:ci && pnpm bench", - "prettier": "prettier 'packages/**/*.{ts,js,md}' --check", - "prettier:fix": "prettier 'packages/**/*.{ts,js,md}' --write", - "eslint": "eslint", - "eslint:fix": "eslint --fix", - "release": "pnpm build && changeset publish", - "pre-commit": "lint-staged", - "prepare": "husky install", - "changeset": "changeset", - "packages:publish": "changeset publish", - "packages:version": "changeset version" + "test:ci": "pnpm build && pnpm check && pnpm test:coverage", + "ts:typecheck": "turbo run ts:typecheck", + "ts:perf": "rimraf ts-perf && tsc --noEmit --generateTrace ts-perf", + "lint": "biome lint --write", + "format": "biome format --write", + "check": "biome check --write", + "report": "biome check --reporter=summary", + "reset:dist": "pnpm -r --parallel exec rimraf dist .turbo", + "reset:modules": "pnpm -r --parallel exec rimraf node_modules && rimraf node_modules .turbo", + "reset": "pnpm reset:dist && pnpm reset:modules", + "pkg:init": "pnpm test:ci && changeset", + "pkg:version": "changeset version", + "pkg:publish": "changeset publish", + "pre-commit": "pnpm format" }, + "packageManager": "pnpm@10.20.0", "devDependencies": { - "@changesets/cli": "^2.27.11", - "@typescript-eslint/eslint-plugin": "^8.20.0", - "@typescript-eslint/parser": "^8.20.0", - "@vitest/coverage-v8": "^3.0.0", - "chalk": "^5.4.1", - "eslint": "^9.18.0", - "eslint-config-prettier": "^10.0.1", - "eslint-config-turbo": "^2.3.3", - "eslint-plugin-import": "^2.31.0", - "eslint-plugin-prettier": "^5.2.2", - "husky": "^9.1.7", - "lint-staged": "^15.4.0", - "msw": "^2.7.0", - "prettier": "^3.4.2", - "rimraf": "^6.0.1", - "ts-expect": "^1.3.0", - "tsup": "^8.3.5", - "turbo": "^2.3.3", - "typescript": "^5.7.3", - "vite-tsconfig-paths": "^5.1.4", - "vitest": "^3.0.0", - "@metal-box/type": "^0.2.0" + "@biomejs/biome": "latest", + "@changesets/cli": "latest", + "@freestylejs/config": "latest", + "@types/node": "latest", + "@vitest/coverage-v8": "4.0.6", + "husky": "latest", + "lint-staged": "latest", + "msw": "^2.12.3", + "rimraf": "latest", + "tsup": "latest", + "turbo": "latest", + "typescript": "latest", + "vite": "latest", + "vite-tsconfig-paths": "latest", + "vitest": "latest" }, "engines": { "node": ">=20.0.0" - }, - "gitmoji": { - "capitalizeTitle": false - }, - "packageManager": "pnpm@9.15.4" + } } diff --git a/packages/fetch/CHANGELOG.md b/packages/fetch/CHANGELOG.md new file mode 100644 index 0000000..3538754 --- /dev/null +++ b/packages/fetch/CHANGELOG.md @@ -0,0 +1,7 @@ +# @freestylejs/fetch + +## 1.0.0 + +### Major Changes + +- Major V1 release for fluent, type-safe fetch client using typescript. diff --git a/packages/fetch/package.json b/packages/fetch/package.json index 3a037d0..90e56ba 100644 --- a/packages/fetch/package.json +++ b/packages/fetch/package.json @@ -1,6 +1,6 @@ { - "name": "@metal-box/fetch", - "version": "0.1.0", + "name": "@freestylejs/fetch", + "version": "1.0.0", "description": "Declarative fetcher using typescript", "author": "danpacho", "license": "MIT", @@ -9,14 +9,36 @@ "publishConfig": { "access": "public" }, - "files": [ - "dist" - ], + "files": ["dist"], "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", + "exports": { + "./*": { + "import": { + "types": "./dist/*.d.ts", + "default": "./dist/*.js" + }, + "require": { + "types": "./dist/*.d.cts", + "default": "./dist/*.cjs" + } + }, + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, "scripts": { - "build": "tsup src/index.ts --format=cjs,esm --dts", - "build:fast": "tsup src/index.ts --format=cjs,esm" + "build": "tsup", + "build:fast": "tsup --no-dts", + "dev": "pnpm build --sourcemap --watch", + "ts:typecheck": "tsc --noEmit --skipLibCheck" } } diff --git a/packages/fetch/src/__tests__/__mocks__/client.ts b/packages/fetch/src/__tests__/__mocks__/client.ts index 009e2de..a4be4cc 100644 --- a/packages/fetch/src/__tests__/__mocks__/client.ts +++ b/packages/fetch/src/__tests__/__mocks__/client.ts @@ -1,10 +1,10 @@ -import { MetalSchemaShape, t } from '@metal-box/type' -import { type GetRouterConfig, f } from '../..' +import { type ZodSchema, z } from 'zod' +import { f, type GetRouterConfig } from '../..' import { BASE_URL } from './constant' import { Model } from './model' -const ApiResponse = (data: DataSchema) => - t.object({ +const ApiResponse = (data: DataSchema) => + z.object({ data, }) @@ -16,7 +16,7 @@ export const api = f.router(BASE_URL, { .def_json() .def_default_referrer('about:client') .def_response(async ({ json }) => - ApiResponse(t.union(t.string, t.undefined)).parse( + ApiResponse(z.union([z.string(), z.undefined()])).parse( await json() ) ), @@ -51,7 +51,7 @@ export const api = f.router(BASE_URL, { .builder() .def_json() .def_default_referrer('about:client') - .def_body(Model.bookRequest.parse) + .def_body(Model.book.parse) .def_response(async ({ json }) => ApiResponse(Model.book).parse(await json()) ), diff --git a/packages/fetch/src/__tests__/__mocks__/model.ts b/packages/fetch/src/__tests__/__mocks__/model.ts index f53cba2..b6f967c 100644 --- a/packages/fetch/src/__tests__/__mocks__/model.ts +++ b/packages/fetch/src/__tests__/__mocks__/model.ts @@ -1,40 +1,40 @@ -import { Infer, t } from '@metal-box/type' +import { z } from 'zod' -const Book = t.object({ - name: t.string, - price: t.number, - category: t.string, +const Book = z.object({ + name: z.string(), + price: z.number(), + category: z.string(), // server data - publish_date: t.string, - uuid: t.string, + publish_date: z.string(), + uuid: z.string(), }) -export type BookModel = Infer +export type BookModel = z.Infer -const BookRequest = t.object({ - name: t.string, - price: t.number, - category: t.string, +const BookRequest = z.object({ + name: z.string(), + price: z.number(), + category: z.string(), }) -export type BookRequestModel = Infer +export type BookRequestModel = z.Infer -const BookList = t.array(Book) -export type BookModelList = Infer +const BookList = z.array(Book) +export type BookModelList = z.Infer -const BookQueryBody = t.object({ - name: t.string, +const BookQueryBody = z.object({ + name: z.string(), }) -export type BookQueryBodyModel = Infer +export type BookQueryBodyModel = z.Infer -const Author = t.object({ - name: t.null.array, +const Author = z.object({ + name: z.array(z.null()), books: BookList, }) -export type AuthorModel = Infer +export type AuthorModel = z.Infer -const AuthorList = t.array(Author) -export type AuthorListModel = Infer +const AuthorList = z.array(Author) +export type AuthorListModel = z.Infer export const Model = { // books diff --git a/packages/fetch/src/__tests__/__mocks__/server.ts b/packages/fetch/src/__tests__/__mocks__/server.ts index 1043500..7394eb4 100644 --- a/packages/fetch/src/__tests__/__mocks__/server.ts +++ b/packages/fetch/src/__tests__/__mocks__/server.ts @@ -1,6 +1,6 @@ import { HttpResponse, http } from 'msw' import { BASE_URL } from './constant' -import { type BookModel, BookRequestModel, Model } from './model' +import { type BookModel, type BookRequestModel, Model } from './model' type UUID = string type DB_COLLECTION = Map @@ -53,11 +53,13 @@ class BookDatabase { } // REST @PUT books/:id - public updateBook(id: UUID, book: BookModel): BookModel { + public updateBook(id: UUID, book: BookModel): BookModel | undefined { const originalBook = this.db.get(id) + if (!originalBook) return undefined + const updatedBook = { ...originalBook, ...book } this.db.set(id, updatedBook) - return book + return updatedBook } } diff --git a/packages/fetch/src/__tests__/build.router.test.ts b/packages/fetch/src/__tests__/build.router.test.ts index 6d87efe..f1c76bb 100644 --- a/packages/fetch/src/__tests__/build.router.test.ts +++ b/packages/fetch/src/__tests__/build.router.test.ts @@ -1,10 +1,10 @@ import { setupServer } from 'msw/node' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { api } from './__mocks__/client' -import { - type BookModel, - type BookModelList, - type BookRequestModel, +import type { + BookModel, + BookModelList, + BookRequestModel, } from './__mocks__/model' import { bookServer } from './__mocks__/server' import { label } from './utils/test.label' diff --git a/packages/fetch/src/__tests__/builder.test.ts b/packages/fetch/src/__tests__/builder.test.ts index 20065e6..8f15ac0 100644 --- a/packages/fetch/src/__tests__/builder.test.ts +++ b/packages/fetch/src/__tests__/builder.test.ts @@ -1,5 +1,5 @@ -import { t } from '@metal-box/type' import { describe, expect, it, vi } from 'vitest' +import { z } from 'zod' import { f } from '..' import { FetchBuilder } from '../core/fetcher' import { FetchUnit } from '../core/fetcher/unit' @@ -32,7 +32,7 @@ describe('FetchBuilder', () => { }) it('should define a body validator', () => { - const bodySchema = t.object({ name: t.string }) + const bodySchema = z.object({ name: z.string() }) const b = f.builder().def_body(bodySchema.parse) const testBody = { name: 'test' } expect(b.bodyValidator(testBody)).toEqual(testBody) diff --git a/packages/fetch/src/__tests__/procedure.test.ts b/packages/fetch/src/__tests__/procedure.test.ts index 455964e..a497a05 100644 --- a/packages/fetch/src/__tests__/procedure.test.ts +++ b/packages/fetch/src/__tests__/procedure.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Procedure, ProcedureSet } from '../utils/procedure' +import { type Procedure, ProcedureSet } from '../utils/procedure' import { label } from './utils/test.label' describe(label.unit('Procedure'), () => { diff --git a/packages/fetch/src/__tests__/unit.test.ts b/packages/fetch/src/__tests__/unit.test.ts index a9e772e..7726653 100644 --- a/packages/fetch/src/__tests__/unit.test.ts +++ b/packages/fetch/src/__tests__/unit.test.ts @@ -1,8 +1,8 @@ -import { Infer, t } from '@metal-box/type' import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' -import { TypeEqual, expectType } from 'ts-expect' +import { expectType, type TypeEqual } from 'ts-expect' import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { z } from 'zod' import { f } from '..' import { FetchPathParamsError, FetchResponseError } from '../core/error' import { FetchUnit, type InferFetchUnit } from '../core/fetcher/unit' @@ -236,10 +236,10 @@ describe('FetchUnit', () => { it(label.case('should DEFINE search params shape'), () => { const fetchUnit = f.builder() - const SearchParams = t + const SearchParams = z .object({ - name: t.string, - category: t.string, + name: z.string(), + category: z.string(), }) .transform((e) => ({ name: e.name, @@ -252,7 +252,7 @@ describe('FetchUnit', () => { type InjectedFetchUnit = FetchUnit< string, unknown, - Infer, + z.Infer, unknown, unknown, { @@ -266,10 +266,10 @@ describe('FetchUnit', () => { it(label.case('should DEFINE body shape'), () => { const fetchUnit = f.builder() - const BookRequest = t.object({ - name: t.string, - category: t.string, - price: t.number, + const BookRequest = z.object({ + name: z.string(), + category: z.string(), + price: z.number(), }) const result = fetchUnit.def_body(BookRequest.parse).build() @@ -278,7 +278,7 @@ describe('FetchUnit', () => { string, unknown, unknown, - Infer, + z.Infer, unknown, { isJsonMode: false @@ -290,19 +290,19 @@ describe('FetchUnit', () => { }) it(label.case('should DEFINE search params'), async () => { - const SearchParams = t.object({ - name: t.string, - price: t.number, + const SearchParams = z.object({ + name: z.string(), + price: z.number(), }) - const BookResponse = t + const BookResponse = z .object({ - data: t.object({ - name: t.string, - price: t.string, - id: t.string, + data: z.object({ + name: z.string(), + price: z.string(), + id: z.string(), }), - status: t.literal('success'), + status: z.literal('success'), }) .transform((e) => ({ ...e, @@ -352,9 +352,9 @@ describe('FetchUnit', () => { () => { const fetchUnit = f.builder().def_json() - const Product = t.object({ - name: t.string, - price: t.number, + const Product = z.object({ + name: z.string(), + price: z.number(), }) const result = fetchUnit .def_response(({ json }) => Product.parse(json)) @@ -365,7 +365,7 @@ describe('FetchUnit', () => { unknown, unknown, unknown, - Infer, + z.Infer, { isJsonMode: true isSafeMode: false @@ -382,9 +382,9 @@ describe('FetchUnit', () => { async () => { const fetchUnit = f.builder().def_json() - const Product = t.object({ - name: t.string, - price: t.number, + const Product = z.object({ + name: z.string(), + price: z.number(), }) const result = fetchUnit .def_response(async ({ json }) => { @@ -399,7 +399,7 @@ describe('FetchUnit', () => { unknown, unknown, unknown, - Infer, + z.Infer, { isJsonMode: true isSafeMode: false @@ -412,15 +412,15 @@ describe('FetchUnit', () => { ) it(label.case('should QUERY json with [strict mode]'), async () => { - const Body = t.object({ - name: t.string, - category: t.string, - price: t.number, + const Body = z.object({ + name: z.string(), + category: z.string(), + price: z.number(), }) - const ApiResponse = t.object({ + const ApiResponse = z.object({ data: Body, - status: t.union(t.literal('success'), t.literal('error')), - 'message?': t.string, + status: z.union([z.literal('success'), z.literal('error')]), + message: z.string().optional(), }) const postUnit = f .builder() @@ -437,8 +437,8 @@ describe('FetchUnit', () => { 'POST', unknown, unknown, - Infer, - Infer, + z.Infer, + z.Infer, { isJsonMode: true isSafeMode: false @@ -452,8 +452,8 @@ describe('FetchUnit', () => { { method: 'POST' url: 'https://unit-api/v2/books' - body: Infer - response: Infer + body: z.Infer + response: z.Infer mode: { isJson: true isSafeFetch: false @@ -477,13 +477,10 @@ describe('FetchUnit', () => { }) it(label.case('should QUERY formData with [strict mode]'), async () => { - const BlobShape = t.custom<'Blob', Blob>( - 'Blob', - (e) => e instanceof Blob - ) + const BlobShape = z.custom((e) => e instanceof Blob) const toJson = async (e: FormData) => { - const title = t.string.parse(e.get('name')) + const title = z.string().parse(e.get('name')) const file = e.get('file') const text = await BlobShape.parse(file).text() return { @@ -492,11 +489,8 @@ describe('FetchUnit', () => { } } - const FormDataShape = t - .custom< - 'FormData', - FormData - >('FormData', (e) => e instanceof FormData) + const FormDataShape = z + .custom((e) => e instanceof FormData) .transform(toJson) const fetchUnit = f @@ -507,7 +501,7 @@ describe('FetchUnit', () => { .def_url(`${BASE_URL}/books/images`) .def_body(BlobShape.parse) .def_response(async ({ response }) => - FormDataShape.parse(await response.formData()) + FormDataShape.parseAsync(await response.formData()) ) .build() diff --git a/packages/fetch/src/core/fetcher/builder.ts b/packages/fetch/src/core/fetcher/builder.ts index 4e7bdab..d2520b1 100644 --- a/packages/fetch/src/core/fetcher/builder.ts +++ b/packages/fetch/src/core/fetcher/builder.ts @@ -1,13 +1,13 @@ -import { Middleware, MiddlewareFunction } from '../../utils/middleware' -import { Procedure, ProcedureSet } from '../../utils/procedure' -import type { ConcreteBoolean, JSON } from '../../utils/types' +import { Middleware, type MiddlewareFunction } from '../../utils/middleware' +import { type Procedure, ProcedureSet } from '../../utils/procedure' +import type { ConcreteBoolean, Json } from '../../utils/types' import { - FetchErrorCode, + type FetchErrorCode, FetchPathParamsError, - FetchResponseError, + type FetchResponseError, FetchSearchParamsError, } from '../error' -import { +import type { DefaultFetchModeOptions, FetchMethod, FetchMethodString, @@ -452,7 +452,7 @@ export class FetchBuilder< * const fetchUnit = f.builder().def_body(BodyZod.parse) * * // Example using metal-box/type - * import { t } from "@metal-box/type" + * import { t } from "@freestylejs/schema" * * const BodyMetal = t.object({ name: t.string }) * const fetchUnit2 = f.builder().def_body(BodyMetal.parse) @@ -486,7 +486,7 @@ export class FetchBuilder< responseArgument: $ModeOptions['isJsonMode'] extends true ? { response: Response - json: () => Promise + json: () => Promise } : { response: Response @@ -501,7 +501,7 @@ export class FetchBuilder< responseArgument: $ModeOptions['isJsonMode'] extends true ? { response: Response - json: () => Promise + json: () => Promise } : { response: Response diff --git a/packages/fetch/src/core/fetcher/core.type.ts b/packages/fetch/src/core/fetcher/core.type.ts index 128700e..49f8938 100644 --- a/packages/fetch/src/core/fetcher/core.type.ts +++ b/packages/fetch/src/core/fetcher/core.type.ts @@ -20,7 +20,7 @@ export type FetchMethod = export type FetchHeader = BaseFetchOption['headers'] & { [key in FetchHeaderKeyString]?: string } -export type FetchOption = Omit & { +export type FetchOption = Omit & { headers: FetchHeader } diff --git a/packages/fetch/src/core/fetcher/fetch.option.ts b/packages/fetch/src/core/fetcher/fetch.option.ts index b063ef3..262f632 100644 --- a/packages/fetch/src/core/fetcher/fetch.option.ts +++ b/packages/fetch/src/core/fetcher/fetch.option.ts @@ -5,6 +5,20 @@ import type { FetchUrl, } from './core.type' +export interface FetchCommonConfiguration { + baseUrl: string + cache?: FetchOption['cache'] + mode?: FetchOption['mode'] + credentials?: FetchOption['credentials'] + redirect?: FetchOption['redirect'] + referrer?: FetchOption['referrer'] + referrerPolicy?: FetchOption['referrerPolicy'] + integrity?: FetchOption['integrity'] + keepalive?: FetchOption['keepalive'] + window?: FetchOption['window'] + priority?: FetchOption['priority'] +} + export class FetchOptionStore { public get options(): FetchOption { return { diff --git a/packages/fetch/src/core/fetcher/index.ts b/packages/fetch/src/core/fetcher/index.ts index 2737164..0c2a962 100644 --- a/packages/fetch/src/core/fetcher/index.ts +++ b/packages/fetch/src/core/fetcher/index.ts @@ -1,2 +1,2 @@ -export * from './unit' export * from './builder' +export * from './unit' diff --git a/packages/fetch/src/core/fetcher/unit.ts b/packages/fetch/src/core/fetcher/unit.ts index e551fa6..eef5944 100644 --- a/packages/fetch/src/core/fetcher/unit.ts +++ b/packages/fetch/src/core/fetcher/unit.ts @@ -1,4 +1,4 @@ -import type { ConcreteBoolean, JSON, OmitUnknown } from '../../utils/types' +import type { ConcreteBoolean, Json, OmitUnknown } from '../../utils/types' import { type FetchErrorCode, FetchResponseError } from '../error' import type { FetchBuilder } from './builder' import type { @@ -375,7 +375,7 @@ export class FetchUnit< (this.$builder.isJsonMode ? { response, - json: () => response.json() as Promise, + json: () => response.json() as Promise, } : { response }) as any ) diff --git a/packages/fetch/src/core/router.ts b/packages/fetch/src/core/router.ts index ff0773b..24382b6 100644 --- a/packages/fetch/src/core/router.ts +++ b/packages/fetch/src/core/router.ts @@ -1,10 +1,11 @@ -import { FetchUnit, FetchUnitShape, InferFetchUnit } from './fetcher' +import type { FetchUnit, FetchUnitShape, InferFetchUnit } from './fetcher' import { type DefaultFetchBuilderShape, FetchBuilder, type FetchBuilderShape, } from './fetcher/builder' import type { FetchMethod, Param } from './fetcher/core.type' +import type { FetchCommonConfiguration } from './fetcher/fetch.option' type Structure = | { @@ -17,26 +18,27 @@ type BuilderStructure = Structure type UnitStructure = Structure class Router< - const RouterBaseUrl extends string, const RouterBuilderStructure extends BuilderStructure, + const CommonConfig extends FetchCommonConfiguration, > { public constructor( - routerBaseUrl: RouterBaseUrl, - routerStructure: RouterBuilderStructure + routerStructure: RouterBuilderStructure, + commonConfig: CommonConfig ) { this._buildedRouterStructure = this.buildRouterStructure( routerStructure, - routerBaseUrl + commonConfig, + commonConfig.baseUrl ) } private _buildedRouterStructure: BuildRouterUrlFromStructure< RouterBuilderStructure, - RouterBaseUrl + CommonConfig['baseUrl'] > public get routerStructure(): BuildRouterUrlFromStructure< RouterBuilderStructure, - RouterBaseUrl + CommonConfig['baseUrl'] > { return this._buildedRouterStructure } @@ -73,6 +75,7 @@ class Router< private buildRouterStructure>( structure: BuilderStructure, + commonConfig: FetchCommonConfiguration, baseUrl: string = '' ): T { const result = {} as Record @@ -91,11 +94,47 @@ class Router< const newBuilder = value .def_url(Router.getUrlPath(baseUrl)) .def_method(key) - // 2. Build + + // 2. Set common config + if (commonConfig.cache) { + newBuilder.def_default_cache(commonConfig.cache) + } + if (commonConfig.credentials) { + newBuilder.def_default_credentials(commonConfig.credentials) + } + if (commonConfig.integrity) { + newBuilder.def_default_integrity(commonConfig.integrity) + } + if (commonConfig.keepalive) { + newBuilder.def_default_keepalive(commonConfig.keepalive) + } + if (commonConfig.mode) { + newBuilder.def_default_mode(commonConfig.mode) + } + if (commonConfig.priority) { + newBuilder.def_default_priority(commonConfig.priority) + } + if (commonConfig.redirect) { + newBuilder.def_default_redirect(commonConfig.redirect) + } + if (commonConfig.referrer) { + newBuilder.def_default_referrer(commonConfig.referrer) + } + if (commonConfig.referrerPolicy) { + newBuilder.def_default_referrer_policy( + commonConfig.referrerPolicy + ) + } + if (commonConfig.window) { + newBuilder.def_default_window(commonConfig.window) + } + + // 3. Build result[key] = newBuilder.build() } else if (Router.isRecord(value)) { result[key] = this.buildRouterStructure( value, + commonConfig, Router.getUrlPath(baseUrl, key as string) ) } else { @@ -109,91 +148,32 @@ class Router< } } -//TODO: Add transformer for router -/** - * @example - * ```ts - * - * const ex = - * { - * api: { - * "auth-login": { - * GET: f.unit() - * } - * } - * } - * - * const transformed = - * { - * api: { - * auth: { // auth-login -> auth, we need to change that for convenience! - * GET: f.unit() - * } - * } - * ``` - routerTransformer?: < - TransformedRouterStructure extends RouterBuilderStructure, - >( - base: RouterBuilderStructure - ) => TransformedRouterStructure - */ - -/** - * @description Define RESTful API structure with `router` - * @param baseUrl Represents the base_url of the api - * @param router RESTful API structure - * @example - * ```md - * 1. BASE_URL : 'https://api/v1/example.com' - * - * 2. REST_API_STRUCTURE - * โ€บ auth : 'BASE_URL/auth' - * โ€บ login : 'BASE_URL/auth/login' - * โ€บ books : 'BASE_URL/books' - * โ€บ book : 'BASE_URL/books/:id' - * - * 3. DEFINE_ROUTER - * ``` - * - * ```ts - * import * as f from "@metal-box/fetch" - * - * export const api = f.router(BASE_URL, { - * auth: { - * login: { - * GET: f.builder() - * }, - * }, - * books: { - * GET: f.builder() - * POST: f.builder() - * // Dynamic path parameter via $ symbol - * $id: { - * GET: f.builder() - * PUT: f.builder() - * DELETE: f.builder() - * }, - * }, - * }) - * ``` - * - * ```md - * - * 4. GET_ROUTER_CONFIG - * ``` - * ```ts - * export type Api = f.GetRouterConfig - * - * ``` - */ -export const router = < +export function router< const RouterBaseUrl extends string, const RouterBuilderStructure extends BuilderStructure, >( baseUrl: RouterBaseUrl, router: RouterBuilderStructure -): BuildRouterUrlFromStructure => { - const baseRouter = new Router(baseUrl, router).routerStructure +): BuildRouterUrlFromStructure +export function router< + const CommonConfig extends FetchCommonConfiguration, + const RouterBuilderStructure extends BuilderStructure, +>( + config: CommonConfig, + router: RouterBuilderStructure +): BuildRouterUrlFromStructure +export function router< + const ConfigOrBaseUrl extends string | FetchCommonConfiguration, + const RouterBuilderStructure extends BuilderStructure, +>(configOrBaseUrl: ConfigOrBaseUrl, routerStructure: RouterBuilderStructure) { + if (typeof configOrBaseUrl === 'string') { + const baseRouter = new Router(routerStructure, { + baseUrl: configOrBaseUrl, + }).routerStructure + return baseRouter + } + const baseRouter = new Router(routerStructure, configOrBaseUrl) + .routerStructure return baseRouter } diff --git a/packages/fetch/src/index.ts b/packages/fetch/src/index.ts index fb53e6b..ad2654b 100644 --- a/packages/fetch/src/index.ts +++ b/packages/fetch/src/index.ts @@ -1,5 +1,5 @@ -import { FetchBuilder, type FetchUnitShape, builder } from './core/fetcher' -import { GetRouterConfig, router } from './core/router' +import { builder, type FetchBuilder, type FetchUnitShape } from './core/fetcher' +import { type GetRouterConfig, router } from './core/router' import { Middleware } from './utils/middleware' /** diff --git a/packages/fetch/src/utils/procedure.ts b/packages/fetch/src/utils/procedure.ts index bddac0c..b007ad5 100644 --- a/packages/fetch/src/utils/procedure.ts +++ b/packages/fetch/src/utils/procedure.ts @@ -8,7 +8,6 @@ export class ProcedureSet { new Set() private procedureList: Array> = [] - public constructor() {} public get procedures(): ReadonlyArray> { return Array.from(this.registeredProcedure) diff --git a/packages/fetch/src/utils/types/index.ts b/packages/fetch/src/utils/types/index.ts index fe0d389..4faf741 100644 --- a/packages/fetch/src/utils/types/index.ts +++ b/packages/fetch/src/utils/types/index.ts @@ -1,9 +1,9 @@ export type IncludeString = string & {} -export type JSON_Supported = string | number | boolean | null -export type JSON = - | Record - | Array +export type JsonPrimitives = string | number | boolean | null +export type Json = + | Record + | Array | string | number | boolean diff --git a/packages/fetch/tsup.config.ts b/packages/fetch/tsup.config.ts new file mode 100644 index 0000000..e1c3928 --- /dev/null +++ b/packages/fetch/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup' + +export default defineConfig((options) => ({ + entry: { + index: 'src/index.ts', + }, + watch: options.watch ? ['src/**/*'] : false, + clean: false, + dts: true, + outDir: 'dist', + // noExternal: [], + target: 'esnext', + format: ['cjs', 'esm'], + sourcemap: false, +})) diff --git a/packages/fetch_monitor/readme.md b/packages/fetch_monitor/readme.md deleted file mode 100644 index b280ff9..0000000 --- a/packages/fetch_monitor/readme.md +++ /dev/null @@ -1 +0,0 @@ -# Fetch monitoring tool diff --git a/packages/generator/readme.md b/packages/generator/readme.md deleted file mode 100644 index 4ca0546..0000000 --- a/packages/generator/readme.md +++ /dev/null @@ -1,16 +0,0 @@ -# Generator - -1. script that generates api router via `JSON schema` - 1. Schema builder script: https://github.com/StefanTerdell/json-schema-to-zod?tab=readme-ov-file#readme -2. API Structure query script - 1. Input: `query url` - 2. Returns: - 1. api endpoint structure - 1. type: `GET` | `POST` | `PUT` | `DELETE` - 2. path: `string` - 3. query_schema: `JSON schema` - 4. response_schema: `JSON schema` - 5. status_code: `number` -3. Build router automatically - 1. Input: `api endpoint structure` - 2. Output: `router` diff --git a/packages/openapi_generator/CHANGELOG.md b/packages/openapi_generator/CHANGELOG.md new file mode 100644 index 0000000..2323ff0 --- /dev/null +++ b/packages/openapi_generator/CHANGELOG.md @@ -0,0 +1,12 @@ +# create-freestyle-fetch + +## 1.0.0 + +### Major Changes + +- Major V1 release for fluent, type-safe fetch client using typescript. + +### Patch Changes + +- Updated dependencies + - @freestylejs/fetch@1.0.0 diff --git a/packages/openapi_generator/package.json b/packages/openapi_generator/package.json new file mode 100644 index 0000000..2afd967 --- /dev/null +++ b/packages/openapi_generator/package.json @@ -0,0 +1,35 @@ +{ + "name": "create-freestyle-fetch", + "version": "1.0.0", + "description": "Generate freestylejs fetch client from OpenAPI spec", + "author": "danpacho", + "license": "MIT", + "keywords": ["openapi", "generator", "fetch"], + "publishConfig": { + "access": "public" + }, + "bin": { + "create-freestyle-fetch": "dist/index.js" + }, + "scripts": { + "build": "tsup", + "build:fast": "tsup --no-dts", + "dev": "pnpm build --sourcemap --watch", + "test": "vitest", + "ts:typecheck": "tsc --noEmit --skipLibCheck" + }, + "dependencies": { + "@freestylejs/fetch": "workspace:^", + "commander": "^14.0.2", + "fs-extra": "^11.3.2", + "openapi-types": "^12.1.3", + "swagger-parser": "^10.0.3", + "zod": "^4.1.13", + "chalk": "^5.6.2" + }, + "devDependencies": { + "@types/fs-extra": "^11.0.4", + "@types/node": "^20.14.2", + "ts-expect": "^1.3.0" + } +} diff --git a/packages/openapi_generator/src/__tests__/__mocks__/.gen/e_commerse/api.ts b/packages/openapi_generator/src/__tests__/__mocks__/.gen/e_commerse/api.ts new file mode 100644 index 0000000..5bbbdbb --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/.gen/e_commerse/api.ts @@ -0,0 +1,15 @@ +import { f } from '@freestylejs/fetch'; +import { z } from 'zod'; +import * as Model from './models'; + +export const api = f.router('https://{environment}.example.com/v{version}', { +'products': { +'GET': f.builder().def_json().def_searchparams(z.object({ category: z.enum(['electronics', 'clothing', 'books']).optional(), priceRange: z.array(z.number()).optional(), page: z.number().int().min(1).optional(), limit: z.number().int().min(1).max(100).optional(), sort: z.record(z.enum(['asc', 'desc']), z.any()).optional() }).parse).def_response(async ({ json }) => z.array(Model.Product).parse(await json())), +'POST': f.builder().def_json().def_body(Model.Product.parse).def_response(async ({ json }) => Model.Product.parse(await json())) +}, +'orders': { +'$orderId': { +'GET': f.builder().def_json().def_response(async ({ json }) => Model.Order.parse(await json())) +} +} +}); \ No newline at end of file diff --git a/packages/openapi_generator/src/__tests__/__mocks__/.gen/e_commerse/index.ts b/packages/openapi_generator/src/__tests__/__mocks__/.gen/e_commerse/index.ts new file mode 100644 index 0000000..ec10836 --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/.gen/e_commerse/index.ts @@ -0,0 +1,2 @@ +export * from './api' +export * from './models' diff --git a/packages/openapi_generator/src/__tests__/__mocks__/.gen/e_commerse/models.ts b/packages/openapi_generator/src/__tests__/__mocks__/.gen/e_commerse/models.ts new file mode 100644 index 0000000..c653477 --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/.gen/e_commerse/models.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; + +export const Product = z.object({ +'id': z.uuid(), +'name': z.string(), +'productType': z.string(), +'price': z.number().min(0) +}); + +export type ProductModel = z.infer; + +export const ElectronicsProduct = Product.and(z.object({ +'specs': z.record(z.string(), z.any()).optional() +})); + +export type ElectronicsProductModel = z.infer; + +export const ClothingProduct = Product.and(z.object({ +'size': z.enum(['S', 'M', 'L', 'XL']).optional(), +'color': z.string().optional() +})); + +export type ClothingProductModel = z.infer; + +export const Order = z.object({ +'id': z.uuid().optional(), +'userId': z.string().optional(), +'products': z.array(Product).optional(), +'total': z.number().optional(), +'status': z.enum(['pending', 'shipped', 'delivered']).optional() +}); + +export type OrderModel = z.infer; + +export const Error = z.object({ +'code': z.number().int().optional(), +'message': z.string().optional() +}); + +export type ErrorModel = z.infer; \ No newline at end of file diff --git a/packages/openapi_generator/src/__tests__/__mocks__/.gen/e_commerse_2/api.ts b/packages/openapi_generator/src/__tests__/__mocks__/.gen/e_commerse_2/api.ts new file mode 100644 index 0000000..70aabf9 --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/.gen/e_commerse_2/api.ts @@ -0,0 +1,20 @@ +import { f } from '@freestylejs/fetch'; +import { z } from 'zod'; +import * as Model from './models'; + +export const api = f.router('https://{environment}.example-commerce.com/api/v2', { +'products': { +'GET': f.builder().def_json().def_searchparams(z.object({ searchQuery: z.string().optional(), tags: z.array(z.string()).optional(), page: z.number().int().min(1).optional() }).parse).def_response(async ({ json }) => Model.PaginatedProductResponse.parse(await json())), +'POST': f.builder().def_json().def_body(z.instanceof(FormData).parse).def_response(async ({ json }) => Model.Product.parse(await json())), +'$productId': { +'GET': f.builder().def_json().def_response(async ({ json }) => Model.Product.parse(await json())) +} +}, +'orders': { +'$orderId': { +'process': { +'POST': f.builder().def_json() +} +} +} +}); \ No newline at end of file diff --git a/packages/openapi_generator/src/__tests__/__mocks__/.gen/e_commerse_2/index.ts b/packages/openapi_generator/src/__tests__/__mocks__/.gen/e_commerse_2/index.ts new file mode 100644 index 0000000..ec10836 --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/.gen/e_commerse_2/index.ts @@ -0,0 +1,2 @@ +export * from './api' +export * from './models' diff --git a/packages/openapi_generator/src/__tests__/__mocks__/.gen/e_commerse_2/models.ts b/packages/openapi_generator/src/__tests__/__mocks__/.gen/e_commerse_2/models.ts new file mode 100644 index 0000000..0aa2358 --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/.gen/e_commerse_2/models.ts @@ -0,0 +1,91 @@ +import { z } from 'zod'; + +export const User = z.object({ +'id': z.uuid(), +'username': z.string().regex(/^[a-zA-Z0-9_-]{3,16}$/), +'email': z.email(), +'profile': z.object({ +'fullName': z.string().optional(), +'joinDate': z.iso.datetime().optional() +}).optional(), +'legacyId': z.number().int().optional() +}); + +export type UserModel = z.infer; + +export const ProductInput = z.object({ +'name': z.string(), +'description': z.string().optional(), +'price': z.number().min(0) +}); + +export type ProductInputModel = z.infer; + +export const Product = ProductInput.and(z.object({ +'id': z.uuid().optional(), +'imageUrl': z.url().optional(), +'stock': z.number().int().optional() +})); + +export type ProductModel = z.infer; + +export const PaginatedResponse = z.object({ +'page': z.number().int().optional(), +'pageSize': z.number().int().optional(), +'total': z.number().int().optional(), +'items': z.array(z.any()).optional() +}); + +export type PaginatedResponseModel = z.infer; + +export const PaginatedProductResponse = z.object({ +'page': z.number().int().optional(), +'pageSize': z.number().int().optional(), +'total': z.number().int().optional(), +'items': z.array(Product).optional() +}); + +export type PaginatedProductResponseModel = z.infer; + +export const CreditCard = z.object({ +'methodType': z.enum(['card']), +'cardNumber': z.string(), +'expiry': z.string().optional(), +'cvv': z.string().optional() +}); + +export type CreditCardModel = z.infer; + +export const PayPal = z.object({ +'methodType': z.enum(['paypal_account']), +'email': z.email() +}); + +export type PayPalModel = z.infer; + +export const PaymentMethod = z.discriminatedUnion('methodType', [CreditCard, PayPal]); + +export type PaymentMethodModel = z.infer; + +export const CallbackPayload = z.object({ +'orderId': z.uuid().optional(), +'status': z.enum(['PROCESSED', 'FAILED']).optional(), +'detail': z.string().optional() +}); + +export type CallbackPayloadModel = z.infer; + +export const InventoryUpdatePayload = z.object({ +'productId': z.uuid().optional(), +'newStockLevel': z.number().int().optional(), +'timestamp': z.iso.datetime().optional() +}); + +export type InventoryUpdatePayloadModel = z.infer; + +export const ApiError = z.object({ +'errorCode': z.string().optional(), +'message': z.string().optional() +}); + +export type ApiErrorModel = z.infer; \ No newline at end of file diff --git a/packages/openapi_generator/src/__tests__/__mocks__/.gen/hell/api.ts b/packages/openapi_generator/src/__tests__/__mocks__/.gen/hell/api.ts new file mode 100644 index 0000000..f59c795 --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/.gen/hell/api.ts @@ -0,0 +1,7 @@ +import { f } from '@freestylejs/fetch'; +import { z } from 'zod'; +import * as Model from './models'; + +export const api = f.router('', { + +}); \ No newline at end of file diff --git a/packages/openapi_generator/src/__tests__/__mocks__/.gen/hell/index.ts b/packages/openapi_generator/src/__tests__/__mocks__/.gen/hell/index.ts new file mode 100644 index 0000000..ec10836 --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/.gen/hell/index.ts @@ -0,0 +1,2 @@ +export * from './api' +export * from './models' diff --git a/packages/openapi_generator/src/__tests__/__mocks__/.gen/hell/models.ts b/packages/openapi_generator/src/__tests__/__mocks__/.gen/hell/models.ts new file mode 100644 index 0000000..8f275f6 --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/.gen/hell/models.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; + +export const HellObject = z.object({ +'required-key': z.string(), +'optional-key': z.string().optional(), +'spaced key': z.number(), +'$special$': z.boolean().optional() +}); + +export type HellObjectModel = z.infer; + +export const DeepNested = z.object({ +'level1': z.object({ +'level2': z.array(z.object({ +'level3': z.enum(['deep']).optional() +})).optional() +}).optional() +}); + +export type DeepNestedModel = z.infer; + +export const IntersectionHell = HellObject.and(z.object({ +'extra': z.string().optional() +})); + +export type IntersectionHellModel = z.infer; + +export const OptionA = z.object({ +'type': z.enum(['A']), +'a': z.string().optional() +}); + +export type OptionAModel = z.infer; + +export const OptionB = z.object({ +'type': z.enum(['B']), +'b': z.number().optional() +}); + +export type OptionBModel = z.infer; + +export const UnionHell = z.discriminatedUnion('type', [OptionA, OptionB]); + +export type UnionHellModel = z.infer; \ No newline at end of file diff --git a/packages/openapi_generator/src/__tests__/__mocks__/.gen/hell_2/api.ts b/packages/openapi_generator/src/__tests__/__mocks__/.gen/hell_2/api.ts new file mode 100644 index 0000000..f59c795 --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/.gen/hell_2/api.ts @@ -0,0 +1,7 @@ +import { f } from '@freestylejs/fetch'; +import { z } from 'zod'; +import * as Model from './models'; + +export const api = f.router('', { + +}); \ No newline at end of file diff --git a/packages/openapi_generator/src/__tests__/__mocks__/.gen/hell_2/index.ts b/packages/openapi_generator/src/__tests__/__mocks__/.gen/hell_2/index.ts new file mode 100644 index 0000000..ec10836 --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/.gen/hell_2/index.ts @@ -0,0 +1,2 @@ +export * from './api' +export * from './models' diff --git a/packages/openapi_generator/src/__tests__/__mocks__/.gen/hell_2/models.ts b/packages/openapi_generator/src/__tests__/__mocks__/.gen/hell_2/models.ts new file mode 100644 index 0000000..3967a6c --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/.gen/hell_2/models.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +export const StringDictionary = z.record(z.string(), z.any()); + +export type StringDictionaryModel = z.infer; + +export const ObjectWithCatchall = z.object({ +'id': z.string() +}).catchall(z.number().int()); + +export type ObjectWithCatchallModel = z.infer; + +export const SimpleUnion = z.union([z.string(), z.boolean()]); + +export type SimpleUnionModel = z.infer; + +export const AnyOfUnion = z.union([StringDictionary, z.number()]); + +export type AnyOfUnionModel = z.infer; + +export const ConstString = z.literal('FIXED_VALUE'); + +export type ConstStringModel = z.infer; + +export const NullableString = z.string().nullable(); + +export type NullableStringModel = z.infer; \ No newline at end of file diff --git a/packages/openapi_generator/src/__tests__/__mocks__/.gen/simple/api.ts b/packages/openapi_generator/src/__tests__/__mocks__/.gen/simple/api.ts new file mode 100644 index 0000000..eb14f90 --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/.gen/simple/api.ts @@ -0,0 +1,13 @@ +import { f } from '@freestylejs/fetch'; +import { z } from 'zod'; +import * as Model from './models'; + +export const api = f.router('', { +'books': { +'GET': f.builder().def_json().def_response(async ({ json }) => z.array(Model.Book).parse(await json())), +'POST': f.builder().def_json().def_body(Model.BookRequest.parse).def_response(async ({ json }) => Model.Book.parse(await json())), +'$id': { +'GET': f.builder().def_json().def_response(async ({ json }) => Model.Book.parse(await json())) +} +} +}); \ No newline at end of file diff --git a/packages/openapi_generator/src/__tests__/__mocks__/.gen/simple/index.ts b/packages/openapi_generator/src/__tests__/__mocks__/.gen/simple/index.ts new file mode 100644 index 0000000..ec10836 --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/.gen/simple/index.ts @@ -0,0 +1,2 @@ +export * from './api' +export * from './models' diff --git a/packages/openapi_generator/src/__tests__/__mocks__/.gen/simple/models.ts b/packages/openapi_generator/src/__tests__/__mocks__/.gen/simple/models.ts new file mode 100644 index 0000000..0025d88 --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/.gen/simple/models.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +export const Book = z.object({ +'uuid': z.string(), +'name': z.string(), +'price': z.number(), +'category': z.string(), +'publish_date': z.iso.datetime() +}); + +export type BookModel = z.infer; + +export const BookRequest = z.object({ +'name': z.string(), +'price': z.number(), +'category': z.string() +}); + +export type BookRequestModel = z.infer; \ No newline at end of file diff --git a/packages/openapi_generator/src/__tests__/__mocks__/.gen/youtube/api.ts b/packages/openapi_generator/src/__tests__/__mocks__/.gen/youtube/api.ts new file mode 100644 index 0000000..d3d52e8 --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/.gen/youtube/api.ts @@ -0,0 +1,14 @@ +import { f } from '@freestylejs/fetch'; +import { z } from 'zod'; +import * as Model from './models'; + +export const api = f.router('https://youtube.googleapis.com/', { +'youtube': { +'v3': { +'videos': { +'GET': f.builder().def_json().def_searchparams(z.object({ part: z.array(z.enum(['contentDetails', 'fileDetails', 'id', 'liveStreamingDetails', 'localizations', 'player', 'processingDetails', 'recordingDetails', 'snippet', 'statistics', 'status', 'suggestions', 'topicDetails'])), id: z.array(z.string()).optional(), chart: z.enum(['chartUnspecified', 'mostPopular']).optional(), maxResults: z.number().int().min(1).max(50).optional(), pageToken: z.string().optional() }).parse).def_response(async ({ json }) => Model.VideoListResponse.parse(await json())), +'POST': f.builder().def_json().def_searchparams(z.object({ part: z.array(z.enum(['snippet', 'status', 'player'])) }).parse).def_body(Model.Video.parse).def_response(async ({ json }) => Model.Video.parse(await json())) +} +} +} +}); \ No newline at end of file diff --git a/packages/openapi_generator/src/__tests__/__mocks__/.gen/youtube/index.ts b/packages/openapi_generator/src/__tests__/__mocks__/.gen/youtube/index.ts new file mode 100644 index 0000000..ec10836 --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/.gen/youtube/index.ts @@ -0,0 +1,2 @@ +export * from './api' +export * from './models' diff --git a/packages/openapi_generator/src/__tests__/__mocks__/.gen/youtube/models.ts b/packages/openapi_generator/src/__tests__/__mocks__/.gen/youtube/models.ts new file mode 100644 index 0000000..3adbd0c --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/.gen/youtube/models.ts @@ -0,0 +1,131 @@ +import { z } from 'zod'; + +export const PageInfo = z.object({ +'totalResults': z.number().int().optional(), +'resultsPerPage': z.number().int().optional() +}); + +export type PageInfoModel = z.infer; + +export const Thumbnail = z.object({ +'url': z.string().optional(), +'width': z.number().int().optional(), +'height': z.number().int().optional() +}); + +export type ThumbnailModel = z.infer; + +export const ThumbnailDetails = z.object({ +'default': Thumbnail.optional(), +'medium': Thumbnail.optional(), +'high': Thumbnail.optional(), +'standard': Thumbnail.optional(), +'maxres': Thumbnail.optional() +}); + +export type ThumbnailDetailsModel = z.infer; + +export const VideoLocalization = z.object({ +'title': z.string().optional(), +'description': z.string().optional() +}); + +export type VideoLocalizationModel = z.infer; + +export const VideoSnippet = z.object({ +'publishedAt': z.iso.datetime().optional(), +'channelId': z.string().optional(), +'title': z.string().optional(), +'description': z.string().optional(), +'thumbnails': ThumbnailDetails.optional(), +'channelTitle': z.string().optional(), +'tags': z.array(z.string()).optional(), +'categoryId': z.string().optional(), +'liveBroadcastContent': z.enum(['none', 'upcoming', 'live', 'completed']).optional(), +'localized': VideoLocalization.optional() +}); + +export type VideoSnippetModel = z.infer; + +export const VideoContentDetails = z.object({ +'duration': z.string().regex(/^PT[0-9]+[M|H|S]$/).optional(), +'dimension': z.string().optional(), +'definition': z.enum(['hd', 'sd']).optional(), +'caption': z.enum(['false', 'true']).optional(), +'licensedContent': z.boolean().optional(), +'regionRestriction': z.object({ +'allowed': z.array(z.string()).optional(), +'blocked': z.array(z.string()).optional() +}).optional() +}); + +export type VideoContentDetailsModel = z.infer; + +export const VideoStatus = z.object({ +'uploadStatus': z.enum(['deleted', 'failed', 'processed', 'rejected', 'uploaded']).optional(), +'failureReason': z.enum(['codec', 'conversion', 'emptyFile', 'invalidFile', 'tooSmall', 'uploadAborted']).optional(), +'rejectionReason': z.enum(['claim', 'copyright', 'duplicate', 'inappropriate', 'legal', 'length', 'termsOfUse', 'trademark', 'uploaderAccountClosed', 'uploaderAccountSuspended']).optional(), +'privacyStatus': z.enum(['private', 'public', 'unlisted']).optional(), +'license': z.enum(['creativeCommon', 'youtube']).optional(), +'embeddable': z.boolean().optional(), +'publicStatsViewable': z.boolean().optional(), +'madeForKids': z.boolean().optional() +}); + +export type VideoStatusModel = z.infer; + +export const VideoStatistics = z.object({ +'viewCount': z.string().optional(), +'likeCount': z.string().optional(), +'dislikeCount': z.string().optional(), +'favoriteCount': z.string().optional(), +'commentCount': z.string().optional() +}); + +export type VideoStatisticsModel = z.infer; + +export const VideoPlayer = z.object({ +'embedHtml': z.string().optional(), +'embedHeight': z.number().int().optional(), +'embedWidth': z.number().int().optional() +}); + +export type VideoPlayerModel = z.infer; + +export const Video = z.object({ +'kind': z.string().optional(), +'etag': z.string().optional(), +'id': z.string().optional(), +'snippet': VideoSnippet.optional(), +'contentDetails': VideoContentDetails.optional(), +'status': VideoStatus.optional(), +'statistics': VideoStatistics.optional(), +'player': VideoPlayer.optional() +}); + +export type VideoModel = z.infer; + +export const VideoListResponse = z.object({ +'kind': z.string().optional(), +'etag': z.string().optional(), +'nextPageToken': z.string().optional(), +'prevPageToken': z.string().optional(), +'pageInfo': PageInfo.optional(), +'items': z.array(Video).optional() +}); + +export type VideoListResponseModel = z.infer; + +export const ErrorResponse = z.object({ +'error': z.object({ +'code': z.number().int().optional(), +'message': z.string().optional(), +'errors': z.array(z.object({ +'domain': z.string().optional(), +'reason': z.string().optional(), +'message': z.string().optional() +})).optional() +}).optional() +}); + +export type ErrorResponseModel = z.infer; \ No newline at end of file diff --git a/packages/openapi_generator/src/__tests__/__mocks__/openapi/e_commerse.json b/packages/openapi_generator/src/__tests__/__mocks__/openapi/e_commerse.json new file mode 100644 index 0000000..2fe2489 --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/openapi/e_commerse.json @@ -0,0 +1,693 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Comprehensive Real-World E-Commerce API", + "summary": "An advanced API for managing an online store, including products, orders, users, payments, and analytics.", + "description": "This API provides endpoints for e-commerce operations, supporting features like user authentication, product catalog management, order processing, payment integration, and real-time notifications via webhooks. It demonstrates polymorphism in product types, complex schemas with composition, various security schemes, and runtime expressions in callbacks and links.", + "termsOfService": "https://example.com/terms", + "contact": { + "name": "API Support Team", + "url": "https://support.example.com", + "email": "support@example.com" + }, + "license": { + "name": "Apache 2.0", + "identifier": "Apache-2.0" + }, + "version": "1.2.0" + }, + "jsonSchemaDialect": "https://spec.openapis.org/oas/3.1/dialect/base", + "servers": [ + { + "url": "https://{environment}.example.com/v{version}", + "description": "Dynamic server URL with variables", + "variables": { + "environment": { + "default": "api", + "description": "Server environment (api, staging, dev)", + "enum": ["api", "staging", "dev"] + }, + "version": { + "default": "1", + "description": "API version" + } + } + } + ], + "paths": { + "/products": { + "summary": "Product catalog operations", + "description": "Endpoints for managing products in the store.", + "get": { + "tags": ["products"], + "summary": "List products", + "description": "Retrieve a paginated list of products with filtering options.", + "operationId": "listProducts", + "parameters": [ + { + "name": "category", + "in": "query", + "description": "Filter by product category", + "schema": { + "type": "string", + "enum": ["electronics", "clothing", "books"] + } + }, + { + "name": "priceRange", + "in": "query", + "description": "Filter by price range (min,max)", + "schema": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 2, + "maxItems": 2 + }, + "style": "form", + "explode": false + }, + { + "name": "page", + "in": "query", + "description": "Pagination page number", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "description": "Number of items per page", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + } + }, + { + "name": "sort", + "in": "query", + "description": "Sort criteria", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "enum": ["asc", "desc"] + } + }, + "style": "deepObject", + "explode": true + } + ], + "responses": { + "200": { + "description": "Successful response with product list", + "headers": { + "X-Total-Count": { + "description": "Total number of products", + "schema": { + "type": "integer" + } + }, + "X-Rate-Limit-Remaining": { + "description": "Remaining requests in the current rate limit window", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Product" + } + }, + "examples": { + "electronicsList": { + "summary": "Example list of electronics", + "value": [ + { + "id": "prod123", + "name": "Smartphone", + "productType": "Electronics", + "price": 499.99, + "specs": { + "battery": "5000mAh", + "screen": "6.5 inches" + } + } + ] + } + } + } + }, + "links": { + "nextPage": { + "operationId": "listProducts", + "parameters": { + "page": "$response.body#/nextPage" + }, + "description": "Link to the next page of results" + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "default": { + "description": "Unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "deprecated": false, + "security": [ + { + "apiKeyAuth": [] + } + ] + }, + "post": { + "tags": ["products"], + "summary": "Create a new product", + "description": "Add a new product to the catalog. Supports different product types via polymorphism.", + "operationId": "createProduct", + "requestBody": { + "description": "Product details to create", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Product" + }, + "examples": { + "electronicsExample": { + "summary": "Electronics product example", + "value": { + "name": "Laptop", + "productType": "Electronics", + "price": 999.99, + "specs": { + "cpu": "Intel i7", + "ram": "16GB" + } + } + } + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Product" + } + }, + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "productData": { + "type": "string", + "format": "binary" + }, + "image": { + "type": "string", + "format": "binary" + } + } + }, + "encoding": { + "productData": { + "contentType": "application/json", + "style": "form", + "explode": false, + "allowReserved": true + }, + "image": { + "contentType": "image/png, image/jpeg", + "headers": { + "X-Image-Size": { + "schema": { + "type": "integer" + } + } + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Product created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Product" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + }, + "callbacks": { + "productCreatedNotification": { + "{$request.body#/notificationUrl}": { + "post": { + "requestBody": { + "description": "Notification payload for new product", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "event": { + "type": "string", + "const": "productCreated" + }, + "productId": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Notification acknowledged" + } + } + } + } + } + }, + "security": [ + { + "oauth2": ["write:products"] + } + ] + } + }, + "/orders/{orderId}": { + "parameters": [ + { + "name": "orderId", + "in": "path", + "required": true, + "description": "Unique identifier for the order", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "get": { + "tags": ["orders"], + "summary": "Get order details", + "description": "Retrieve details of a specific order.", + "operationId": "getOrder", + "responses": { + "200": { + "description": "Order details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + }, + "security": [ + { + "basicAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + } + }, + "webhooks": { + "orderStatusUpdate": { + "post": { + "summary": "Webhook for order status changes", + "description": "Receive notifications when an order status updates.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + }, + "newStatus": { + "type": "string", + "enum": [ + "pending", + "shipped", + "delivered", + "cancelled" + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Webhook received successfully" + } + } + } + } + }, + "components": { + "schemas": { + "Product": { + "type": "object", + "discriminator": { + "propertyName": "productType", + "mapping": { + "electronics": "#/components/schemas/ElectronicsProduct", + "clothing": "#/components/schemas/ClothingProduct" + } + }, + "required": ["id", "name", "productType", "price"], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "xml": { + "name": "productName" + } + }, + "productType": { + "type": "string" + }, + "price": { + "type": "number", + "format": "double", + "minimum": 0 + } + }, + "xml": { + "name": "Product", + "namespace": "https://example.com/schema/product" + }, + "externalDocs": { + "description": "More about products", + "url": "https://docs.example.com/products" + } + }, + "ElectronicsProduct": { + "allOf": [ + { + "$ref": "#/components/schemas/Product" + }, + { + "type": "object", + "properties": { + "specs": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + ] + }, + "ClothingProduct": { + "allOf": [ + { + "$ref": "#/components/schemas/Product" + }, + { + "type": "object", + "properties": { + "size": { + "type": "string", + "enum": ["S", "M", "L", "XL"] + }, + "color": { + "type": "string" + } + } + } + ] + }, + "Order": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "userId": { + "type": "string" + }, + "products": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Product" + } + }, + "total": { + "type": "number" + }, + "status": { + "type": "string", + "enum": ["pending", "shipped", "delivered"] + } + } + }, + "Error": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "NotFound": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": { + "userId": { + "name": "userId", + "in": "header", + "description": "User identifier", + "schema": { + "type": "string" + } + } + }, + "examples": { + "orderExample": { + "summary": "Sample order", + "value": { + "id": "order123", + "userId": "user456", + "total": 1499.98 + } + } + }, + "requestBodies": { + "updateUser": { + "description": "User update details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + } + } + } + } + } + } + }, + "headers": { + "X-Auth-Token": { + "description": "Authentication token", + "schema": { + "type": "string" + } + } + }, + "securitySchemes": { + "apiKeyAuth": { + "type": "apiKey", + "name": "X-API-Key", + "in": "header" + }, + "basicAuth": { + "type": "http", + "scheme": "basic" + }, + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + }, + "oauth2": { + "type": "oauth2", + "flows": { + "authorizationCode": { + "authorizationUrl": "https://example.com/oauth/authorize", + "tokenUrl": "https://example.com/oauth/token", + "scopes": { + "read:products": "Read products", + "write:products": "Modify products" + } + }, + "implicit": { + "authorizationUrl": "https://example.com/oauth/authorize", + "scopes": { + "read:products": "Read products" + } + }, + "password": { + "tokenUrl": "https://example.com/oauth/token", + "scopes": { + "write:orders": "Create orders" + } + }, + "clientCredentials": { + "tokenUrl": "https://example.com/oauth/token", + "scopes": { + "read:analytics": "Read analytics" + } + } + } + }, + "openIdConnect": { + "type": "openIdConnect", + "openIdConnectUrl": "https://example.com/.well-known/openid-configuration" + }, + "mutualTls": { + "type": "mutualTLS", + "description": "Client certificate authentication" + } + }, + "links": { + "getOrderById": { + "operationId": "getOrder", + "parameters": { + "orderId": "$response.body#/id" + }, + "description": "Link to retrieve the created order" + } + }, + "callbacks": { + "paymentCallback": { + "{$request.body#/callbackUrl}": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "paymentStatus": { + "type": "string", + "enum": ["success", "failed"] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Payment callback received" + } + } + } + } + } + }, + "pathItems": { + "reusablePathItem": { + "summary": "Reusable path item for common operations", + "parameters": [ + { + "$ref": "#/components/parameters/userId" + } + ] + } + } + }, + "security": [ + { + "oauth2": ["read:products"] + } + ], + "tags": [ + { + "name": "products", + "description": "Operations related to products", + "externalDocs": { + "description": "Product documentation", + "url": "https://docs.example.com/products" + } + }, + { + "name": "orders", + "description": "Operations related to orders" + } + ], + "externalDocs": { + "description": "Full API documentation", + "url": "https://docs.example.com" + } +} diff --git a/packages/openapi_generator/src/__tests__/__mocks__/openapi/e_commerse_2.json b/packages/openapi_generator/src/__tests__/__mocks__/openapi/e_commerse_2.json new file mode 100644 index 0000000..13d6518 --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/openapi/e_commerse_2.json @@ -0,0 +1,560 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "E-Commerce Platform API", + "version": "2.0.0", + "description": "A comprehensive API for managing products, users, orders, and more on an advanced e-commerce platform. This specification showcases a wide range of OpenAPI 3.1 features, including polymorphism, webhooks, callbacks, and advanced security models.", + "termsOfService": "https://api.example-commerce.com/terms", + "contact": { + "name": "API Support Team", + "url": "https://support.example-commerce.com", + "email": "api-support@example-commerce.com" + }, + "license": { + "name": "Apache 2.0", + "identifier": "Apache-2.0" + }, + "x-version-lifecycle": "beta" + }, + "jsonSchemaDialect": "https://spec.openapis.org/oas/3.1/dialect/base", + "externalDocs": { + "description": "Find more in-depth tutorials and guides here.", + "url": "https://docs.example-commerce.com" + }, + "servers": [ + { + "url": "https://{environment}.example-commerce.com/api/v2", + "description": "Main API server", + "variables": { + "environment": { + "default": "api", + "enum": ["api", "staging-api"], + "description": "`api` for production, `staging-api` for testing." + } + } + } + ], + "tags": [ + { + "name": "Users", + "description": "Operations related to user accounts and profiles." + }, + { + "name": "Products", + "description": "Manage the product catalog, inventory, and reviews." + }, + { + "name": "Orders", + "description": "Endpoints for creating and managing customer orders." + }, + { + "name": "Webhooks", + "description": "Manage webhook subscriptions for real-time event notifications." + } + ], + "security": [ + { + "oauth2_user": ["read:profile", "read:orders"] + } + ], + "paths": { + "/products": { + "summary": "Product Collection", + "description": "Endpoint for searching and creating products.", + "get": { + "tags": ["Products"], + "operationId": "searchProducts", + "summary": "Search and filter products", + "parameters": [ + { + "name": "searchQuery", + "in": "query", + "description": "Text search across product name and description.", + "schema": { + "type": "string" + } + }, + { + "name": "tags", + "in": "query", + "description": "Filter by product tags.", + "style": "pipeDelimited", + "explode": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "default": 1, + "minimum": 1 + } + } + ], + "responses": { + "200": { + "description": "A paginated list of products.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedProductResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + } + }, + "post": { + "tags": ["Products"], + "operationId": "createProduct", + "summary": "Add a new product with an image", + "security": [ + { + "oauth2_user": ["write:products"], + "apiKey_internal": [] + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "productData": { + "$ref": "#/components/schemas/ProductInput" + }, + "productImage": { + "type": "string", + "format": "binary", + "description": "The primary product image file." + } + } + }, + "encoding": { + "productData": { + "contentType": "application/json" + }, + "productImage": { + "contentType": "image/png, image/jpeg, image/gif", + "headers": { + "X-File-Metadata": { + "description": "Custom metadata about the file.", + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Product created successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Product" + } + } + }, + "links": { + "getProductById": { + "operationId": "getProductById", + "parameters": { + "productId": "$response.body#/id" + }, + "description": "A link to the newly created product." + } + } + } + } + } + }, + "/products/{productId}": { + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "description": "The unique identifier of the product.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "get": { + "tags": ["Products"], + "operationId": "getProductById", + "summary": "Get a single product by its ID", + "responses": { + "200": { + "description": "The requested product.", + "headers": { + "ETag": { + "$ref": "#/components/headers/ETag" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Product" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/orders/{orderId}/process": { + "post": { + "tags": ["Orders"], + "operationId": "processOrder", + "summary": "Process an order asynchronously", + "description": "Begins processing an order. A callback URL is invoked upon completion.", + "parameters": [ + { + "name": "orderId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/OrderProcessingRequest" + }, + "callbacks": { + "onOrderProcessed": { + "{$request.body#/notificationUrl}": { + "post": { + "requestBody": { + "required": true, + "description": "Callback payload sent when order processing is complete.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CallbackPayload" + } + } + } + }, + "responses": { + "202": { + "description": "Your server has successfully acknowledged the callback." + } + } + } + } + } + }, + "responses": { + "202": { + "description": "Order processing has been initiated." + } + } + } + } + }, + "webhooks": { + "inventoryUpdate": { + "summary": "Inventory Level Update", + "post": { + "operationId": "inventoryUpdateWebhook", + "requestBody": { + "description": "Notification sent when a product's inventory level changes.", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InventoryUpdatePayload" + } + } + } + }, + "responses": { + "200": { + "description": "The event has been successfully received." + } + } + } + } + }, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "readOnly": true + }, + "username": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]{3,16}$" + }, + "email": { + "type": "string", + "format": "email" + }, + "profile": { + "type": "object", + "properties": { + "fullName": { + "type": "string" + }, + "joinDate": { + "type": "string", + "format": "date-time", + "readOnly": true + } + } + }, + "legacyId": { + "type": "integer", + "deprecated": true + } + }, + "required": ["id", "username", "email"] + }, + "ProductInput": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Quantum Fusion Laptop" + }, + "description": { + "type": "string" + }, + "price": { + "type": "number", + "format": "double", + "minimum": 0 + } + }, + "required": ["name", "price"] + }, + "Product": { + "allOf": [ + { + "$ref": "#/components/schemas/ProductInput" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "readOnly": true + }, + "imageUrl": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "stock": { + "type": "integer", + "writeOnly": true + } + } + } + ] + }, + "PaginatedResponse": { + "$id": "generic_paginated_response", + "type": "object", + "properties": { + "page": { "type": "integer" }, + "pageSize": { "type": "integer" }, + "total": { "type": "integer" }, + "items": { + "type": "array", + "items": { + "$dynamicRef": "#item" + } + } + }, + "$defs": { + "genericItem": { + "$dynamicAnchor": "item" + } + } + }, + "PaginatedProductResponse": { + "$ref": "#/components/schemas/PaginatedResponse", + "$defs": { + "productItem": { + "$dynamicAnchor": "item", + "$ref": "#/components/schemas/Product" + } + } + }, + "PaymentMethod": { + "oneOf": [ + { "$ref": "#/components/schemas/CreditCard" }, + { "$ref": "#/components/schemas/PayPal" } + ], + "discriminator": { + "propertyName": "methodType", + "mapping": { + "card": "#/components/schemas/CreditCard", + "paypal_account": "#/components/schemas/PayPal" + } + } + }, + "CreditCard": { + "type": "object", + "properties": { + "methodType": { "type": "string", "enum": ["card"] }, + "cardNumber": { "type": "string" }, + "expiry": { "type": "string" }, + "cvv": { "type": "string", "writeOnly": true } + }, + "required": ["methodType", "cardNumber"] + }, + "PayPal": { + "type": "object", + "properties": { + "methodType": { + "type": "string", + "enum": ["paypal_account"] + }, + "email": { "type": "string", "format": "email" } + }, + "required": ["methodType", "email"] + }, + "CallbackPayload": { + "type": "object", + "properties": { + "orderId": { "type": "string", "format": "uuid" }, + "status": { + "type": "string", + "enum": ["PROCESSED", "FAILED"] + }, + "detail": { "type": "string" } + } + }, + "InventoryUpdatePayload": { + "type": "object", + "properties": { + "productId": { "type": "string", "format": "uuid" }, + "newStockLevel": { "type": "integer" }, + "timestamp": { "type": "string", "format": "date-time" } + }, + "xml": { + "name": "InventoryEvent" + } + }, + "ApiError": { + "type": "object", + "properties": { + "errorCode": { "type": "string" }, + "message": { "type": "string" } + } + } + }, + "responses": { + "NotFound": { + "description": "The specified resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "BadRequest": { + "description": "Invalid input provided.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "requestBodies": { + "OrderProcessingRequest": { + "description": "Details for initiating order processing.", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "notificationUrl": { + "type": "string", + "format": "uri", + "description": "The URL to send a callback to upon completion." + } + }, + "required": ["notificationUrl"] + }, + "examples": { + "standard": { + "summary": "A standard notification endpoint.", + "value": { + "notificationUrl": "https://client.example.com/order-callback" + } + } + } + } + } + } + }, + "headers": { + "ETag": { + "description": "An identifier for a specific version of a resource.", + "schema": { + "type": "string" + } + } + }, + "securitySchemes": { + "oauth2_user": { + "type": "oauth2", + "description": "OAuth2 for end-user authentication.", + "flows": { + "authorizationCode": { + "authorizationUrl": "https://auth.example-commerce.com/oauth/authorize", + "tokenUrl": "https://auth.example-commerce.com/oauth/token", + "scopes": { + "read:profile": "Read user profile", + "write:profile": "Modify user profile", + "read:orders": "Read user's orders", + "write:orders": "Create orders for user", + "write:products": "Create or update products" + } + } + } + }, + "apiKey_internal": { + "type": "apiKey", + "name": "X-Internal-API-Key", + "in": "header", + "description": "API Key for internal, machine-to-machine services." + } + } + } +} diff --git a/packages/openapi_generator/src/__tests__/__mocks__/openapi/hell.json b/packages/openapi_generator/src/__tests__/__mocks__/openapi/hell.json new file mode 100644 index 0000000..d920949 --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/openapi/hell.json @@ -0,0 +1,110 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Hell API", + "version": "6.6.6" + }, + "paths": {}, + "components": { + "schemas": { + "HellObject": { + "type": "object", + "required": ["required-key", "spaced key"], + "properties": { + "required-key": { + "type": "string" + }, + "optional-key": { + "type": "string" + }, + "spaced key": { + "type": "number" + }, + "$special$": { + "type": "boolean" + } + } + }, + "DeepNested": { + "type": "object", + "properties": { + "level1": { + "type": "object", + "properties": { + "level2": { + "type": "array", + "items": { + "type": "object", + "properties": { + "level3": { + "type": "string", + "enum": ["deep"] + } + } + } + } + } + } + } + }, + "IntersectionHell": { + "allOf": [ + { + "$ref": "#/components/schemas/HellObject" + }, + { + "type": "object", + "properties": { + "extra": { + "type": "string" + } + } + } + ] + }, + "UnionHell": { + "oneOf": [ + { + "$ref": "#/components/schemas/OptionA" + }, + { + "$ref": "#/components/schemas/OptionB" + } + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "A": "#/components/schemas/OptionA", + "B": "#/components/schemas/OptionB" + } + } + }, + "OptionA": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["A"] + }, + "a": { + "type": "string" + } + } + }, + "OptionB": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["B"] + }, + "b": { + "type": "number" + } + } + } + } + } +} diff --git a/packages/openapi_generator/src/__tests__/__mocks__/openapi/hell_2.json b/packages/openapi_generator/src/__tests__/__mocks__/openapi/hell_2.json new file mode 100644 index 0000000..2d880df --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/openapi/hell_2.json @@ -0,0 +1,44 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Hell Vol 2 API", + "version": "1.0.0" + }, + "paths": {}, + "components": { + "schemas": { + "StringDictionary": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "ObjectWithCatchall": { + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "string" } + }, + "additionalProperties": { + "type": "integer" + } + }, + "SimpleUnion": { + "oneOf": [{ "type": "string" }, { "type": "boolean" }] + }, + "AnyOfUnion": { + "anyOf": [ + { "$ref": "#/components/schemas/StringDictionary" }, + { "type": "number" } + ] + }, + "ConstString": { + "type": "string", + "const": "FIXED_VALUE" + }, + "NullableString": { + "type": ["string", "null"] + } + } + } +} diff --git a/packages/openapi_generator/src/__tests__/__mocks__/openapi/simple.json b/packages/openapi_generator/src/__tests__/__mocks__/openapi/simple.json new file mode 100644 index 0000000..0a4587b --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/openapi/simple.json @@ -0,0 +1,128 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Simple Books API", + "version": "1.0.0" + }, + "paths": { + "/books": { + "get": { + "summary": "Get a list of books", + "responses": { + "200": { + "description": "A list of books", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Book" + } + } + } + } + } + } + }, + "post": { + "summary": "Create a new book", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookRequest" + } + } + } + }, + "responses": { + "201": { + "description": "The created book", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Book" + } + } + } + } + } + } + }, + "/books/{id}": { + "get": { + "summary": "Get a book by ID", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "A single book", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Book" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Book": { + "type": "object", + "properties": { + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "price": { + "type": "number" + }, + "category": { + "type": "string" + }, + "publish_date": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "uuid", + "name", + "price", + "category", + "publish_date" + ] + }, + "BookRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "price": { + "type": "number" + }, + "category": { + "type": "string" + } + }, + "required": ["name", "price", "category"] + } + } + } +} diff --git a/packages/openapi_generator/src/__tests__/__mocks__/openapi/youtube.json b/packages/openapi_generator/src/__tests__/__mocks__/openapi/youtube.json new file mode 100644 index 0000000..22a181d --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/openapi/youtube.json @@ -0,0 +1,521 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "YouTube Data API v3", + "version": "v3", + "description": "Supports core YouTube features, such as uploading videos, creating and managing playlists, searching for content, and much more.", + "termsOfService": "https://developers.google.com/youtube/terms/api-services-terms-of-service", + "contact": { + "name": "Google", + "url": "https://google.com" + }, + "license": { + "name": "Creative Commons Attribution 3.0", + "url": "http://creativecommons.org/licenses/by/3.0/" + } + }, + "servers": [ + { + "url": "https://youtube.googleapis.com/", + "description": "Production Server" + } + ], + "paths": { + "/youtube/v3/videos": { + "get": { + "tags": ["Videos"], + "summary": "Retrieves a list of resources, possibly filtered.", + "description": "Returns a list of videos that match the API request parameters.", + "operationId": "youtube.videos.list", + "parameters": [ + { + "name": "part", + "in": "query", + "description": "The part parameter specifies a comma-separated list of one or more video resource properties that the API response will include.", + "required": true, + "style": "form", + "explode": false, + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "contentDetails", + "fileDetails", + "id", + "liveStreamingDetails", + "localizations", + "player", + "processingDetails", + "recordingDetails", + "snippet", + "statistics", + "status", + "suggestions", + "topicDetails" + ] + } + } + }, + { + "name": "id", + "in": "query", + "description": "Return videos with the given IDs.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "chart", + "in": "query", + "description": "Return the most popular videos for the specified content region and video category.", + "schema": { + "type": "string", + "enum": ["chartUnspecified", "mostPopular"] + } + }, + { + "name": "maxResults", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 5 + } + }, + { + "name": "pageToken", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VideoListResponse" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "Oauth2": [ + "https://www.googleapis.com/auth/youtube", + "https://www.googleapis.com/auth/youtube.readonly" + ] + } + ] + }, + "post": { + "tags": ["Videos"], + "summary": "Uploads a video to YouTube.", + "operationId": "youtube.videos.insert", + "parameters": [ + { + "name": "part", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": ["snippet", "status", "player"] + } + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Video" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Video uploaded successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Video" + } + } + } + } + }, + "security": [ + { + "Oauth2": [ + "https://www.googleapis.com/auth/youtube.upload", + "https://www.googleapis.com/auth/youtube.force-ssl" + ] + } + ] + } + } + }, + "components": { + "schemas": { + "VideoListResponse": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "default": "youtube#videoListResponse" + }, + "etag": { + "type": "string" + }, + "nextPageToken": { + "type": "string" + }, + "prevPageToken": { + "type": "string" + }, + "pageInfo": { + "$ref": "#/components/schemas/PageInfo" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Video" + } + } + } + }, + "PageInfo": { + "type": "object", + "properties": { + "totalResults": { + "type": "integer" + }, + "resultsPerPage": { + "type": "integer" + } + } + }, + "Video": { + "type": "object", + "description": "A video resource represents a YouTube video.", + "properties": { + "kind": { + "type": "string", + "default": "youtube#video" + }, + "etag": { + "type": "string" + }, + "id": { + "type": "string" + }, + "snippet": { + "$ref": "#/components/schemas/VideoSnippet" + }, + "contentDetails": { + "$ref": "#/components/schemas/VideoContentDetails" + }, + "status": { + "$ref": "#/components/schemas/VideoStatus" + }, + "statistics": { + "$ref": "#/components/schemas/VideoStatistics" + }, + "player": { + "$ref": "#/components/schemas/VideoPlayer" + } + } + }, + "VideoSnippet": { + "type": "object", + "properties": { + "publishedAt": { + "type": "string", + "format": "date-time" + }, + "channelId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "thumbnails": { + "$ref": "#/components/schemas/ThumbnailDetails" + }, + "channelTitle": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "categoryId": { + "type": "string" + }, + "liveBroadcastContent": { + "type": "string", + "enum": ["none", "upcoming", "live", "completed"] + }, + "localized": { + "$ref": "#/components/schemas/VideoLocalization" + } + } + }, + "ThumbnailDetails": { + "type": "object", + "properties": { + "default": { + "$ref": "#/components/schemas/Thumbnail" + }, + "medium": { + "$ref": "#/components/schemas/Thumbnail" + }, + "high": { + "$ref": "#/components/schemas/Thumbnail" + }, + "standard": { + "$ref": "#/components/schemas/Thumbnail" + }, + "maxres": { + "$ref": "#/components/schemas/Thumbnail" + } + } + }, + "Thumbnail": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "width": { + "type": "integer" + }, + "height": { + "type": "integer" + } + } + }, + "VideoStatus": { + "type": "object", + "properties": { + "uploadStatus": { + "type": "string", + "enum": [ + "deleted", + "failed", + "processed", + "rejected", + "uploaded" + ] + }, + "failureReason": { + "type": "string", + "enum": [ + "codec", + "conversion", + "emptyFile", + "invalidFile", + "tooSmall", + "uploadAborted" + ] + }, + "rejectionReason": { + "type": "string", + "enum": [ + "claim", + "copyright", + "duplicate", + "inappropriate", + "legal", + "length", + "termsOfUse", + "trademark", + "uploaderAccountClosed", + "uploaderAccountSuspended" + ] + }, + "privacyStatus": { + "type": "string", + "enum": ["private", "public", "unlisted"] + }, + "license": { + "type": "string", + "enum": ["creativeCommon", "youtube"] + }, + "embeddable": { + "type": "boolean" + }, + "publicStatsViewable": { + "type": "boolean" + }, + "madeForKids": { + "type": "boolean" + } + } + }, + "VideoStatistics": { + "type": "object", + "properties": { + "viewCount": { + "type": "string", + "description": "Type is string because view counts can exceed 2^32." + }, + "likeCount": { + "type": "string" + }, + "dislikeCount": { + "type": "string" + }, + "favoriteCount": { + "type": "string" + }, + "commentCount": { + "type": "string" + } + } + }, + "VideoPlayer": { + "type": "object", + "properties": { + "embedHtml": { + "type": "string" + }, + "embedHeight": { + "type": "integer" + }, + "embedWidth": { + "type": "integer" + } + } + }, + "VideoContentDetails": { + "type": "object", + "properties": { + "duration": { + "type": "string", + "pattern": "^PT[0-9]+[M|H|S]$" + }, + "dimension": { + "type": "string" + }, + "definition": { + "type": "string", + "enum": ["hd", "sd"] + }, + "caption": { + "type": "string", + "enum": ["false", "true"] + }, + "licensedContent": { + "type": "boolean" + }, + "regionRestriction": { + "type": "object", + "properties": { + "allowed": { + "type": "array", + "items": { + "type": "string" + } + }, + "blocked": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "VideoLocalization": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, + "ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "securitySchemes": { + "Oauth2": { + "type": "oauth2", + "flows": { + "authorizationCode": { + "authorizationUrl": "https://accounts.google.com/o/oauth2/auth", + "tokenUrl": "https://accounts.google.com/o/oauth2/token", + "scopes": { + "https://www.googleapis.com/auth/youtube": "Manage your YouTube account", + "https://www.googleapis.com/auth/youtube.readonly": "View your YouTube account", + "https://www.googleapis.com/auth/youtube.upload": "Manage your YouTube videos", + "https://www.googleapis.com/auth/youtubepartner-channel-audit": "View private information of your channel relevant during the audit process with a YouTube partner" + } + } + } + } + } + } +} diff --git a/packages/openapi_generator/src/__tests__/__mocks__/target_model/ecommerse_1.model.ts b/packages/openapi_generator/src/__tests__/__mocks__/target_model/ecommerse_1.model.ts new file mode 100644 index 0000000..e38cb47 --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/target_model/ecommerse_1.model.ts @@ -0,0 +1,45 @@ +import { z } from 'zod' + +export const Product = z.object({ + id: z.uuid(), + name: z.string(), + productType: z.string(), + price: z.number().min(0), +}) + +export type ProductModel = z.infer + +export const ElectronicsProduct = Product.and( + z.object({ + specs: z.record(z.string(), z.any()).optional(), + }) +) + +export type ElectronicsProductModel = z.infer + +export const ClothingProduct = Product.and( + z.object({ + size: z.enum(['S', 'M', 'L', 'XL']).optional(), + color: z.string().optional(), + }) +) + +export type ClothingProductModel = z.infer + +export const Order = z.object({ + id: z.uuid().optional(), + userId: z.string().optional(), + products: z.array(Product).optional(), + total: z.number().optional(), + status: z.enum(['pending', 'shipped', 'delivered']).optional(), +}) + +export type OrderModel = z.infer + +// biome-ignore lint/suspicious/noShadowRestrictedNames: +export const Error = z.object({ + code: z.number().int().optional(), + message: z.string().optional(), +}) + +export type ErrorModel = z.infer diff --git a/packages/openapi_generator/src/__tests__/__mocks__/target_model/ecommerse_2.model.ts b/packages/openapi_generator/src/__tests__/__mocks__/target_model/ecommerse_2.model.ts new file mode 100644 index 0000000..95cf15f --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/target_model/ecommerse_2.model.ts @@ -0,0 +1,100 @@ +import { z } from 'zod' + +export const User = z.object({ + id: z.uuid(), + username: z.string().regex(/^[a-zA-Z0-9_-]{3,16}$/), + email: z.email(), + profile: z + .object({ + fullName: z.string().optional(), + joinDate: z.iso.datetime().optional(), + }) + .optional(), + legacyId: z.number().int().optional(), +}) + +export type UserModel = z.infer + +export const ProductInput = z.object({ + name: z.string(), + description: z.string().optional(), + price: z.number().min(0), +}) + +export type ProductInputModel = z.infer + +export const Product = ProductInput.and( + z.object({ + id: z.uuid().optional(), + imageUrl: z.url().optional(), + stock: z.number().int().optional(), + }) +) + +export type ProductModel = z.infer + +export const PaginatedResponse = z.object({ + page: z.number().int().optional(), + pageSize: z.number().int().optional(), + total: z.number().int().optional(), + items: z.array(z.any()).optional(), +}) + +export type PaginatedResponseModel = z.infer + +export const PaginatedProductResponse = z.object({ + page: z.number().int().optional(), + pageSize: z.number().int().optional(), + total: z.number().int().optional(), + items: z.array(Product).optional(), +}) + +export type PaginatedProductResponseModel = z.infer< + typeof PaginatedProductResponse +> + +export const CreditCard = z.object({ + methodType: z.enum(['card']), + cardNumber: z.string(), + expiry: z.string().optional(), + cvv: z.string().optional(), +}) + +export type CreditCardModel = z.infer + +export const PayPal = z.object({ + methodType: z.enum(['paypal_account']), + email: z.email(), +}) + +export type PayPalModel = z.infer + +export const PaymentMethod = z.discriminatedUnion('methodType', [ + CreditCard, + PayPal, +]) + +export type PaymentMethodModel = z.infer + +export const CallbackPayload = z.object({ + orderId: z.uuid().optional(), + status: z.enum(['PROCESSED', 'FAILED']).optional(), + detail: z.string().optional(), +}) + +export type CallbackPayloadModel = z.infer + +export const InventoryUpdatePayload = z.object({ + productId: z.uuid().optional(), + newStockLevel: z.number().int().optional(), + timestamp: z.iso.datetime().optional(), +}) + +export type InventoryUpdatePayloadModel = z.infer + +export const ApiError = z.object({ + errorCode: z.string().optional(), + message: z.string().optional(), +}) + +export type ApiErrorModel = z.infer diff --git a/packages/openapi_generator/src/__tests__/__mocks__/target_model/hell.model.ts b/packages/openapi_generator/src/__tests__/__mocks__/target_model/hell.model.ts new file mode 100644 index 0000000..cad7723 --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/target_model/hell.model.ts @@ -0,0 +1,52 @@ +import { z } from 'zod' + +export const HellObject = z.object({ + 'required-key': z.string(), + 'optional-key': z.string().optional(), + 'spaced key': z.number(), + $special$: z.boolean().optional(), +}) + +export type HellObjectModel = z.infer + +export const DeepNested = z.object({ + level1: z + .object({ + level2: z + .array( + z.object({ + level3: z.enum(['deep']).optional(), + }) + ) + .optional(), + }) + .optional(), +}) + +export type DeepNestedModel = z.infer + +export const IntersectionHell = HellObject.and( + z.object({ + extra: z.string().optional(), + }) +) + +export type IntersectionHellModel = z.infer + +export const OptionA = z.object({ + type: z.enum(['A']), + a: z.string().optional(), +}) + +export type OptionAModel = z.infer + +export const OptionB = z.object({ + type: z.enum(['B']), + b: z.number().optional(), +}) + +export type OptionBModel = z.infer + +export const UnionHell = z.discriminatedUnion('type', [OptionA, OptionB]) + +export type UnionHellModel = z.infer diff --git a/packages/openapi_generator/src/__tests__/__mocks__/target_model/hell_2.model.ts b/packages/openapi_generator/src/__tests__/__mocks__/target_model/hell_2.model.ts new file mode 100644 index 0000000..be6d694 --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/target_model/hell_2.model.ts @@ -0,0 +1,29 @@ +import { z } from 'zod' + +export const StringDictionary = z.record(z.string(), z.any()) + +export type StringDictionaryModel = z.infer + +export const ObjectWithCatchall = z + .object({ + id: z.string(), + }) + .catchall(z.number().int()) + +export type ObjectWithCatchallModel = z.infer + +export const SimpleUnion = z.union([z.string(), z.boolean()]) + +export type SimpleUnionModel = z.infer + +export const AnyOfUnion = z.union([StringDictionary, z.number()]) + +export type AnyOfUnionModel = z.infer + +export const ConstString = z.literal('FIXED_VALUE') + +export type ConstStringModel = z.infer + +export const NullableString = z.string().nullable() + +export type NullableStringModel = z.infer diff --git a/packages/openapi_generator/src/__tests__/__mocks__/target_model/simple.model.ts b/packages/openapi_generator/src/__tests__/__mocks__/target_model/simple.model.ts new file mode 100644 index 0000000..7403093 --- /dev/null +++ b/packages/openapi_generator/src/__tests__/__mocks__/target_model/simple.model.ts @@ -0,0 +1,19 @@ +import { z } from 'zod' + +export const Book = z.object({ + uuid: z.string(), + name: z.string(), + price: z.number(), + category: z.string(), + publish_date: z.iso.datetime(), +}) + +export type BookModel = z.infer + +export const BookRequest = z.object({ + name: z.string(), + price: z.number(), + category: z.string(), +}) + +export type BookRequestModel = z.infer diff --git a/packages/openapi_generator/src/__tests__/errors.test.ts b/packages/openapi_generator/src/__tests__/errors.test.ts new file mode 100644 index 0000000..44cccc3 --- /dev/null +++ b/packages/openapi_generator/src/__tests__/errors.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest' +import { + ConfigurationError, + FileSystemError, + SchemaValidationError, + SpecParsingError, +} from '../errors' + +describe('GeneratorError classes', () => { + describe('SpecParsingError', () => { + it('should format error with file path only', () => { + const error = new SpecParsingError( + 'Invalid OpenAPI version', + '/path/to/spec.yaml' + ) + const formatted = error.format() + + expect(formatted).toContain('Error parsing OpenAPI specification') + expect(formatted).toContain('/path/to/spec.yaml') + expect(formatted).toContain('Invalid OpenAPI version') + }) + + it('should format error with line and column', () => { + const error = new SpecParsingError( + 'Unexpected token', + '/path/to/spec.yaml', + 42, + 15 + ) + const formatted = error.format() + + expect(formatted).toContain('/path/to/spec.yaml:42:15') + expect(formatted).toContain('Unexpected token') + }) + + it('should include suggestion when provided', () => { + const error = new SpecParsingError( + 'Missing required field', + '/path/to/spec.yaml', + undefined, + undefined, + 'Add the "info" section to your spec' + ) + const formatted = error.format() + + expect(formatted).toContain('๐Ÿ’ก Suggestion:') + expect(formatted).toContain('Add the "info" section') + }) + + it('should format error with line but no column', () => { + const error = new SpecParsingError( + 'Indentation error', + '/path/to/spec.yaml', + 10 + ) + const formatted = error.format() + + expect(formatted).toContain('/path/to/spec.yaml:10') + expect(formatted).not.toContain(':10:') + }) + }) + + describe('SchemaValidationError', () => { + it('should format error with schema context', () => { + const error = new SchemaValidationError( + 'Type mismatch', + 'UserSchema', + '#/components/schemas/User' + ) + const formatted = error.format() + + expect(formatted).toContain('Invalid Schema') + expect(formatted).toContain('UserSchema') + expect(formatted).toContain('#/components/schemas/User') + expect(formatted).toContain('Type mismatch') + }) + + it('should include suggestion when provided', () => { + const error = new SchemaValidationError( + 'Missing type field', + 'UserSchema', + '#/components/schemas/User', + 'Add a "type" field to the schema' + ) + const formatted = error.format() + + expect(formatted).toContain('๐Ÿ’ก Suggestion:') + expect(formatted).toContain('Add a "type" field') + }) + }) + + describe('FileSystemError', () => { + it('should format read error', () => { + const error = new FileSystemError( + 'File not found', + 'read', + '/path/to/file.json' + ) + const formatted = error.format() + + expect(formatted).toContain('File System Error (read)') + expect(formatted).toContain('/path/to/file.json') + expect(formatted).toContain('File not found') + }) + + it('should format write error', () => { + const error = new FileSystemError( + 'Permission denied', + 'write', + '/path/to/output.ts' + ) + const formatted = error.format() + + expect(formatted).toContain('File System Error (write)') + expect(formatted).toContain('Permission denied') + }) + }) + + describe('ConfigurationError', () => { + it('should format error with option and value', () => { + const error = new ConfigurationError( + 'Invalid output path', + 'output', + '/invalid/path', + 'Provide a valid directory path' + ) + const formatted = error.format() + + expect(formatted).toContain('Configuration Error') + expect(formatted).toContain('--output') + expect(formatted).toContain('/invalid/path') + expect(formatted).toContain('Invalid output path') + expect(formatted).toContain('๐Ÿ’ก Suggestion:') + }) + + it('should format error without option', () => { + const error = new ConfigurationError('Missing required arguments') + const formatted = error.format() + + expect(formatted).toContain('Configuration Error') + expect(formatted).toContain('Missing required arguments') + expect(formatted).not.toContain('Option:') + }) + + it('should format error with option but no value', () => { + const error = new ConfigurationError( + 'Option is required', + 'input', + undefined, + 'Provide the path to your OpenAPI spec' + ) + const formatted = error.format() + + expect(formatted).toContain('--input') + expect(formatted).not.toContain('Provided:') + expect(formatted).toContain('๐Ÿ’ก Suggestion:') + }) + }) + + describe('Error instanceof checks', () => { + it('should be instances of Error', () => { + const specError = new SpecParsingError('test', '/path/to/spec.yaml') + const schemaError = new SchemaValidationError( + 'test', + 'Schema', + 'path' + ) + const fsError = new FileSystemError('test', 'read', '/path') + const configError = new ConfigurationError('test') + + expect(specError).toBeInstanceOf(Error) + expect(schemaError).toBeInstanceOf(Error) + expect(fsError).toBeInstanceOf(Error) + expect(configError).toBeInstanceOf(Error) + }) + + it('should capture stack traces', () => { + const error = new SpecParsingError('test', '/path/to/spec.yaml') + + expect(error.stack).toBeDefined() + expect(error.stack).toContain('SpecParsingError') + }) + }) +}) diff --git a/packages/openapi_generator/src/__tests__/integration.test.ts b/packages/openapi_generator/src/__tests__/integration.test.ts new file mode 100644 index 0000000..23eac7b --- /dev/null +++ b/packages/openapi_generator/src/__tests__/integration.test.ts @@ -0,0 +1,55 @@ +import { existsSync, readdirSync, readFileSync, statSync } from 'fs-extra' +import { join, resolve } from 'path' +import { describe, expect, it } from 'vitest' +import { parseOpenApiSpec, parsePaths } from '../path_parser' +import { generateRouter } from '../router_generator' +import { SchemaGenerator } from '../schema_generator' + +describe('Generator', () => { + const mocksDir = resolve(__dirname, '__mocks__') + const genDir = join(mocksDir, '.gen') + const openapiDir = join(mocksDir, 'openapi') + + const testCases = readdirSync(genDir).filter((file) => { + return statSync(join(genDir, file)).isDirectory() + }) + + testCases.forEach((caseName) => { + const specFileName = `${caseName}.json` + const specPath = join(openapiDir, specFileName) + + if (!existsSync(specPath)) { + console.warn( + `Skipping test case ${caseName}: Spec file ${specFileName} not found.` + ) + return + } + + describe(caseName, () => { + it('should generate the same models as the snapshot', async () => { + const spec = await parseOpenApiSpec(specPath) + const modelGenerator = new SchemaGenerator(spec) + + const generatedModels = modelGenerator.generateModels() + + const snapshot = readFileSync( + join(genDir, caseName, 'models.ts'), + 'utf-8' + ) + expect(generatedModels).toEqual(snapshot) + }) + + it('should generate the same api as the snapshot', async () => { + const spec = await parseOpenApiSpec(specPath) + const parsedPaths = parsePaths(spec) + const generatedRouter = generateRouter(parsedPaths, spec) + + const snapshot = readFileSync( + join(genDir, caseName, 'api.ts'), + 'utf-8' + ) + expect(generatedRouter).toEqual(snapshot) + }) + }) + }) +}) diff --git a/packages/openapi_generator/src/__tests__/path_parser.test.ts b/packages/openapi_generator/src/__tests__/path_parser.test.ts new file mode 100644 index 0000000..38ec59c --- /dev/null +++ b/packages/openapi_generator/src/__tests__/path_parser.test.ts @@ -0,0 +1,436 @@ +import { existsSync, unlinkSync, writeFileSync } from 'fs-extra' +import type { OpenAPIV3_1 } from 'openapi-types' +import { tmpdir } from 'os' +import { basename, join } from 'path' +import { describe, expect, it } from 'vitest' +import { SpecParsingError } from '../errors' +import { parseOpenApiSpec, parsePaths } from '../path_parser' + +describe('path_parser', () => { + describe('parseOpenApiSpec', () => { + it('should parse a valid OpenAPI JSON file', async () => { + const tempFile = join(tmpdir(), 'test-spec.json') + const spec = { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: {}, + } + + writeFileSync(tempFile, JSON.stringify(spec)) + + try { + const result = await parseOpenApiSpec(tempFile) + expect(result.openapi).toBe('3.0.0') + expect(result.info.title).toBe('Test') + expect(result.paths).toBeDefined() + } finally { + unlinkSync(tempFile) + } + }) + + it('should parse a valid OpenAPI YAML file', async () => { + const tempFile = join(tmpdir(), 'test-spec.yaml') + const yamlContent = ` +openapi: 3.0.0 +info: + title: Test API + version: 1.0.0 +paths: + /users: + get: + responses: + '200': + description: Success + ` + + writeFileSync(tempFile, yamlContent) + + try { + const result = await parseOpenApiSpec(tempFile) + expect(result.openapi).toBe('3.0.0') + expect(result.info.title).toBe('Test API') + expect(result.paths?.['/users']).toBeDefined() + } finally { + unlinkSync(tempFile) + } + }) + + it('should parse YAML file with .yml extension', async () => { + const tempFile = join(tmpdir(), 'test-spec.yml') + const yamlContent = ` +openapi: 3.0.0 +info: + title: YML Test + version: 1.0.0 +paths: {} + ` + + writeFileSync(tempFile, yamlContent) + + try { + const result = await parseOpenApiSpec(tempFile) + expect(result.info.title).toBe('YML Test') + } finally { + unlinkSync(tempFile) + } + }) + + it('should resolve internal $ref in YAML', async () => { + const tempFile = join(tmpdir(), 'test-with-ref.yaml') + const yamlContent = ` +openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + User: + type: object + properties: + id: + type: string + name: + type: string +paths: + /users: + get: + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/User' + ` + + writeFileSync(tempFile, yamlContent) + + try { + const result = await parseOpenApiSpec(tempFile) + // Should be bundled - $ref should remain pointing to internal component + const response = + result.paths?.['/users']?.get?.responses?.['200'] + const schema = (response as any)?.content?.['application/json'] + ?.schema + expect(schema).toBeDefined() + expect(schema.$ref).toBe('#/components/schemas/User') + // Verify the component exists + expect(result.components?.schemas?.['User']).toBeDefined() + } finally { + unlinkSync(tempFile) + } + }) + + it('should resolve external $ref across YAML files', async () => { + const uniqueId = Math.random().toString(36).substring(7) + const schemasFile = join(tmpdir(), `schemas-${uniqueId}.yaml`) + const apiFile = join(tmpdir(), `api-${uniqueId}.yaml`) + + // Create external schemas file + const schemasContent = ` +components: + schemas: + Product: + type: object + properties: + id: + type: string + name: + type: string + ` + + // Create API file with external ref + const apiContent = ` +openapi: 3.0.0 +info: + title: API with External Refs + version: 1.0.0 +paths: + /products: + get: + responses: + '200': + content: + application/json: + schema: + $ref: '${basename(schemasFile)}#/components/schemas/Product' + ` + + writeFileSync(schemasFile, schemasContent) + writeFileSync(apiFile, apiContent) + + try { + const result = await parseOpenApiSpec(apiFile) + const response = + result.paths?.['/products']?.get?.responses?.['200'] + const schema = (response as any)?.content?.['application/json'] + ?.schema + + // External ref should be bundled. + // In this simple case, swagger-parser inlines it. + expect(schema).toBeDefined() + if ((schema as any).$ref) { + // If it's a ref, verify it points to a component + const refName = (schema as any).$ref.split('/').pop()! + const component = result.components?.schemas?.[ + refName + ] as any + expect(component).toBeDefined() + expect(component.properties.id).toBeDefined() + expect(component.properties.name).toBeDefined() + } else { + // If it's inlined, verify the properties + expect(schema.type).toBe('object') + expect(schema.properties.id).toBeDefined() + expect(schema.properties.name).toBeDefined() + } + } finally { + if (existsSync(schemasFile)) unlinkSync(schemasFile) + if (existsSync(apiFile)) unlinkSync(apiFile) + } + }) + + it('should throw error for invalid YAML syntax', async () => { + const tempFile = join(tmpdir(), 'invalid.yaml') + const invalidYaml = ` +openapi: 3.0.0 +info: + title: "Unclosed string + version: 1.0.0 + ` + + writeFileSync(tempFile, invalidYaml) + + try { + await expect(parseOpenApiSpec(tempFile)).rejects.toThrow( + SpecParsingError + ) + } finally { + unlinkSync(tempFile) + } + }) + + it('should throw error for missing file', async () => { + const nonExistentFile = join(tmpdir(), 'does-not-exist.yaml') + + await expect(parseOpenApiSpec(nonExistentFile)).rejects.toThrow( + SpecParsingError + ) + }) + + it('should throw error for invalid external $ref', async () => { + const apiFile = join(tmpdir(), 'api-bad-ref.yaml') + const apiContent = ` +openapi: 3.0.0 +info: + title: Bad Ref + version: 1.0.0 +paths: + /test: + get: + responses: + '200': + content: + application/json: + schema: + $ref: 'missing-file.yaml#/components/schemas/Missing' + ` + + writeFileSync(apiFile, apiContent) + + try { + await expect(parseOpenApiSpec(apiFile)).rejects.toThrow( + SpecParsingError + ) + } finally { + unlinkSync(apiFile) + } + }) + }) + + describe('parsePaths', () => { + it('should return empty object when spec has no paths', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: undefined as any, + } + + const result = parsePaths(spec) + expect(result).toEqual({}) + }) + + it('should skip null or undefined pathItems', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/users': null as any, + '/posts': { get: { responses: {} } }, + }, + } + + const result = parsePaths(spec) + expect(result.users).toBeUndefined() + expect(result.posts).toBeDefined() + }) + + it('should parse simple paths', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/users': { + get: { responses: {} }, + }, + }, + } + + const result = parsePaths(spec) + expect(result.users).toBeDefined() + expect(result.users.get).toBeDefined() + }) + + it('should parse nested paths', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/users/profile': { + get: { responses: {} }, + }, + }, + } + + const result = parsePaths(spec) + expect(result.users.profile.get).toBeDefined() + }) + + it('should convert path parameters to $ prefix', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/users/{userId}': { + get: { responses: {} }, + }, + }, + } + + const result = parsePaths(spec) + expect(result.users.$userId).toBeDefined() + expect(result.users.$userId.get).toBeDefined() + }) + + it('should handle multiple dynamic segments', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/users/{userId}/posts/{postId}': { + get: { responses: {} }, + }, + }, + } + + const result = parsePaths(spec) + expect(result.users.$userId.posts.$postId.get).toBeDefined() + }) + + it('should merge multiple operations on the same path', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/users': { + get: { responses: {} }, + post: { responses: {} }, + }, + }, + } + + const result = parsePaths(spec) + expect(result.users.get).toBeDefined() + expect(result.users.post).toBeDefined() + }) + + it('should handle paths with leading slash only', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/': { + get: { responses: {} }, + }, + }, + } + + const result = parsePaths(spec) + // Path "/" after split and filter becomes empty array, so result should be empty + expect(Object.keys(result).length).toBe(0) + }) + + it('should handle paths with trailing slashes', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/users/': { + get: { responses: {} }, + }, + }, + } + + const result = parsePaths(spec) + expect(result.users.get).toBeDefined() + }) + + it('should handle empty paths object', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: {}, + } + + const result = parsePaths(spec) + expect(result).toEqual({}) + }) + + it('should handle complex nested structure with mixed static and dynamic paths', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/api/v1/users/{userId}/posts/{postId}/comments': { + get: { responses: {} }, + post: { responses: {} }, + }, + }, + } + + const result = parsePaths(spec) + expect( + result.api.v1.users.$userId.posts.$postId.comments.get + ).toBeDefined() + expect( + result.api.v1.users.$userId.posts.$postId.comments.post + ).toBeDefined() + }) + + it('should preserve path-level parameters and metadata', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/users': { + parameters: [{ name: 'test', in: 'query' }], + get: { responses: {} }, + }, + }, + } + + const result = parsePaths(spec) + expect(result.users.parameters).toBeDefined() + expect(result.users.get).toBeDefined() + }) + }) +}) diff --git a/packages/openapi_generator/src/__tests__/router_generator.test.ts b/packages/openapi_generator/src/__tests__/router_generator.test.ts new file mode 100644 index 0000000..65de7fd --- /dev/null +++ b/packages/openapi_generator/src/__tests__/router_generator.test.ts @@ -0,0 +1,309 @@ +import type { OpenAPIV3_1 } from 'openapi-types' +import { describe, expect, it } from 'vitest' +import { parsePaths } from '../path_parser' +import { generateRouter } from '../router_generator' + +describe('Router Generator Comprehensive Tests', () => { + const baseSpec: OpenAPIV3_1.Document = { + openapi: '3.1.0', + info: { title: 'Test API', version: '1.0.0' }, + paths: {}, + components: { + schemas: { + User: { + type: 'object', + properties: { id: { type: 'string' } }, + }, + UserList: { + type: 'array', + items: { $ref: '#/components/schemas/User' }, + }, + }, + }, + } + + it('should generate query parameters correctly', () => { + const spec: OpenAPIV3_1.Document = { + ...baseSpec, + paths: { + '/users': { + get: { + parameters: [ + { + name: 'page', + in: 'query', + schema: { type: 'number' }, + }, + { + name: 'sort', + in: 'query', + required: true, + schema: { type: 'string' }, + }, + ], + responses: { '200': { description: 'OK' } }, + }, + }, + }, + } + const parsedPaths = parsePaths(spec) + const code = generateRouter(parsedPaths, spec) + + expect(code).toContain( + '.def_searchparams(z.object({ page: z.number().optional(), sort: z.string() }).parse)' + ) + }) + + it('should generate request body with JSON ref correctly', () => { + const spec: OpenAPIV3_1.Document = { + ...baseSpec, + paths: { + '/users': { + post: { + requestBody: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/User', + }, + }, + }, + }, + responses: { '201': { description: 'Created' } }, + }, + }, + }, + } + const parsedPaths = parsePaths(spec) + const code = generateRouter(parsedPaths, spec) + + expect(code).toContain('.def_body(Model.User.parse)') + }) + + it('should generate request body with FormData correctly', () => { + const spec: OpenAPIV3_1.Document = { + ...baseSpec, + paths: { + '/upload': { + post: { + requestBody: { + content: { + 'multipart/form-data': { + schema: { type: 'object' }, + }, + }, + }, + responses: { '200': { description: 'OK' } }, + }, + }, + }, + } + const parsedPaths = parsePaths(spec) + const code = generateRouter(parsedPaths, spec) + + expect(code).toContain('.def_body(z.instanceof(FormData).parse)') + }) + + it('should generate response with JSON ref correctly', () => { + const spec: OpenAPIV3_1.Document = { + ...baseSpec, + paths: { + '/users/1': { + get: { + responses: { + '200': { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/User', + }, + }, + }, + description: 'OK', + }, + }, + }, + }, + }, + } + const parsedPaths = parsePaths(spec) + const code = generateRouter(parsedPaths, spec) + + expect(code).toContain( + '.def_response(async ({ json }) => Model.User.parse(await json()))' + ) + }) + + it('should generate response with Array of refs correctly', () => { + const spec: OpenAPIV3_1.Document = { + ...baseSpec, + paths: { + '/users': { + get: { + responses: { + '200': { + content: { + 'application/json': { + schema: { + type: 'array', + items: { + $ref: '#/components/schemas/User', + }, + }, + }, + }, + description: 'OK', + }, + }, + }, + }, + }, + } + const parsedPaths = parsePaths(spec) + const code = generateRouter(parsedPaths, spec) + + expect(code).toContain( + '.def_response(async ({ json }) => z.array(Model.User).parse(await json()))' + ) + }) + + it('should handle nested routes and path parameters', () => { + const spec: OpenAPIV3_1.Document = { + ...baseSpec, + paths: { + '/users/{userId}/posts': { + get: { responses: { '200': { description: 'OK' } } }, + }, + }, + } + const parsedPaths = parsePaths(spec) + const code = generateRouter(parsedPaths, spec) + + // Check structure nesting + expect(code).toContain("'users': {") + expect(code).toContain("'$userId': {") + expect(code).toContain("'posts': {") + expect(code).toContain("'GET': f.builder()") + }) + + it('should inherit path level parameters', () => { + const spec: OpenAPIV3_1.Document = { + ...baseSpec, + paths: { + '/users': { + parameters: [ + { + name: 'common', + in: 'query', + schema: { type: 'string' }, + }, + ], + get: { + responses: { '200': { description: 'OK' } }, + }, + post: { + parameters: [ + { + name: 'specific', + in: 'query', + schema: { type: 'number' }, + }, + ], + responses: { '200': { description: 'OK' } }, + }, + }, + }, + } + const parsedPaths = parsePaths(spec) + const code = generateRouter(parsedPaths, spec) + + // GET should have common param + expect(code).toContain('common: z.string().optional()') + + expect(code).toMatch( + /def_searchparams\(z\.object\({.*common: z\.string\(\)\.optional\(\).*}\)\.parse\)/ + ) + expect(code).toMatch( + /def_searchparams\(z\.object\({.*specific: z\.number\(\)\.optional\(\).*}\)\.parse\)/ + ) + }) + + it('should handle empty paths gracefully', () => { + const spec: OpenAPIV3_1.Document = { + ...baseSpec, + paths: {}, + } + const parsedPaths = parsePaths(spec) + const code = generateRouter(parsedPaths, spec) + + expect(code).toContain("export const api = f.router('', {\n\n});") + }) + + it('should ignore metadata keys in path objects', () => { + const spec: OpenAPIV3_1.Document = { + ...baseSpec, + paths: { + '/users': { + summary: 'User operations', + description: 'All user related things', + get: { responses: { '200': { description: 'OK' } } }, + } as any, // Cast to any to allow non-standard fields if strict types complain, though summary/desc are valid in PathItem + }, + } + const parsedPaths = parsePaths(spec) + const code = generateRouter(parsedPaths, spec) + + expect(code).not.toContain('summary') + expect(code).not.toContain('description') + expect(code).toContain("'GET': f.builder()") + }) + + it('should handle mixed static and dynamic paths correctly', () => { + const spec: OpenAPIV3_1.Document = { + ...baseSpec, + paths: { + '/users/me': { + get: { responses: { '200': { description: 'OK' } } }, + }, + '/users/{userId}': { + get: { responses: { '200': { description: 'OK' } } }, + }, + }, + } + const parsedPaths = parsePaths(spec) + const code = generateRouter(parsedPaths, spec) + + expect(code).toContain("'me': {") + expect(code).toContain("'$userId': {") + }) + + it('should not generate searchparams if no query params exist', () => { + const spec: OpenAPIV3_1.Document = { + ...baseSpec, + paths: { + '/users': { + get: { + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string' }, + }, + { + name: 'header-param', + in: 'header', + schema: { type: 'string' }, + }, + ], + responses: { '200': { description: 'OK' } }, + }, + }, + }, + } + const parsedPaths = parsePaths(spec) + const code = generateRouter(parsedPaths, spec) + + expect(code).not.toContain('.def_searchparams') + }) +}) diff --git a/packages/openapi_generator/src/__tests__/router_generator_edge.test.ts b/packages/openapi_generator/src/__tests__/router_generator_edge.test.ts new file mode 100644 index 0000000..2a93f72 --- /dev/null +++ b/packages/openapi_generator/src/__tests__/router_generator_edge.test.ts @@ -0,0 +1,221 @@ +import type { OpenAPIV3_1 } from 'openapi-types' +import { describe, expect, it } from 'vitest' +import { generateRouter } from '../router_generator' + +describe('router_generator edge cases', () => { + describe('Reference Edge Cases', () => { + it('should handle empty $ref (pathParams with ref)', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.1.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/users': { + parameters: [ + { $ref: '' } as any, // Edge case: empty ref + ], + get: { + responses: { '200': { description: 'OK' } }, + }, + }, + }, + } + + // This should not throw, even with unusual ref + const result = generateRouter({ users: spec.paths['/users'] }, spec) + expect(result).toContain('export const api') + }) + + it('should handle request body with malformed $ref', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.1.0', + info: { title: 'Test', version: '1.0.0' }, + paths: {}, + components: { + schemas: {}, + }, + } + + const parsedPaths = { + users: { + POST: { + requestBody: { + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/' }, // Ends with slash, pop() returns empty + }, + }, + }, + responses: { '201': { description: 'Created' } }, + }, + }, + } + + // Should handle the empty string from .pop() + const result = generateRouter(parsedPaths, spec) + expect(result).toContain('.def_body(Model..parse)') // Empty modelName + }) + + it('should handle response with malformed $ref', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.1.0', + info: { title: 'Test', version: '1.0.0' }, + paths: {}, + components: { + schemas: {}, + }, + } + + const parsedPaths = { + users: { + GET: { + responses: { + '200': { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/', + }, // Ends with slash + }, + }, + }, + }, + }, + }, + } + + // Should handle the empty string from .pop() + const result = generateRouter(parsedPaths, spec) + expect(result).toContain('Model..parse') // Empty modelName + }) + + it('should handle array response with malformed $ref in items', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.1.0', + info: { title: 'Test', version: '1.0.0' }, + paths: {}, + components: { + schemas: {}, + }, + } + + const parsedPaths = { + users: { + GET: { + responses: { + '200': { + content: { + 'application/json': { + schema: { + type: 'array', + items: { $ref: '#/' }, // Very short ref, pop() returns empty + }, + }, + }, + }, + }, + }, + }, + } + + // Should handle the empty string from .pop() + const result = generateRouter(parsedPaths, spec) + expect(result).toContain('z.array(Model.)') // Empty modelName + }) + + it('should handle pathParams that are reference objects', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.1.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/users': { + parameters: [ + { + $ref: '#/components/parameters/UserId', + } as OpenAPIV3_1.ReferenceObject, + { + name: 'filter', + in: 'query', + schema: { type: 'string' }, + } as OpenAPIV3_1.ParameterObject, + ], + get: { + responses: { '200': { description: 'OK' } }, + }, + }, + }, + } + + const result = generateRouter({ users: spec.paths['/users'] }, spec) + // Should process the pathParams correctly even when some are $ref + expect(result).toContain('export const api') + }) + + it('should handle operation parameters that duplicate path parameters', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.1.0', + info: { title: 'Test', version: '1.0.0' }, + paths: {}, + } + + const parsedPaths = { + users: { + parameters: [ + { + name: 'shared', + in: 'query', + schema: { type: 'string' }, + }, + ], + GET: { + parameters: [ + { + name: 'shared', + in: 'query', + schema: { type: 'string' }, + }, // Duplicate + { + name: 'specific', + in: 'query', + schema: { type: 'number' }, + }, + ], + responses: { '200': { description: 'OK' } }, + }, + }, + } + + const result = generateRouter(parsedPaths, spec) + // Should not duplicate the 'shared' parameter + const sharedCount = (result.match(/shared:/g) || []).length + expect(sharedCount).toBe(1) // Should only appear once + }) + + it('should handle operation with only reference parameters', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.1.0', + info: { title: 'Test', version: '1.0.0' }, + paths: {}, + } + + const parsedPaths = { + users: { + GET: { + parameters: [ + { + $ref: '#/components/parameters/Param1', + } as OpenAPIV3_1.ReferenceObject, + { + $ref: '#/components/parameters/Param2', + } as OpenAPIV3_1.ReferenceObject, + ], + responses: { '200': { description: 'OK' } }, + }, + }, + } + + const result = generateRouter(parsedPaths, spec) + // Should handle all reference parameters + expect(result).toContain('export const api') + }) + }) +}) diff --git a/packages/openapi_generator/src/__tests__/router_generator_implicit.test.ts b/packages/openapi_generator/src/__tests__/router_generator_implicit.test.ts new file mode 100644 index 0000000..9fbf440 --- /dev/null +++ b/packages/openapi_generator/src/__tests__/router_generator_implicit.test.ts @@ -0,0 +1,32 @@ +import type { OpenAPIV3_1 } from 'openapi-types' +import { describe, expect, it } from 'vitest' +import { parsePaths } from '../path_parser' +import { generateRouter } from '../router_generator' + +describe('HTTP Methods Generation (Implicit)', () => { + it('should NOT generate .def_method() but rely on router structure', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.1.0', + info: { title: 'Test API', version: '1.0.0' }, + paths: { + '/users': { + get: { responses: { '200': { description: 'OK' } } }, + post: { responses: { '201': { description: 'Created' } } }, + }, + }, + } + + const parsedPaths = parsePaths(spec) + const code = generateRouter(parsedPaths, spec) + + // We expect the generated code to NOT explicitly define methods + // because the runtime router handles it based on the key (GET, POST, etc.) + + expect(code).not.toContain(".def_method('GET')") + expect(code).not.toContain(".def_method('POST')") + + // But it MUST contain the keys in the object structure + expect(code).toContain("'GET':") + expect(code).toContain("'POST':") + }) +}) diff --git a/packages/openapi_generator/src/__tests__/schema_generator.test.ts b/packages/openapi_generator/src/__tests__/schema_generator.test.ts new file mode 100644 index 0000000..6d0c81e --- /dev/null +++ b/packages/openapi_generator/src/__tests__/schema_generator.test.ts @@ -0,0 +1,1184 @@ +import type { OpenAPIV3_1 } from 'openapi-types' +import { describe, expect, it } from 'vitest' +import { SchemaGenerator } from '../schema_generator' + +describe('SchemaGenerator', () => { + const createSpec = ( + schemas: Record< + string, + OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject + > + ): OpenAPIV3_1.Document => ({ + openapi: '3.1.0', + info: { title: 'Test', version: '1.0.0' }, + paths: {}, + components: { schemas }, + }) + + describe('Basic Types', () => { + it('should generate z.string() for string type', () => { + const spec = createSpec({ Test: { type: 'string' } }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { type: 'string' }, + 'test' + ) + expect(result).toBe('z.string()') + }) + + it('should generate z.number() for number type', () => { + const spec = createSpec({ Test: { type: 'number' } }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { type: 'number' }, + 'test' + ) + expect(result).toBe('z.number()') + }) + + it('should generate z.number().int() for integer type', () => { + const spec = createSpec({ Test: { type: 'integer' } }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { type: 'integer' }, + 'test' + ) + expect(result).toBe('z.number().int()') + }) + + it('should generate z.boolean() for boolean type', () => { + const spec = createSpec({ Test: { type: 'boolean' } }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { type: 'boolean' }, + 'test' + ) + expect(result).toBe('z.boolean()') + }) + + it('should generate z.any() for undefined type', () => { + const spec = createSpec({ Test: {} }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema({}, 'test') + expect(result).toBe('z.any()') + }) + }) + + describe('String Formats and Constraints', () => { + it('should generate z.email() for email format', () => { + const spec = createSpec({ + Test: { type: 'string', format: 'email' }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { type: 'string', format: 'email' }, + 'test' + ) + expect(result).toBe('z.email()') + }) + + it('should generate z.uuid() for uuid format', () => { + const spec = createSpec({ + Test: { type: 'string', format: 'uuid' }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { type: 'string', format: 'uuid' }, + 'test' + ) + expect(result).toBe('z.uuid()') + }) + + it('should generate z.url() for uri format', () => { + const spec = createSpec({ Test: { type: 'string', format: 'uri' } }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { type: 'string', format: 'uri' }, + 'test' + ) + expect(result).toBe('z.url()') + }) + + it('should generate z.iso.datetime() for date-time format', () => { + const spec = createSpec({ + Test: { type: 'string', format: 'date-time' }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { type: 'string', format: 'date-time' }, + 'test' + ) + expect(result).toBe('z.iso.datetime()') + }) + + it('should generate z.enum() for string enum', () => { + const spec = createSpec({ + Test: { type: 'string', enum: ['a', 'b', 'c'] }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { type: 'string', enum: ['a', 'b', 'c'] }, + 'test' + ) + expect(result).toBe("z.enum(['a', 'b', 'c'])") + }) + + it('should generate z.literal() for string const', () => { + const spec = createSpec({ + Test: { type: 'string', const: 'fixed' }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { type: 'string', const: 'fixed' }, + 'test' + ) + expect(result).toBe("z.literal('fixed')") + }) + + it('should generate z.string().regex() for pattern', () => { + const spec = createSpec({ + Test: { type: 'string', pattern: '^[a-z]+$' }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { type: 'string', pattern: '^[a-z]+$' }, + 'test' + ) + expect(result).toBe('z.string().regex(/^[a-z]+$/)') + }) + }) + + describe('Number Constraints', () => { + it('should generate z.number().min() for minimum', () => { + const spec = createSpec({ Test: { type: 'number', minimum: 0 } }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { type: 'number', minimum: 0 }, + 'test' + ) + expect(result).toBe('z.number().min(0)') + }) + + it('should generate z.number().max() for maximum', () => { + const spec = createSpec({ Test: { type: 'number', maximum: 100 } }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { type: 'number', maximum: 100 }, + 'test' + ) + expect(result).toBe('z.number().max(100)') + }) + + it('should generate z.number().min().max() for both min and max', () => { + const spec = createSpec({ + Test: { type: 'number', minimum: 0, maximum: 100 }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { type: 'number', minimum: 0, maximum: 100 }, + 'test' + ) + expect(result).toBe('z.number().min(0).max(100)') + }) + + it('should generate z.number().int().min().max() for integer with constraints', () => { + const spec = createSpec({ + Test: { type: 'integer', minimum: 1, maximum: 10 }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { type: 'integer', minimum: 1, maximum: 10 }, + 'test' + ) + expect(result).toBe('z.number().int().min(1).max(10)') + }) + }) + + describe('Array Types', () => { + it('should generate z.array() with string items', () => { + const spec = createSpec({ + Test: { type: 'array', items: { type: 'string' } }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { type: 'array', items: { type: 'string' } }, + 'test' + ) + expect(result).toBe('z.array(z.string())') + }) + + it('should generate z.array() with number items', () => { + const spec = createSpec({ + Test: { type: 'array', items: { type: 'number' } }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { type: 'array', items: { type: 'number' } }, + 'test' + ) + expect(result).toBe('z.array(z.number())') + }) + + it('should generate z.array() with ref items', () => { + const spec = createSpec({ + Item: { type: 'string' }, + Test: { + type: 'array', + items: { $ref: '#/components/schemas/Item' }, + }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { type: 'array', items: { $ref: '#/components/schemas/Item' } }, + 'test' + ) + expect(result).toBe('z.array(Item)') + }) + + it('should throw error for array without items', () => { + const spec = createSpec({ Test: { type: 'array', items: {} } }) + const generator = new SchemaGenerator(spec) + expect(() => + generator.generateZodSchema({ type: 'array' } as any, 'test') + ).toThrow('Array schema must have items defined.') + }) + }) + + describe('Object Types', () => { + it('should generate z.object() with required properties', () => { + const spec = createSpec({ + Test: { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string' }, + }, + }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { + type: 'object', + required: ['name'], + properties: { name: { type: 'string' } }, + }, + 'test' + ) + expect(result).toContain("'name': z.string()") + }) + + it('should generate z.object() with optional properties', () => { + const spec = createSpec({ + Test: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { + type: 'object', + properties: { name: { type: 'string' } }, + }, + 'test' + ) + expect(result).toContain("'name': z.string().optional()") + }) + + it('should generate z.object() with mixed required and optional properties', () => { + const spec = createSpec({ + Test: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + }, + }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + }, + }, + 'test' + ) + expect(result).toContain("'id': z.string()") + expect(result).toContain("'name': z.string().optional()") + }) + + it('should generate z.object({}) for empty object', () => { + const spec = createSpec({ Test: { type: 'object' } }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { type: 'object' }, + 'test' + ) + expect(result).toBe('z.object({})') + }) + + it('should generate z.object().catchall(z.any()) for additionalProperties: true', () => { + const spec = createSpec({ + Test: { + type: 'object', + properties: { name: { type: 'string' } }, + additionalProperties: true, + }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { + type: 'object', + properties: { name: { type: 'string' } }, + additionalProperties: true, + }, + 'test' + ) + expect(result).toContain('.catchall(z.any())') + }) + + it('should generate z.object().catchall() with typed additionalProperties', () => { + const spec = createSpec({ + Test: { + type: 'object', + properties: { name: { type: 'string' } }, + additionalProperties: { type: 'number' }, + }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { + type: 'object', + properties: { name: { type: 'string' } }, + additionalProperties: { type: 'number' }, + }, + 'test' + ) + expect(result).toContain('.catchall(z.number())') + }) + + it('should generate z.record() when no properties but additionalProperties defined', () => { + const spec = createSpec({ + Test: { + type: 'object', + additionalProperties: { type: 'string' }, + }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { + type: 'object', + additionalProperties: { type: 'string' }, + }, + 'test' + ) + expect(result).toBe('z.record(z.string(), z.any())') + }) + }) + + describe('References ($ref)', () => { + it('should resolve simple reference', () => { + const spec = createSpec({ + User: { type: 'string' }, + Test: { $ref: '#/components/schemas/User' }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { $ref: '#/components/schemas/User' }, + 'test' + ) + expect(result).toBe('User') + }) + + it('should throw error for invalid $ref format', () => { + const spec = createSpec({ Test: { $ref: 'invalid/ref' } }) + const generator = new SchemaGenerator(spec) + expect(() => + generator.generateZodSchema({ $ref: 'invalid/ref' }, 'test') + ).toThrow('Unsupported $ref format') + }) + + it('should throw error for non-existent $ref', () => { + const spec = createSpec({ + Test: { $ref: '#/components/schemas/NonExistent' }, + }) + const generator = new SchemaGenerator(spec) + expect(() => + generator.generateZodSchema( + { $ref: '#/components/schemas/NonExistent' }, + 'test' + ) + ).toThrow('Schema not found for $ref') + }) + + it('should handle reference with additional properties (allOf conversion)', () => { + const spec = createSpec({ + Base: { type: 'string' }, + Test: { + $ref: '#/components/schemas/Base', + description: 'extra', + } as any, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { + $ref: '#/components/schemas/Base', + description: 'extra', + } as any, + 'test' + ) + // Should convert to allOf and process + expect(result).toContain('Base') + }) + }) + + describe('allOf (Intersection)', () => { + it('should generate intersection with .and()', () => { + const spec = createSpec({ + Base: { + type: 'object', + properties: { id: { type: 'string' } }, + }, + Extra: { + type: 'object', + properties: { name: { type: 'string' } }, + }, + Test: { + allOf: [ + { $ref: '#/components/schemas/Base' }, + { $ref: '#/components/schemas/Extra' }, + ], + }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { + allOf: [ + { $ref: '#/components/schemas/Base' }, + { $ref: '#/components/schemas/Extra' }, + ], + }, + 'test' + ) + expect(result).toContain('Base.and(Extra)') + }) + + it('should handle allOf with three schemas', () => { + const spec = createSpec({ + A: { type: 'object', properties: { a: { type: 'string' } } }, + B: { type: 'object', properties: { b: { type: 'string' } } }, + C: { type: 'object', properties: { c: { type: 'string' } } }, + Test: { + allOf: [ + { $ref: '#/components/schemas/A' }, + { $ref: '#/components/schemas/B' }, + { $ref: '#/components/schemas/C' }, + ], + }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { + allOf: [ + { $ref: '#/components/schemas/A' }, + { $ref: '#/components/schemas/B' }, + { $ref: '#/components/schemas/C' }, + ], + }, + 'test' + ) + expect(result).toBe('A.and(B.and(C))') + }) + + it('should handle allOf with inline schemas', () => { + const spec = createSpec({ + Test: { + allOf: [ + { + type: 'object', + properties: { a: { type: 'string' } }, + }, + { + type: 'object', + properties: { b: { type: 'number' } }, + }, + ], + }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { + allOf: [ + { + type: 'object', + properties: { a: { type: 'string' } }, + }, + { + type: 'object', + properties: { b: { type: 'number' } }, + }, + ], + }, + 'test' + ) + expect(result).toContain('.and(') + }) + }) + + describe('oneOf/anyOf (Union)', () => { + it('should generate z.union() for simple oneOf', () => { + const spec = createSpec({ + A: { type: 'string' }, + B: { type: 'number' }, + Test: { + oneOf: [ + { $ref: '#/components/schemas/A' }, + { $ref: '#/components/schemas/B' }, + ], + }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { + oneOf: [ + { $ref: '#/components/schemas/A' }, + { $ref: '#/components/schemas/B' }, + ], + }, + 'test' + ) + expect(result).toBe('z.union([A, B])') + }) + + it('should generate z.union() for anyOf', () => { + const spec = createSpec({ + A: { type: 'string' }, + B: { type: 'number' }, + Test: { + anyOf: [ + { $ref: '#/components/schemas/A' }, + { $ref: '#/components/schemas/B' }, + ], + }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { + anyOf: [ + { $ref: '#/components/schemas/A' }, + { $ref: '#/components/schemas/B' }, + ], + }, + 'test' + ) + expect(result).toBe('z.union([A, B])') + }) + + it('should generate z.discriminatedUnion() for oneOf with discriminator', () => { + const spec = createSpec({ + OptionA: { + type: 'object', + required: ['type'], + properties: { + type: { type: 'string', const: 'A' }, + a: { type: 'string' }, + }, + }, + OptionB: { + type: 'object', + required: ['type'], + properties: { + type: { type: 'string', const: 'B' }, + b: { type: 'number' }, + }, + }, + Test: { + oneOf: [ + { $ref: '#/components/schemas/OptionA' }, + { $ref: '#/components/schemas/OptionB' }, + ], + discriminator: { propertyName: 'type' }, + }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { + oneOf: [ + { $ref: '#/components/schemas/OptionA' }, + { $ref: '#/components/schemas/OptionB' }, + ], + discriminator: { propertyName: 'type' }, + }, + 'test' + ) + expect(result).toBe( + "z.discriminatedUnion('type', [OptionA, OptionB])" + ) + }) + + it('should throw error for discriminated union with non-ref schemas', () => { + const spec = createSpec({ + Test: { + oneOf: [ + { + type: 'object', + properties: { type: { type: 'string' } }, + }, + ], + discriminator: { propertyName: 'type' }, + }, + }) + const generator = new SchemaGenerator(spec) + expect(() => + generator.generateZodSchema( + { + oneOf: [ + { + type: 'object', + properties: { type: { type: 'string' } }, + }, + ], + discriminator: { propertyName: 'type' }, + }, + 'test' + ) + ).toThrow('oneOf with discriminator must use $ref objects') + }) + }) + + describe('Nullable Types (OAS 3.1)', () => { + it('should generate .nullable() for type array with null', () => { + const spec = createSpec({ + Test: { type: ['string', 'null'] } as any, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { type: ['string', 'null'] } as any, + 'test' + ) + expect(result).toBe('z.string().nullable()') + }) + + it('should generate .nullable() for number with null', () => { + const spec = createSpec({ + Test: { type: ['number', 'null'] } as any, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { type: ['number', 'null'] } as any, + 'test' + ) + expect(result).toBe('z.number().nullable()') + }) + + it('should preserve constraints in nullable types', () => { + const spec = createSpec({ + Test: { + type: ['number', 'null'], + minimum: 0, + maximum: 100, + } as any, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { + type: ['number', 'null'], + minimum: 0, + maximum: 100, + } as any, + 'test' + ) + expect(result).toBe('z.number().min(0).max(100).nullable()') + }) + }) + + describe('generateModels()', () => { + it('should generate empty string for spec without components', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.1.0', + info: { title: 'Test', version: '1.0.0' }, + paths: {}, + } + const generator = new SchemaGenerator(spec) + const result = generator.generateModels() + expect(result).toBe('') + }) + + it('should generate empty string for spec with empty schemas', () => { + const spec = createSpec({}) + const generator = new SchemaGenerator(spec) + const result = generator.generateModels() + expect(result).toContain("import { z } from 'zod';") + }) + + it('should generate model exports with PascalCase names', () => { + const spec = createSpec({ + 'user-profile': { + type: 'object', + properties: { name: { type: 'string' } }, + }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateModels() + expect(result).toContain('export const UserProfile =') + expect(result).toContain('export type UserProfileModel =') + }) + + it('should generate imports and exports', () => { + const spec = createSpec({ + User: { type: 'string' }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateModels() + expect(result).toContain("import { z } from 'zod';") + expect(result).toContain('export const User = z.string();') + expect(result).toContain( + 'export type UserModel = z.infer;' + ) + }) + + it('should handle multiple schemas', () => { + const spec = createSpec({ + User: { type: 'string' }, + Post: { + type: 'object', + properties: { title: { type: 'string' } }, + }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateModels() + expect(result).toContain('export const User') + expect(result).toContain('export const Post') + }) + }) + + describe('Real-World Complex Schemas', () => { + it('should handle deeply nested objects and arrays', () => { + const spec = createSpec({ + DeepNested: { + type: 'object', + properties: { + level1: { + type: 'object', + properties: { + level2: { + type: 'array', + items: { + type: 'object', + properties: { + level3: { + type: 'string', + enum: ['deep'], + }, + }, + }, + }, + }, + }, + }, + }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + spec.components!.schemas! + .DeepNested as OpenAPIV3_1.SchemaObject, + 'DeepNested' + ) + + // Should contain nested structure + expect(result).toContain('z.object({') + expect(result).toContain('z.array(') + expect(result).toContain("z.enum(['deep'])") + }) + + it('should handle allOf with ref and inline schema (IntersectionHell pattern)', () => { + const spec = createSpec({ + HellObject: { + type: 'object', + required: ['required-key', 'spaced key'], + properties: { + 'required-key': { type: 'string' }, + 'optional-key': { type: 'string' }, + 'spaced key': { type: 'number' }, + $special$: { type: 'boolean' }, + }, + }, + IntersectionHell: { + allOf: [ + { $ref: '#/components/schemas/HellObject' }, + { + type: 'object', + properties: { + extra: { type: 'string' }, + }, + }, + ], + }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + spec.components!.schemas! + .IntersectionHell as OpenAPIV3_1.SchemaObject, + 'IntersectionHell' + ) + + expect(result).toContain('HellObject.and(') + expect(result).toContain('z.object({') + }) + + it('should correctly handle complete hell.json HellObject schema', () => { + const spec = createSpec({ + HellObject: { + type: 'object', + required: ['required-key', 'spaced key'], + properties: { + 'required-key': { type: 'string' }, + 'optional-key': { type: 'string' }, + 'spaced key': { type: 'number' }, + $special$: { type: 'boolean' }, + }, + }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + spec.components!.schemas! + .HellObject as OpenAPIV3_1.SchemaObject, + 'HellObject' + ) + + expect(result).toContain("'required-key': z.string()") + expect(result).toContain("'optional-key': z.string().optional()") + expect(result).toContain("'spaced key': z.number()") + expect(result).toContain("'$special$': z.boolean().optional()") + }) + + it('should handle complete hell.json UnionHell with discriminator', () => { + const spec = createSpec({ + OptionA: { + type: 'object', + required: ['type'], + properties: { + type: { type: 'string', enum: ['A'] }, + a: { type: 'string' }, + }, + }, + OptionB: { + type: 'object', + required: ['type'], + properties: { + type: { type: 'string', enum: ['B'] }, + b: { type: 'number' }, + }, + }, + UnionHell: { + oneOf: [ + { $ref: '#/components/schemas/OptionA' }, + { $ref: '#/components/schemas/OptionB' }, + ], + discriminator: { + propertyName: 'type', + }, + }, + }) + const generator = new SchemaGenerator(spec) + const models = generator.generateModels() + + expect(models).toContain( + "export const UnionHell = z.discriminatedUnion('type', [OptionA, OptionB]);" + ) + }) + + it('should handle nested object with array of objects containing enums', () => { + const spec = createSpec({ + Complex: { + type: 'object', + properties: { + items: { + type: 'array', + items: { + type: 'object', + properties: { + status: { + type: 'string', + enum: [ + 'pending', + 'active', + 'completed', + ], + }, + count: { + type: 'integer', + minimum: 0, + maximum: 100, + }, + }, + }, + }, + }, + }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + spec.components!.schemas!.Complex as OpenAPIV3_1.SchemaObject, + 'Complex' + ) + + expect(result).toContain('z.array(') + expect(result).toContain( + "z.enum(['pending', 'active', 'completed'])" + ) + expect(result).toContain('z.number().int().min(0).max(100)') + }) + + it('should handle object with additionalProperties containing $ref', () => { + const spec = createSpec({ + Value: { type: 'string' }, + Dictionary: { + type: 'object', + additionalProperties: { + $ref: '#/components/schemas/Value', + }, + }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + spec.components!.schemas! + .Dictionary as OpenAPIV3_1.SchemaObject, + 'Dictionary' + ) + + expect(result).toBe('z.record(Value, z.any())') + }) + + it('should handle allOf with multiple refs and inline schemas', () => { + const spec = createSpec({ + Base: { + type: 'object', + properties: { id: { type: 'string' } }, + }, + Timestamped: { + type: 'object', + properties: { + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, + }, + }, + Entity: { + allOf: [ + { $ref: '#/components/schemas/Base' }, + { $ref: '#/components/schemas/Timestamped' }, + { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + ], + }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + spec.components!.schemas!.Entity as OpenAPIV3_1.SchemaObject, + 'Entity' + ) + + expect(result).toContain('Base.and(') + expect(result).toContain('Timestamped') + }) + + it('should handle array of refs', () => { + const spec = createSpec({ + User: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + }, + }, + Users: { + type: 'array', + items: { $ref: '#/components/schemas/User' }, + }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + spec.components!.schemas!.Users as OpenAPIV3_1.SchemaObject, + 'Users' + ) + + expect(result).toBe('z.array(User)') + }) + + it('should handle nullable complex types', () => { + const spec = createSpec({ + NullableObject: { + type: ['object', 'null'], + properties: { + value: { type: 'string' }, + }, + } as any, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + spec.components!.schemas!.NullableObject as any, + 'NullableObject' + ) + + expect(result).toContain('.nullable()') + expect(result).toContain('z.object({') + }) + }) + + describe('Edge Cases', () => { + it('should throw error for non-existent schema name', () => { + const spec = createSpec({}) + const generator = new SchemaGenerator(spec) + expect(() => + generator.generateZodSchema(undefined as any, 'NonExistent') + ).toThrow('Schema "NonExistent" not found in specification') + }) + + it('should handle caching of processed schemas', () => { + const spec = createSpec({ + User: { type: 'string' }, + Profile: { $ref: '#/components/schemas/User' }, + }) + const generator = new SchemaGenerator(spec) + + // First call processes User + generator.generateZodSchema( + { $ref: '#/components/schemas/User' }, + 'test1' + ) + + // Second call should return cached result + const result = generator.generateZodSchema( + { $ref: '#/components/schemas/User' }, + 'test2' + ) + expect(result).toBe('User') + }) + + it('should handle special characters in property names', () => { + const spec = createSpec({ + Test: { + type: 'object', + properties: { + 'special-key': { type: 'string' }, + $dollar: { type: 'number' }, + }, + }, + }) + const generator = new SchemaGenerator(spec) + const result = generator.generateZodSchema( + { + type: 'object', + properties: { + 'special-key': { type: 'string' }, + $dollar: { type: 'number' }, + }, + }, + 'test' + ) + expect(result).toContain("'special-key'") + expect(result).toContain("'$dollar'") + }) + + it('should handle $defs pattern for specialized allOf composition', () => { + // This test covers the special $defs handling code path (lines 96-118) + const spec = createSpec({ + BaseList: { + type: 'object', + properties: { + items: { + type: 'array', + items: { type: 'string' }, // Will be replaced + }, + }, + }, + Product: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + }, + }, + ProductList: { + allOf: [ + { $ref: '#/components/schemas/BaseList' }, + { + $defs: { + productItem: { + $ref: '#/components/schemas/Product', + }, + }, + } as any, + ], + }, + }) + + const generator = new SchemaGenerator(spec) + const result = generator.generateModels() + + // The $defs pattern should replace z.array(z.any()) with z.array(Product) + expect(result).toContain('Product') + expect(result).toContain('ProductList') + }) + + it('should throw error when base schema not found in $defs pattern (lines 103-106)', () => { + const spec = createSpec({ + BaseSchema: { + type: 'object', + properties: { + items: { type: 'array', items: { type: 'string' } }, + }, + }, + Item: { type: 'string' }, + Test: { + allOf: [ + { $ref: '#/components/schemas/BaseSchema' }, + { + $defs: { + productItem: { + $ref: '#/components/schemas/Item', + }, + }, + } as any, + ], + }, + }) + + const generator = new SchemaGenerator(spec) + + // Manually trigger the scenario: Process the Test schema which will try to + // process BaseSchema via the $defs pattern. We intercept the internal state + // to simulate BaseSchema not being in processedSchemas after mapSchemaObjectToZod + const originalGet = generator['processedSchemas'].get.bind( + generator['processedSchemas'] + ) + let callCount = 0 + generator['processedSchemas'].get = function (key: string) { + callCount++ + // On the specific call for 'BaseSchema' in the $defs handler, return undefined + if (key === 'BaseSchema' && callCount === 1) { + return undefined + } + return originalGet(key) + } + + expect(() => + generator.generateZodSchema( + spec.components!.schemas!.Test as OpenAPIV3_1.SchemaObject, + 'Test' + ) + ).toThrow('Base schema "BaseSchema" not found in processed schemas') + }) + }) +}) diff --git a/packages/openapi_generator/src/__tests__/utils.test.ts b/packages/openapi_generator/src/__tests__/utils.test.ts new file mode 100644 index 0000000..ac07ade --- /dev/null +++ b/packages/openapi_generator/src/__tests__/utils.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { toPascalCase } from '../utils' + +describe('utils', () => { + describe('toPascalCase', () => { + it('should convert kebab-case to PascalCase', () => { + expect(toPascalCase('user-profile')).toBe('UserProfile') + expect(toPascalCase('api-key')).toBe('ApiKey') + expect(toPascalCase('my-long-variable-name')).toBe( + 'MyLongVariableName' + ) + }) + + it('should convert snake_case to PascalCase', () => { + expect(toPascalCase('user_profile')).toBe('UserProfile') + expect(toPascalCase('api_key')).toBe('ApiKey') + expect(toPascalCase('my_long_variable_name')).toBe( + 'MyLongVariableName' + ) + }) + + it('should convert camelCase to PascalCase', () => { + expect(toPascalCase('userProfile')).toBe('UserProfile') + expect(toPascalCase('apiKey')).toBe('ApiKey') + expect(toPascalCase('myLongVariableName')).toBe( + 'MyLongVariableName' + ) + }) + + it('should convert space-separated to PascalCase', () => { + expect(toPascalCase('user profile')).toBe('UserProfile') + expect(toPascalCase('api key')).toBe('ApiKey') + expect(toPascalCase('my long variable name')).toBe( + 'MyLongVariableName' + ) + }) + + it('should handle already PascalCase strings', () => { + expect(toPascalCase('UserProfile')).toBe('UserProfile') + expect(toPascalCase('APIKey')).toBe('ApiKey') // Splits API and Key + }) + + it('should preserve leading special characters', () => { + expect(toPascalCase('$special-key')).toBe('$SpecialKey') + expect(toPascalCase('$dollar')).toBe('$Dollar') + // Underscore at the start is treated as separator, not special char + expect(toPascalCase('_private-var')).toBe('PrivateVar') + }) + + it('should handle mixed separators', () => { + expect(toPascalCase('user-profile_name')).toBe('UserProfileName') + expect(toPascalCase('api_key-value')).toBe('ApiKeyValue') + }) + + it('should handle empty string', () => { + expect(toPascalCase('')).toBe('') + }) + + it('should handle single word', () => { + expect(toPascalCase('user')).toBe('User') + expect(toPascalCase('api')).toBe('Api') + }) + + it('should handle all uppercase', () => { + expect(toPascalCase('API')).toBe('API') // Single word, already uppercase + expect(toPascalCase('HTTP')).toBe('HTTP') + }) + + it('should handle numbers in names', () => { + expect(toPascalCase('user-1-profile')).toBe('User1Profile') + expect(toPascalCase('api2-key')).toBe('Api2Key') + }) + + it('should handle consecutive separators', () => { + expect(toPascalCase('user--profile')).toBe('UserProfile') + expect(toPascalCase('api__key')).toBe('ApiKey') + expect(toPascalCase('my name')).toBe('MyName') + }) + + it('should handle real-world cases from codebase', () => { + expect(toPascalCase('user-profile')).toBe('UserProfile') + expect(toPascalCase('HellObject')).toBe('HellObject') // No separators, preserve + expect(toPascalCase('BaseList')).toBe('BaseList') + expect(toPascalCase('ProductList')).toBe('ProductList') + }) + }) +}) diff --git a/packages/openapi_generator/src/errors.ts b/packages/openapi_generator/src/errors.ts new file mode 100644 index 0000000..b97b814 --- /dev/null +++ b/packages/openapi_generator/src/errors.ts @@ -0,0 +1,114 @@ +import chalk from 'chalk' + +/** + * Base class for all generator errors + */ +export abstract class GeneratorError extends Error { + constructor(message: string) { + super(message) + this.name = this.constructor.name + Error.captureStackTrace(this, this.constructor) + } + + abstract format(): string +} + +/** + * Error for OpenAPI specification parsing failures + */ +export class SpecParsingError extends GeneratorError { + constructor( + message: string, + public filePath: string, + public line?: number, + public column?: number, + public suggestion?: string + ) { + super(message) + } + + format(): string { + const location = this.line + ? `:${this.line}${this.column ? `:${this.column}` : ''}` + : '' + + return ` +${chalk.red('โœ—')} ${chalk.bold('Error parsing OpenAPI specification')} + + ${chalk.dim('File:')} ${this.filePath}${location} + ${chalk.dim('Issue:')} ${this.message} +${this.suggestion ? `\n ${chalk.yellow('๐Ÿ’ก Suggestion:')} ${this.suggestion}` : ''} + `.trim() + } +} + +/** + * Error for invalid schema definitions + */ +export class SchemaValidationError extends GeneratorError { + constructor( + message: string, + public schemaName: string, + public schemaPath: string, + public suggestion?: string + ) { + super(message) + } + + format(): string { + return ` +${chalk.red('โœ—')} ${chalk.bold('Invalid Schema')} + + ${chalk.dim('Schema:')} ${this.schemaName} (${this.schemaPath}) + ${chalk.dim('Issue:')} ${this.message} +${this.suggestion ? `\n ${chalk.yellow('๐Ÿ’ก Suggestion:')} ${this.suggestion}` : ''} + `.trim() + } +} + +/** + * Error for file system operations + */ +export class FileSystemError extends GeneratorError { + constructor( + message: string, + public operation: 'read' | 'write' | 'create' | 'delete', + public filePath: string + ) { + super(message) + } + + format(): string { + return ` +${chalk.red('โœ—')} ${chalk.bold(`File System Error (${this.operation})`)} + + ${chalk.dim('File:')} ${this.filePath} + ${chalk.dim('Issue:')} ${this.message} + `.trim() + } +} + +/** + * Error for CLI configuration issues + */ +export class ConfigurationError extends GeneratorError { + constructor( + message: string, + public option?: string, + public providedValue?: string, + public suggestion?: string + ) { + super(message) + } + + format(): string { + return ` +${chalk.red('โœ—')} ${chalk.bold('Configuration Error')} + +${this.option ? ` ${chalk.dim('Option:')} --${this.option}` : ''} +${this.providedValue ? ` ${chalk.dim('Provided:')} ${this.providedValue}` : ''} + ${chalk.dim('Issue:')} ${this.message} +${this.suggestion ? `\n ${chalk.yellow('๐Ÿ’ก Suggestion:')} ${this.suggestion}` : ''} + `.trim() + } +} diff --git a/packages/openapi_generator/src/index.ts b/packages/openapi_generator/src/index.ts new file mode 100644 index 0000000..4fb39da --- /dev/null +++ b/packages/openapi_generator/src/index.ts @@ -0,0 +1,55 @@ +#!/usr/bin/env node + +import { resolve } from 'node:path' +import { Command } from 'commander' +import { outputFileSync } from 'fs-extra' +import { parseOpenApiSpec, parsePaths } from './path_parser' +import { generateRouter } from './router_generator' +import { SchemaGenerator } from './schema_generator' + +const program = new Command() + +program + .command('generate') + .description( + 'Generate a new API client from an OpenAPI specification file.' + ) + .requiredOption('-i, --input ', 'Path to the OpenAPI JSON file') + .requiredOption( + '-o, --output ', + 'Directory to output the generated client' + ) + .action(async (options) => { + const absoluteInputPath = resolve(process.cwd(), options.input) + const absoluteOutputPath = resolve(process.cwd(), options.output) + + const spec = await parseOpenApiSpec(absoluteInputPath) + + const schemaGenerator = new SchemaGenerator(spec) + + // Generate and write models + const modelsFileContent = schemaGenerator.generateModels() + + outputFileSync( + resolve(absoluteOutputPath, 'models.ts'), + modelsFileContent + ) + + // Generate and write router + const parsedPaths = parsePaths(spec) + const routerFileContent = generateRouter(parsedPaths, spec) + + outputFileSync(resolve(absoluteOutputPath, 'api.ts'), routerFileContent) + + // Generate and write index + outputFileSync( + resolve(absoluteOutputPath, 'index.ts'), + "export * from './api';\nexport * from './models';" + ) + + console.log( + `API client generated successfully at ${absoluteOutputPath}` + ) + }) + +program.parse(process.argv) diff --git a/packages/openapi_generator/src/path_parser.ts b/packages/openapi_generator/src/path_parser.ts new file mode 100644 index 0000000..bb7c5e8 --- /dev/null +++ b/packages/openapi_generator/src/path_parser.ts @@ -0,0 +1,136 @@ +import SwaggerParser from '@apidevtools/swagger-parser' +import type { OpenAPIV3_1 } from 'openapi-types' +import { SpecParsingError } from './errors' + +/** + * Parse an OpenAPI specification file (JSON or YAML) + * Automatically resolves all $ref references (local and external) + * + * @param filePath - Absolute or relative path to the OpenAPI spec file (.json, .yaml, or .yml) + * @returns Fully dereferenced OpenAPI 3.1 document + */ +export async function parseOpenApiSpec( + filePath: string +): Promise { + try { + const api = (await SwaggerParser.bundle( + filePath + )) as OpenAPIV3_1.Document + + // Validate required OpenAPI fields + if (!api.openapi) { + throw new SpecParsingError( + 'Missing required "openapi" version field', + filePath, + undefined, + undefined, + 'Add "openapi: 3.0.0" or "openapi: 3.1.0" at the top of your spec' + ) + } + + if (!api.info) { + throw new SpecParsingError( + 'Missing required "info" section', + filePath, + undefined, + undefined, + 'Add an "info" section with "title" and "version" fields' + ) + } + + return api + } catch (error) { + // If already a SpecParsingError, re-throw + if (error instanceof SpecParsingError) { + throw error + } + + if (error instanceof Error) { + const isYaml = + filePath.endsWith('.yaml') || filePath.endsWith('.yml') + + // Parse YAML-specific errors for line numbers + let line: number | undefined + const lineMatch = error.message.match(/line (\d+)/) + if (lineMatch && lineMatch[1]) { + line = Number.parseInt(lineMatch[1], 10) + } + + // Provide contextual suggestions + let suggestion: string | undefined + if (isYaml) { + if (error.message.includes('duplicate')) { + suggestion = 'Check for duplicate keys in your YAML file' + } else if ( + error.message.includes('indent') || + error.message.includes('indentation') + ) { + suggestion = + 'YAML requires consistent indentation (use spaces, not tabs)' + } else if (error.message.includes('mapping')) { + suggestion = + 'Check YAML structure - ensure proper key:value pairs and indentation' + } else { + suggestion = + 'Check YAML syntax - common issues: unclosed quotes, incorrect indentation, or missing colons' + } + } else if (error.message.includes('JSON')) { + suggestion = + 'Check for missing commas, brackets, or quotes in your JSON file' + } else if ( + error.message.includes('ENOENT') || + error.message.includes('no such file') + ) { + suggestion = `File not found. Check that "${filePath}" exists and is accessible` + } else if (error.message.includes('$ref')) { + suggestion = + 'Check that all $ref paths point to valid schemas or files' + } + + throw new SpecParsingError( + error.message, + filePath, + line, + undefined, + suggestion + ) + } + + throw error + } +} + +export function parsePaths(spec: OpenAPIV3_1.Document): Record { + const paths = spec.paths + if (!paths) return {} + const result: Record = {} + + for (const path in paths) { + const pathItem = paths[path] + if (!pathItem) continue + + const pathParts = path.split('/').filter((p) => p) + let current = result + + for (let i = 0; i < pathParts.length; i++) { + let part = pathParts[i] + if (!part) continue + + if (part.startsWith('{') && part.endsWith('}')) { + part = `$${part.slice(1, -1)}` + } + + if (!current[part]) { + current[part] = {} + } + + if (i === pathParts.length - 1) { + current[part] = { ...current[part], ...pathItem } + } + + current = current[part] + } + } + + return result +} diff --git a/packages/openapi_generator/src/router_generator.ts b/packages/openapi_generator/src/router_generator.ts new file mode 100644 index 0000000..2576e38 --- /dev/null +++ b/packages/openapi_generator/src/router_generator.ts @@ -0,0 +1,145 @@ +import type { OpenAPIV3_1 } from 'openapi-types' +import { SchemaGenerator } from './schema_generator' +import { toPascalCase } from './utils' + +function isReferenceObject(obj: any): obj is OpenAPIV3_1.ReferenceObject { + return obj && '$ref' in obj +} + +function generateBuilder( + operation: OpenAPIV3_1.OperationObject, + pathParams: (OpenAPIV3_1.ParameterObject | OpenAPIV3_1.ReferenceObject)[], + schemaGenerator: SchemaGenerator +): string { + let builder = 'f.builder().def_json()' + + const allParameters = [...pathParams] + if (operation.parameters) { + const pathParamNames = new Set( + pathParams.map((p) => (isReferenceObject(p) ? null : p.name)) + ) + operation.parameters.forEach((opParam) => { + if ( + isReferenceObject(opParam) || + !pathParamNames.has(opParam.name) + ) { + allParameters.push(opParam) + } + }) + } + + const queryParameters = allParameters.filter( + (p) => !isReferenceObject(p) && p.in === 'query' + ) as OpenAPIV3_1.ParameterObject[] + if (queryParameters.length > 0) { + const queryParamsString = queryParameters + .map((param) => { + const zodType = schemaGenerator.generateZodSchema( + param.schema as OpenAPIV3_1.SchemaObject, + param.name + ) + const finalType = param.required + ? zodType + : `${zodType}.optional()` + return `${param.name}: ${finalType}` + }) + .join(', ') + builder += `.def_searchparams(z.object({ ${queryParamsString} }).parse)` + } + + if (operation.requestBody && !isReferenceObject(operation.requestBody)) { + const requestBody = + operation.requestBody as OpenAPIV3_1.RequestBodyObject + const jsonContent = requestBody.content?.['application/json'] + const formContent = requestBody.content?.['multipart/form-data'] + + if (jsonContent?.schema && isReferenceObject(jsonContent.schema)) { + const modelName = toPascalCase( + jsonContent.schema.$ref.split('/').pop() || '' + ) + builder += `.def_body(Model.${modelName}.parse)` + } else if (formContent) { + builder += `.def_body(z.instanceof(FormData).parse)` + } + } + + const response = + operation.responses?.['200'] || operation.responses?.['201'] + if (response && !isReferenceObject(response)) { + const mediaTypeObject = response.content?.['application/json'] + if (mediaTypeObject?.schema) { + const schema = mediaTypeObject.schema + if (isReferenceObject(schema)) { + const modelName = toPascalCase( + schema.$ref.split('/').pop() || '' + ) + builder += `.def_response(async ({ json }) => Model.${modelName}.parse(await json()))` + } else if ( + schema.type === 'array' && + schema.items && + isReferenceObject(schema.items) + ) { + const modelName = toPascalCase( + schema.items.$ref.split('/').pop() || '' + ) + builder += `.def_response(async ({ json }) => z.array(Model.${modelName}).parse(await json()))` + } + } + } + return builder +} + +export function generateRouter( + parsedPaths: Record, + spec: OpenAPIV3_1.Document +): string { + const httpMethods = new Set([ + 'get', + 'put', + 'post', + 'delete', + 'options', + 'head', + 'patch', + 'trace', + ]) + const openApiMetadataKeys = new Set([ + 'summary', + 'description', + 'parameters', + 'servers', + '$ref', + ]) + + const schemaGenerator = new SchemaGenerator(spec) + + function buildRouterObject(pathNode: Record): string { + const parts: string[] = [] + const pathLevelParams = pathNode.parameters || [] + + for (const key in pathNode) { + const value = pathNode[key] + if (httpMethods.has(key.toLowerCase())) { + parts.push( + `'${key.toUpperCase()}': ${generateBuilder(value, pathLevelParams, schemaGenerator)}` + ) + } else if ( + typeof value === 'object' && + value !== null && + !openApiMetadataKeys.has(key) + ) { + parts.push(`'${key}': {\n${buildRouterObject(value)}\n}`) + } + } + return parts.join(',\n') + } + + const routerObject = `{\n${buildRouterObject(parsedPaths)}\n}` + const baseUrl = spec.servers && spec.servers[0] ? spec.servers[0].url : '' + + return `import { f } from '@freestylejs/fetch'; +import { z } from 'zod'; +import * as Model from './models'; + +export const api = f.router('${baseUrl}', ${routerObject});` +} diff --git a/packages/openapi_generator/src/schema_generator.ts b/packages/openapi_generator/src/schema_generator.ts new file mode 100644 index 0000000..feb005f --- /dev/null +++ b/packages/openapi_generator/src/schema_generator.ts @@ -0,0 +1,298 @@ +import type { OpenAPIV3_1 } from 'openapi-types' +import { SchemaValidationError } from './errors' +import { toPascalCase } from './utils' + +function isReferenceObject(obj: any): obj is OpenAPIV3_1.ReferenceObject { + return obj && '$ref' in obj +} + +export class SchemaGenerator { + private spec: OpenAPIV3_1.Document + private processedSchemas: Map = new Map() + + constructor(spec: OpenAPIV3_1.Document) { + this.spec = spec + } + + public generateZodSchema( + schema: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject, + nameHint: string = 'inline' + ): string { + return this.mapSchemaObjectToZod(nameHint, schema) + } + + public generateModels(): string { + if (!this.spec.components || !this.spec.components.schemas) { + return '' + } + const schemas = Object.entries(this.spec.components.schemas) + const modelStrings: string[] = [`import { z } from 'zod';`] + + for (const [name] of schemas) { + this.mapSchemaObjectToZod(name) + } + + for (const [name, zodSchema] of this.processedSchemas.entries()) { + const pascalName = toPascalCase(name) + modelStrings.push(`export const ${pascalName} = ${zodSchema};`) + modelStrings.push( + `export type ${pascalName}Model = z.infer;` + ) + } + return modelStrings.join('\n\n') + } + + private resolveRef(ref: string): { + name: string + schema: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject + } { + if (!ref.startsWith('#/components/schemas/')) { + throw new SchemaValidationError( + `Unsupported $ref format: ${ref}`, + 'unknown', + ref, + 'Use the format "#/components/schemas/SchemaName" for schema references' + ) + } + const name = ref.split('/').pop() + if (!name) { + throw new SchemaValidationError( + `Invalid $ref path: ${ref}`, + 'unknown', + ref, + 'Ensure $ref follows "#/components/schemas/SchemaName" format' + ) + } + const schema = this.spec.components?.schemas?.[name] + if (!schema) { + throw new SchemaValidationError( + `Schema not found for $ref: ${ref}`, + name, + ref, + `Check that the schema "${name}" is defined in components.schemas` + ) + } + return { name, schema } + } + + private mapSchemaObjectToZod( + name: string, + schemaObject?: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject + ): string { + const schema = schemaObject || this.spec.components?.schemas?.[name] + if (!schema) { + throw new SchemaValidationError( + `Schema "${name}" not found in specification`, + name, + `components.schemas.${name}`, + 'Check that the schema is defined in the components.schemas section' + ) + } + + if (this.processedSchemas.has(name) && !schemaObject) { + return toPascalCase(name) + } + + if (isReferenceObject(schema) && Object.keys(schema).length > 1) { + const { $ref, ...rest } = schema + const allOfSchema: OpenAPIV3_1.SchemaObject = { + allOf: [{ $ref }, rest as OpenAPIV3_1.SchemaObject], + } + return this.mapSchemaObjectToZod(name, allOfSchema) + } + + if (isReferenceObject(schema)) { + const { name: refName } = this.resolveRef(schema.$ref) + this.mapSchemaObjectToZod(refName) + return toPascalCase(refName) + } + + if (schema.allOf) { + const baseRef = schema.allOf[0] + const overrides = schema.allOf[1] + + if (isReferenceObject(baseRef) && (overrides as any).$defs) { + const baseSchemaName = this.resolveRef(baseRef.$ref).name + this.mapSchemaObjectToZod(baseSchemaName) + const baseSchemaString = + this.processedSchemas.get(baseSchemaName) + + if (!baseSchemaString) { + throw new SchemaValidationError( + `Base schema "${baseSchemaName}" not found in processed schemas`, + baseSchemaName, + `allOf[0].$ref`, + 'Ensure the base schema is defined before using it in allOf' + ) + } + + const itemRef = (overrides as any).$defs.productItem.$ref + const itemSchemaName = this.resolveRef(itemRef).name + const itemSchemaString = + this.mapSchemaObjectToZod(itemSchemaName) + + const zodSchema = baseSchemaString.replace( + 'z.array(z.any())', + `z.array(${itemSchemaString})` + ) + this.processedSchemas.set(name, zodSchema) + return zodSchema + } + + const allOfSchemas = schema.allOf + .map((s) => this.mapSchemaObjectToZod(name, s)) + .join('.and(') + const zodSchema = allOfSchemas + ')'.repeat(schema.allOf.length - 1) + if (!schemaObject) this.processedSchemas.set(name, zodSchema) + return zodSchema + } + + // Expanded Union Handling: oneOf (discriminated & simple) and anyOf + if (schema.oneOf || schema.anyOf) { + const items = schema.oneOf || schema.anyOf || [] + if (schema.oneOf && schema.discriminator) { + const discriminator = schema.discriminator.propertyName + const options = items.map((s) => { + if (!isReferenceObject(s)) { + throw new SchemaValidationError( + 'oneOf with discriminator must use $ref objects', + name, + 'oneOf', + 'Move inline schemas to components.schemas and reference them with $ref' + ) + } + return this.mapSchemaObjectToZod(name, s) + }) + const zodSchema = `z.discriminatedUnion('${discriminator}', [${options.join(', ')}])` + if (!schemaObject) this.processedSchemas.set(name, zodSchema) + return zodSchema + } else { + const options = items.map((s) => + this.mapSchemaObjectToZod(name, s) + ) + const zodSchema = `z.union([${options.join(', ')}])` + if (!schemaObject) this.processedSchemas.set(name, zodSchema) + return zodSchema + } + } + + let zodString = 'z.any()' + + // Handle simple types defined as arrays (e.g. type: ["string", "null"]) for OAS 3.1 nullability + if (Array.isArray(schema.type)) { + if (schema.type.length === 2 && schema.type.includes('null')) { + const nonNullType = schema.type.find((t) => t !== 'null') + if (nonNullType) { + // Create a temporary schema object to recurse + const innerSchema = { + ...schema, + type: nonNullType, + } as OpenAPIV3_1.SchemaObject + // We must remove 'null' from the type array to avoid infinite recursion if we passed schema.type back, + // but here we are constructing a new single-type object. + // Note: We lose other constraints if they were specific to one type, but standard OAS constraints apply to the instance. + zodString = `${this.mapSchemaObjectToZod(name, innerSchema)}.nullable()` + } + } + } else { + switch (schema.type) { + case 'string': + if (schema.const) { + zodString = `z.literal('${schema.const}')` + } else { + zodString = 'z.string()' + if (schema.enum) { + zodString = `z.enum([${schema.enum.map((e) => `'${e}'`).join(', ')}])` + } + if (schema.format === 'date-time') + zodString = 'z.iso.datetime()' + else if (schema.format === 'email') + zodString = 'z.email()' + else if (schema.format === 'uri') zodString = 'z.url()' + else if (schema.format === 'uuid') + zodString = 'z.uuid()' // use z.uuid() (not z.string().uuid()) + if (schema.pattern) + zodString += `.regex(/${schema.pattern}/)` + } + break + case 'number': + zodString = 'z.number()' + if (schema.minimum !== undefined) + zodString += `.min(${schema.minimum})` + if (schema.maximum !== undefined) + zodString += `.max(${schema.maximum})` + break + case 'integer': + zodString = 'z.number().int()' + if (schema.minimum !== undefined) + zodString += `.min(${schema.minimum})` + if (schema.maximum !== undefined) + zodString += `.max(${schema.maximum})` + break + case 'boolean': + zodString = 'z.boolean()' + break + case 'array': { + if (!schema.items) + throw new Error('Array schema must have items defined.') + const itemSchema = this.mapSchemaObjectToZod( + name, + schema.items + ) + zodString = `z.array(${itemSchema})` + break + } + case 'object': + if (schema.properties) { + const properties = Object.entries(schema.properties) + .map(([key, value]) => { + const isRequired = + schema.required?.includes(key) + const zodType = this.mapSchemaObjectToZod( + key, + value + ) + const finalType = isRequired + ? zodType + : `${zodType}.optional()` + return `'${key}': ${finalType}` + }) + .join(',\n') + zodString = `z.object({ +${properties} +})` + } else { + zodString = `z.object({})` + } + + // Handle additionalProperties + if (schema.additionalProperties) { + if (schema.additionalProperties === true) { + zodString += `.catchall(z.any())` + } else if ( + typeof schema.additionalProperties === 'object' + ) { + const additionalSchema = this.mapSchemaObjectToZod( + name, + schema.additionalProperties + ) + if (schema.properties) { + zodString += `.catchall(${additionalSchema})` + } else { + // If no properties, it is a Record + zodString = `z.record(${additionalSchema}, z.any())` + } + } + } + break + default: + break + } + } + + if (!schemaObject) { + this.processedSchemas.set(name, zodString) + } + return zodString + } +} diff --git a/packages/openapi_generator/src/utils.ts b/packages/openapi_generator/src/utils.ts new file mode 100644 index 0000000..7864b8d --- /dev/null +++ b/packages/openapi_generator/src/utils.ts @@ -0,0 +1,54 @@ +/** + * Converts a string to PascalCase + * Handles kebab-case, snake_case, camelCase, spaces, and special characters + * + * @example + * toPascalCase("user-profile") // "UserProfile" + * toPascalCase("user_profile") // "UserProfile" + * toPascalCase("userProfile") // "UserProfile" + * toPascalCase("user profile") // "UserProfile" + * toPascalCase("$special-key") // "$SpecialKey" + * toPascalCase("APIKey") // "ApiKey" (treats consecutive capitals as one word) + */ +export function toPascalCase(str: string): string { + if (!str) return str + + if (!/[\s\-_]/.test(str)) { + const words = str + .replace(/([a-z])([A-Z])/g, '$1 $2') // Split camelCase: "userId" -> "user Id" + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') // Split "XMLParser" -> "XML Parser" + .split(' ') + .filter((word) => word.length > 0) + + if (words.length === 1) { + const match = str.match(/^([^a-zA-Z0-9]*)(.*)$/) + if (!match) return str + const [, prefix, rest] = match + if (!rest) return str + return prefix + rest.charAt(0).toUpperCase() + rest.slice(1) + } + } + + const words = str + .replace(/([a-z])([A-Z])/g, '$1 $2') // Split camelCase: "userId" -> "user Id" + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') // Split "XMLParser" -> "XML Parser" + .split(/[\s\-_]+/) // Split on spaces, hyphens, underscores + .filter((word) => word.length > 0) + + return words + .map((word) => { + // Preserve leading special characters (but not underscore which is a separator) + const match = word.match(/^([^a-zA-Z0-9]*)(.*)$/) + if (!match) return word + + const [, prefix, rest] = match + if (!rest) return word + + return ( + prefix + + rest.charAt(0).toUpperCase() + + rest.slice(1).toLowerCase() + ) + }) + .join('') +} diff --git a/packages/openapi_generator/tsconfig.json b/packages/openapi_generator/tsconfig.json new file mode 100644 index 0000000..f203bdd --- /dev/null +++ b/packages/openapi_generator/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "baseUrl": "." + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/openapi_generator/tsup.config.ts b/packages/openapi_generator/tsup.config.ts new file mode 100644 index 0000000..e3255d5 --- /dev/null +++ b/packages/openapi_generator/tsup.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'tsup' + +export default defineConfig((options) => ({ + entry: { + index: 'src/index.ts', + }, + watch: options.watch ? ['src/**/*'] : false, + clean: false, + dts: true, + outDir: 'dist', + // add swagger parser as inline deps + external: ['@apidevtools/swagger-parser'], + target: 'node18', + format: ['cjs'], + sourcemap: false, + shims: true, +})) diff --git a/packages/openapi_generator/vitest.config.ts b/packages/openapi_generator/vitest.config.ts new file mode 100644 index 0000000..4fd5cf0 --- /dev/null +++ b/packages/openapi_generator/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, +}) diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md new file mode 100644 index 0000000..30c8985 --- /dev/null +++ b/packages/web/CHANGELOG.md @@ -0,0 +1,12 @@ +# @freestylejs/fetch-web + +## 1.0.0 + +### Major Changes + +- Major V1 release for fluent, type-safe fetch client using typescript. + +### Patch Changes + +- Updated dependencies + - @freestylejs/fetch@1.0.0 diff --git a/packages/web/components.json b/packages/web/components.json new file mode 100644 index 0000000..dd7e879 --- /dev/null +++ b/packages/web/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/global.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/packages/web/content/docs/fetch/core-api/builder.mdx b/packages/web/content/docs/fetch/core-api/builder.mdx new file mode 100644 index 0000000..13b11c2 --- /dev/null +++ b/packages/web/content/docs/fetch/core-api/builder.mdx @@ -0,0 +1,153 @@ +--- +title: FetchBuilder +description: The fluent interface for constructing and configuring HTTP requests. +--- + +The `FetchBuilder` is the cornerstone of Fetch. It employs a **Fluent Interface** pattern, allowing you to chain configuration methods to define every aspect of an HTTP request. + +Each method call returns a **new, immutable** instance of the builder, ensuring that your base configurations remain reusable and side-effect-free. + +### Initialization + +You create a builder instance using the `f.builder()` factory method. + +```typescript +import { f } from '@freestylejs/fetch'; + +const builder = f.builder(); +``` + +--- + +### HTTP Configuration + +These methods configure the fundamental properties of the HTTP request. + +| Method | Signature | Description | +| :--- | :--- | :--- | +| `def_method` | `(method: FetchMethod) => Builder` | Sets the HTTP method (e.g., `'GET'`, `'POST'`, `'PUT'`, `'DELETE'`). Defaults to `'GET'`. | +| `def_url` | `(url: string) => Builder` | Sets the request URL. Supports [Dynamic Paths](/en/docs/fetch/core-api/router#dynamic-paths) using the `$` prefix (e.g., `/users/$id`). | +| `def_json` | `() => Builder` | Activates JSON mode. Automatically sets `Content-Type: application/json` and enables the `.json()` helper in the response handler. | +| `def_query_mode` | `(mode: 'throw' \| 'not_throw') => Builder` | Determines if the `query()` method should throw an error on failure (`'throw'`) or return `undefined` (`'not_throw'`). Defaults to `'throw'`. | + +#### Example + +```typescript +const getPost = f.builder() + .def_method('GET') + .def_url('https://api.example.com/posts/$postId') + .def_json(); + .build() +``` + +--- + +### Data Validation & Transformation + +These methods define the shape of data entering and leaving your request. They are crucial for **TypeScript inference**. + +| Method | Signature | Description | +| :--- | :--- | :--- | +| `def_body` | `(validator: (input: unknown) => T) => Builder` | Defines the request body schema. The `query()` method will require a `body` matching type `T`. | +| `def_searchparams` | `(validator: (input: unknown) => T) => Builder` | Defines the URL search parameters schema. The `query()` method will require `search` matching type `T`. | +| `def_response` | `(handler: (ctx) => R) => Builder` | Defines how to handle and transform the response. The return type `R` becomes the result of `query()`. | + +#### Example: Strictly Typed Request + +This example uses **Zod**, but you can use any validator function. + +```typescript +import { z } from 'zod'; + +const CreateUser = f.builder() + .def_method('POST') + .def_url('/users') + // 1. Enforce Body Shape + .def_body(z.object({ + name: z.string(), + email: z.email(), + }).parse) + // 2. Enforce Search Params + .def_searchparams(z.object({ + verbose: z.boolean().optional() + }).parse) + // 3. Parse Response + .def_response(async ({ json }) => { + const data = await json(); + return z.object({ id: z.string() }).parse(data); + }) + // 4. Build fetcher + .build() + +// Usage +await CreateUser.query({ + body: { name: 'Alice', email: 'alice@example.com' }, // Typed + search: { verbose: true } // Typed +}); +``` + +--- + +### Lifecycle Hooks + +Hooks allow you to inject logic at specific stages of the request lifecycle. + +| Method | Description | +| :--- | :--- | +| `def_request_handler` | **Pre-flight interceptor.** Receives the standard `Request` object immediately before it is sent. Return the modified request. | +| `def_fetch_err_handler` | **HTTP Error Handler.** Triggered when the response status is **not** 2xx. Receives `{ error: FetchResponseError, status: number }`. | +| `def_unknown_err_handler` | **Network Error Handler.** Triggered when the fetch call itself fails (e.g., network offline, DNS failure). Receives `{ error: unknown }`. | +| `def_final_handler` | **Finally Block.** Executed after the request completes, regardless of success or failure. Useful for cleanup or logging. | + +#### Example: Error Handling + +```typescript +const safeRequest = f.builder() + .def_fetch_err_handler(({ status, error }) => { + if (status === 401) { + console.error('Unauthorized access'); + // Redirect to login... + } + }) + .def_final_handler(() => { + console.log('Request finished'); + }); +``` + +--- + +### Middleware + +You can attach middleware to specific builders. For global middleware, it is recommended to use a shared builder instance or apply it manually. + +| Method | Signature | +| :--- | :--- | +| `def_middleware` | `(...middleware: MiddlewareFunction[]) => Builder` | + +See the [Middleware Guide](/en/docs/fetch/core-api/middleware) for detailed usage. + +--- + +### Default Fetch Options + +You can pre-configure standard `fetch` options. These defaults can still be overridden when executing the query. + +| Method | Description | +| :--- | :--- | +| `def_default_headers` | Sets default headers. Merged with headers provided at query time. | +| `def_default_cache` | Sets the cache mode (e.g., `'no-store'`, `'force-cache'`). | +| `def_default_credentials` | Sets credentials mode (e.g., `'include'`, `'same-origin'`). | +| `def_default_mode` | Sets the CORS mode (e.g., `'cors'`, `'no-cors'`). | +| `def_default_redirect` | Sets redirect behavior (e.g., `'follow'`, `'error'`). | +| `def_default_referrer` | Sets the referrer. | +| `def_default_referrer_policy` | Sets the referrer policy. | +| `def_default_integrity` | Sets the subresource integrity hash. | +| `def_default_keepalive` | Enables/disables keepalive. | +| `def_default_window` | Sets the associated window. | +| `def_default_priority` | Sets the request priority. | + +```typescript +const authenticatedBuilder = f.builder() + .def_default_headers({ 'Authorization': 'Bearer ...' }) + .def_default_cache('no-store'); +``` \ No newline at end of file diff --git a/packages/web/content/docs/fetch/core-api/error-handling.mdx b/packages/web/content/docs/fetch/core-api/error-handling.mdx new file mode 100644 index 0000000..afec80f --- /dev/null +++ b/packages/web/content/docs/fetch/core-api/error-handling.mdx @@ -0,0 +1,98 @@ +--- +title: Error Handling +description: Strategies for managing HTTP and network errors. +--- + +Fetch provides structured ways to handle errors, distinguishing between **HTTP Errors** (e.g., 404, 500) and **Network Errors** (e.g., offline, DNS failure). + +### Error Types + +#### 1. HTTP Errors (`def_fetch_err_handler`) + +These occur when the server returns a response with a status code outside the 200-299 range. + +The handler receives an object with: +- `error`: A `FetchResponseError` containing the response details. +- `status`: The HTTP status code. + +```typescript +f.builder() + .def_fetch_err_handler(async ({ error, status }) => { + if (status === 404) { + console.error('Resource not found'); + } + + // You can read the error body + const errorBody = await error.response.json(); + console.error('Server message:', errorBody.message); + }); +``` + +#### 2. Network Errors (`def_unknown_err_handler`) + +These occur when the `fetch` call itself fails to complete, usually due to network issues. + +```typescript +f.builder() + .def_unknown_err_handler(({ error }) => { + console.error('Network failure:', error); + // Show offline message to user + }); +``` + +### Handling Strategies + +#### Global Handling + +Create a base builder with your error handlers and reuse it across your application. + +```typescript +// api-client.ts +export const baseClient = f.builder() + .def_url('https://api.myapp.com') + .def_fetch_err_handler(({ status }) => { + if (status === 401) { + // Global logout trigger + logout(); + } + }); + +// users.ts +import { baseClient } from './api-client'; + +export const getUser = baseClient + .def_method('GET') + .def_url('/users/$id') + .build(); +``` + +#### Per-Request Handling + +You can add specific handlers to individual requests. Note that handlers are **additive**; both global and local handlers will run unless you manage them otherwise. + +```typescript +const getCriticalData = baseClient + .def_fetch_err_handler(({ status }) => { + // Specific handling for this request + if (status === 503) { + retryRequest(); + } + }) + .build(); +``` + +### Throwing vs. Returning Undefined + +By default, `query()` throws an error if the request fails. You can change this behavior using `def_query_mode`. + +```typescript +const safeFetcher = f.builder() + .def_query_mode('not_throw') // Returns undefined on error + .build(); + +const result = await safeFetcher.query(); + +if (!result) { + console.log('Request failed, but no exception was thrown.'); +} +``` diff --git a/packages/web/content/docs/fetch/core-api/f.mdx b/packages/web/content/docs/fetch/core-api/f.mdx new file mode 100644 index 0000000..3a477c2 --- /dev/null +++ b/packages/web/content/docs/fetch/core-api/f.mdx @@ -0,0 +1,18 @@ +--- +title: f (Namespace) +description: The global entry point for Fetch. +--- + +The `f` namespace is your starting point. It bundles the core factories and classes into a single, convenient import. + +```typescript +import { f } from '@freestylejs/fetch'; +``` + +### API Reference + +| Property | Type | Description | +| :--- | :--- | :--- | +| [`f.builder`](/en/docs/fetch/core-api/builder) | `() => FetchBuilder` | **Factory**. Creates a new [FetchBuilder](/en/docs/fetch/core-api/builder) instance. Start here to define a new request. | +| [`f.router`](/en/docs/fetch/core-api/router) | `Function` | **Utility**. Creates a structured [Router](/en/docs/fetch/core-api/router) client. | +| [`f.Middleware`](/en/docs/fetch/core-api/middleware) | `Class` | **Class**. Instantiates a new [Middleware](/en/docs/fetch/core-api/middleware) manager. | \ No newline at end of file diff --git a/packages/web/content/docs/fetch/core-api/meta.json b/packages/web/content/docs/fetch/core-api/meta.json new file mode 100644 index 0000000..8979ee6 --- /dev/null +++ b/packages/web/content/docs/fetch/core-api/meta.json @@ -0,0 +1,4 @@ +{ + "title": "Core API", + "pages": ["f", "builder", "unit", "router", "middleware", "error-handling"] +} diff --git a/packages/web/content/docs/fetch/core-api/middleware.mdx b/packages/web/content/docs/fetch/core-api/middleware.mdx new file mode 100644 index 0000000..e989951 --- /dev/null +++ b/packages/web/content/docs/fetch/core-api/middleware.mdx @@ -0,0 +1,104 @@ +--- +title: Middleware +description: Intercept and modify requests and responses in the pipeline. +--- + +Middleware provides a powerful mechanism to wrap request execution. It is ideal for cross-cutting concerns such as authentication, logging, global error handling, or response transformation. + +### Creating Middleware + +The `Middleware` class manages a stack of interceptor functions. + +```typescript +import { f } from '@freestylejs/fetch'; + +const authMiddleware = new f.Middleware(); + +// Define a middleware function +authMiddleware.use(async (request, next) => { + // --- Request Phase --- + // Modify the request before it's sent + const token = localStorage.getItem('token'); + if (token) { + request.headers.set('Authorization', `Bearer ${token}`); + } + + // --- Execution --- + // Call 'next' to proceed to the next middleware or the network call + const response = await next(request); + + // --- Response Phase --- + // Inspect or modify the response + if (response.status === 401) { + // Handle token expiration + console.warn('Token expired!'); + } + + return response; +}); +``` + +### Common Use Cases + +#### 1. Logging Middleware + +```typescript +const loggingMiddleware = new f.Middleware(); + +loggingMiddleware.use(async (req, next) => { + const start = Date.now(); + console.log(`[REQ] ${req.method} ${req.url}`); + + try { + const res = await next(req); + const duration = Date.now() - start; + console.log(`[RES] ${res.status} (${duration}ms)`); + return res; + } catch (err) { + console.error(`[ERR] Request failed`, err); + throw err; + } +}); +``` + +### Applying Middleware + +You can apply middleware to any `FetchBuilder` using `.def_middleware()`. + +```typescript +const client = f.builder() + // Spread the procedures from your middleware instance + .def_middleware(...loggingMiddleware.procedures, ...authMiddleware.procedures) + .def_url('https://api.example.com'); +``` + +### Execution Pipeline + +Middleware follows an "onion" or "stack" model (similar to Koa or Express). + +1. **Request Phase**: Middleware functions run in the order they were added. +2. **Network Call**: The actual `fetch` is executed. +3. **Response Phase**: Middleware functions resume execution in **reverse order** (after `await next()`). + +```typescript +const m = new f.Middleware(); + +m.use(async (req, next) => { + console.log('1. Request'); + await next(req); + console.log('4. Response'); +}); + +m.use(async (req, next) => { + console.log('2. Request'); + await next(req); + console.log('3. Response'); +}); + +// Output: +// 1. Request +// 2. Request +// (Network Request) +// 3. Response +// 4. Response +``` \ No newline at end of file diff --git a/packages/web/content/docs/fetch/core-api/router.mdx b/packages/web/content/docs/fetch/core-api/router.mdx new file mode 100644 index 0000000..648a3bc --- /dev/null +++ b/packages/web/content/docs/fetch/core-api/router.mdx @@ -0,0 +1,136 @@ +--- +title: Router +description: Organize endpoints into a hierarchical, type-safe client. +--- + +The `router` function allows you to structure your API client to mirror your backend's URL hierarchy. It groups multiple `FetchUnit`s into a single, nested object, providing a clean and organized way to access your endpoints. + +### Visualizing the Router + +The router creates a tree structure where leaves are executable requests and nodes are path segments. + +```mermaid +graph TD + %% --- Styles --- + classDef root fill:#6c5ce7,stroke:#fff,stroke-width:2px,color:#fff,font-weight:bold; + classDef pathNode fill:#dfe6e9,stroke:#b2bec3,stroke-width:2px,color:#2d3436,font-weight:bold; + classDef param fill:#a29bfe,stroke:#6c5ce7,stroke-width:2px,stroke-dasharray: 5 5,color:#fff,font-weight:bold; + + %% Action Buttons (rx:5, ry:5 for slightly rounded corners) + classDef get fill:#e0f7fa,stroke:#00bcd4,stroke-width:2px,color:#006064,rx:5,ry:5; + classDef post fill:#f1f8e9,stroke:#8bc34a,stroke-width:2px,color:#33691e,rx:5,ry:5; + classDef delete fill:#ffebee,stroke:#ef5350,stroke-width:2px,color:#b71c1c,rx:5,ry:5; + + %% --- Nodes --- + + API([" API Root     "]):::root + + Users[/" /users     "/]:::pathNode + UserId{{" :userId     "}}:::param + Posts[/" /posts     "/]:::pathNode + + GetUsers["GET.ListUsers          "]:::get + PostUsers["POST.CreateUser          "]:::post + GetUser["GET.UserDetails          "]:::get + DelUser["DELETE.RemoveUser          "]:::delete + GetPosts["GET.UserPosts          "]:::get + + %% --- Connections --- + API --> Users + Users --> GetUsers + Users --> PostUsers + Users --> UserId + + UserId --> GetUser + UserId --> DelUser + UserId --> Posts + + Posts --> GetPosts + + %% Link Styling + linkStyle default stroke:#b2bec3,stroke-width:2px,fill:none; +``` + +### Defining a Router + +You define a router by passing a base URL and a structure object. + +```typescript +import { f } from '@freestylejs/fetch'; + +const api = f.router('https://api.example.com/v1', { + // Static path: /v1/health + health: { + GET: f.builder() + }, + + // Nested path: /v1/users + users: { + // HTTP Method: GET /v1/users + GET: f.builder().def_json(), + + // Dynamic path: /v1/users/:userId + $userId: { + // GET /v1/users/:userId + GET: f.builder().def_json(), + + // DELETE /v1/users/:userId + DELETE: f.builder(), + + // Deeply nested: /v1/users/:userId/posts + posts: { + GET: f.builder().def_json() + } + } + } +}); +``` + +### Dynamic Paths (`$`) + +To define a dynamic path segment (e.g., `/users/:id`), use a key starting with `$`. + +- **Syntax**: `$paramName` (e.g., `$userId`, `$slug`). +- **Inference**: The router detects these keys and enforces them as required arguments in the `path` object when you call `query()`. +- **Path Building**: The router automatically constructs the full URL by replacing the dynamic segment with the provided value. + +#### Usage + +```typescript +// Requesting: GET /v1/users/123 +await api.users.$userId.GET.query({ + path: { + userId: '123' // Type-safe and required + } +}); + +// Requesting: GET /v1/users/123/posts +await api.users.$userId.posts.GET.query({ + path: { + userId: '123' + } +}); +``` + +### Type Inference + +To use your API client's type throughout your application (e.g., in React components or Vue composables), you can infer it using `GetRouterConfig`. + +This type represents the **return values** of your endpoints, not the router structure itself. + +```typescript +import type { GetRouterConfig } from '@freestylejs/fetch'; + +// Infers the shape of the responses +type ApiClient = GetRouterConfig; + +// Example: Typing a function that uses the API +async function loadUser(id: string): Promise { + return await api.users.$userId.GET.query({ path: { userId: id } }); +} +``` + +### Best Practices + +1. **Shared Configuration**: Create a "base builder" with common headers, middleware, and error handling, then reuse it for all routes. +2. **Validation**: Use `.def_response()` on every route to ensure your runtime data matches your TypeScript types. \ No newline at end of file diff --git a/packages/web/content/docs/fetch/core-api/unit.mdx b/packages/web/content/docs/fetch/core-api/unit.mdx new file mode 100644 index 0000000..878062a --- /dev/null +++ b/packages/web/content/docs/fetch/core-api/unit.mdx @@ -0,0 +1,64 @@ +--- +title: FetchUnit +description: The executable request object. +--- + +The `FetchUnit` is the final product of the `FetchBuilder` **created by `build` method**. It represents a fully configured request ready to be executed. + +### Methods + +#### `query(options?)` + +Executes the HTTP request. + +- **Returns**: `Promise` (where `T` is your defined response type). +- **Options**: + - `path`: Object for dynamic path parameters (required if URL has `$param`). + - `body`: Request body (required if `def_body` is set). + - `search`: Search parameters (required if `def_searchparams` is set). + - `headers`: Additional headers to merge. + - `signal`: `AbortSignal` for cancellation. + +```typescript +const response = await unit.query({ + path: { id: '123' }, + body: { name: 'New Name' } +}); +``` + +#### `set_options(options)` + +Creates a **new** `FetchUnit` with updated default fetch options. This is useful for overriding defaults (like headers) for a specific instance without rebuilding the whole chain. + +```typescript +const authorizedUnit = unit.set_options({ + headers: { 'Authorization': 'Bearer new-token' } +}); +``` + +#### `copy()` + +Creates a deep copy of the `FetchUnit`. + +```typescript +const clone = unit.copy(); +``` + +### Aborting Requests + +You can cancel a request using the standard `AbortController`. + +```typescript +const controller = new AbortController(); + +unit.query({ + signal: controller.signal +}).catch(err => { + if (err.name === 'AbortError') { + console.log('Request aborted'); + } +}); + +// Cancel the request +controller.abort(); +``` \ No newline at end of file diff --git a/packages/web/content/docs/fetch/index.mdx b/packages/web/content/docs/fetch/index.mdx new file mode 100644 index 0000000..49ceca0 --- /dev/null +++ b/packages/web/content/docs/fetch/index.mdx @@ -0,0 +1,49 @@ +--- +title: Fetch +description: A type-safe, fluent fetch wrapper for TypeScript. +--- + +import { Card, Cards } from 'fumadocs-ui/components/card' +import { NetworkIcon, RouteIcon, CodeIcon, LayersIcon } from 'lucide-react' + +## Philosophy + +**Fetch** reimagines HTTP requests in TypeScript. It moves away from loose, untyped `fetch` calls to a structured, type-safe, and fluent API. + +It is built on the **Builder Pattern**, ensuring that every request is constructed correctly before it is executed. With first-class support for **Zod** (or any validator), it bridges the gap between runtime data and static types. + +## Core Features + + + } + title="Fluent Builder" + description="Construct requests using a chainable, immutable builder API. Readable, maintainable, and safe." + href="/en/docs/fetch/core-api/builder" + /> + } + title="Type-Safe Requests" + description="Infer request bodies, search parameters, and responses automatically. No more 'any'." + href="/en/docs/fetch/introduction/core-concepts" + /> + } + title="Structured Routing" + description="Organize your API endpoints into a hierarchical, type-safe router structure." + href="/en/docs/fetch/core-api/router" + /> + } + title="Middleware" + description="Intercept and modify requests and responses globally or per-route." + href="/en/docs/fetch/core-api/middleware" + /> + + +## What is Next? + + + + + diff --git a/packages/web/content/docs/fetch/introduction/core-concepts.mdx b/packages/web/content/docs/fetch/introduction/core-concepts.mdx new file mode 100644 index 0000000..bbd21b7 --- /dev/null +++ b/packages/web/content/docs/fetch/introduction/core-concepts.mdx @@ -0,0 +1,54 @@ +--- +title: Core Concepts +description: Understand the architecture of Fetch. +--- + +Fetch is built on three main pillars: the **Builder**, the **Unit**, and the **Router**. Understanding these concepts is key to using the library effectively. + +### The Builder Pattern + +Unlike the standard `fetch` API, where you pass a large configuration object, Fetch uses a **Fluent Builder** pattern. + +- **Immutability**: Every method call on the builder returns a *new* instance. This allows you to create a "base builder" and extend it for specific requests without side effects. +- **Progressive Typing**: As you add definitions (like `.def_body()` or `.def_response()`), the builder's internal type definition evolves. This ensures that the final execute function knows exactly what inputs it needs and what output it returns. + +```typescript +const base = f.builder().def_url('https://api.com'); + +// New instance, 'base' is unchanged +const postRequest = base.def_method('POST'); +``` + +### The Fetch Unit + +When you call `.build()`, you get a **FetchUnit**. A Unit is a sealed, executable representation of a request. + +- **Execution**: The Unit exposes the `.query()` method. +- **Requirements**: If you defined dynamic URL parameters (e.g., `$id`) or a request body in the builder, the `.query()` method will *require* them as arguments. You cannot compile a request that is missing data. + +```typescript +// If the builder defined a body validator... +const unit = builder.def_body(schema.parse).build(); + +// ...the query method forces you to provide it. +unit.query({ body: ... }); +``` + +### The Router + +The **Router** is a structural tool. It doesn't change how requests are executed but organizes *where* they live. + +- **Mirroring**: The router structure typically mirrors your API's URL path structure. +- **Namespace**: It groups related endpoints (e.g., `api.users.get`, `api.users.post`). +- **Type Inference**: You can export the type of your router (`GetRouterConfig`), allowing you to use your API client type in other parts of your application (like React hooks or Vue composables) without importing the actual runtime logic. + +### Type Safety + +Fetch aims for **end-to-end type safety**. + +1. **Input**: You validate inputs (body, search params) using schemas. +2. **Transport**: The builder tracks these types. +3. **Output**: You validate responses using schemas. +4. **Result**: The `.query()` result is fully typed. + +This eliminates the need to manually cast `response.json() as User`. If the server response doesn't match the schema, the validation logic throws, preventing runtime errors from propagating silently. diff --git a/packages/web/content/docs/fetch/introduction/getting-started.mdx b/packages/web/content/docs/fetch/introduction/getting-started.mdx new file mode 100644 index 0000000..5b2bf57 --- /dev/null +++ b/packages/web/content/docs/fetch/introduction/getting-started.mdx @@ -0,0 +1,96 @@ +--- +title: Getting Started +description: Install and make your first request with Fetch. +--- + +import { Steps, Step } from 'fumadocs-ui/components/steps'; +import { Tabs, Tab } from 'fumadocs-ui/components/tabs'; + +### Quick Start + +This guide will help you set up Fetch and start making type-safe HTTP requests. + + + +### 1. Prerequisites + +Ensure you have **TypeScript 4.5+** installed. Fetch relies on advanced TypeScript features for inference. + + + +### 2. Installation + +Install the `@freestylejs/fetch` package. We also recommend `zod` for validation. But you can use any library you want. + + + + ```bash + npm install @freestylejs/fetch zod + ``` + + + ```bash + pnpm add @freestylejs/fetch zod + ``` + + + ```bash + yarn add @freestylejs/fetch zod + ``` + + + + + +### 3. Create a Request Builder + +Import the `f` namespace to define a request. In this example, we'll fetch a user profile. + +```typescript +import { f } from '@freestylejs/fetch'; +import { z } from 'zod'; + +// 1. Define the response schema +const UserSchema = z.object({ + id: z.number(), // JSONPlaceholder uses numbers for IDs in response + name: z.string(), + email: z.email(), +}); + +// 2. Build the request +const getUser = f.builder() + .def_method('GET') + // Define URL with a dynamic parameter ($id) + .def_url('https://jsonplaceholder.typicode.com/users/$id') + // Enable JSON mode (sets Content-Type and adds .json() helper) + .def_json() + // Handle and validate the response + .def_response(async ({ json }) => { + const data = await json(); + return UserSchema.parse(data); + }) + .build(); +``` + + +### 4. Execute the Request + +Use the `.query()` method to execute the request. Notice how the builder forces you to provide the `id` parameter because we defined `$id` in the URL. + +```typescript +// The type of 'user' is inferred automatically as { id: string, name: string, email: string } +const user = await getUser.query({ + path: { + id: '1' // Type-safe: inferred from url (string because URL params are strings) + } +}); + +console.log(`Hello, ${user.name}!`); +``` + + + +## Next Steps + +- Learn about the **[Core Concepts](/en/docs/fetch/introduction/core-concepts)**. +- See how to build a **[Router](/en/docs/fetch/core-api/router)**. diff --git a/packages/web/content/docs/fetch/introduction/meta.json b/packages/web/content/docs/fetch/introduction/meta.json new file mode 100644 index 0000000..9f22d22 --- /dev/null +++ b/packages/web/content/docs/fetch/introduction/meta.json @@ -0,0 +1,4 @@ +{ + "title": "Introduction", + "pages": ["getting-started", "core-concepts"] +} diff --git a/packages/web/content/docs/fetch/meta.json b/packages/web/content/docs/fetch/meta.json new file mode 100644 index 0000000..956b8f2 --- /dev/null +++ b/packages/web/content/docs/fetch/meta.json @@ -0,0 +1,4 @@ +{ + "title": "Fetch", + "pages": ["index", "introduction", "core-api"] +} diff --git a/packages/web/content/docs/index.mdx b/packages/web/content/docs/index.mdx new file mode 100644 index 0000000..8ffb25a --- /dev/null +++ b/packages/web/content/docs/index.mdx @@ -0,0 +1,26 @@ +--- +title: Documentation +description: Welcome to the Metal-TS documentation. +--- + +import { Card, Cards } from 'fumadocs-ui/components/card'; +import { BoxIcon, FileCodeIcon } from 'lucide-react'; + +## Modules + +Welcome to the Metal-TS project documentation. Select a module to explore: + + + } + title="Fetch" + description="A type-safe, fluent fetch wrapper for TypeScript." + href="/en/docs/fetch" + /> + } + title="OpenAPI Generator" + description="Generate type-safe API clients from OpenAPI specifications." + href="/en/docs/openapi-generator" + /> + diff --git a/packages/web/content/docs/meta.json b/packages/web/content/docs/meta.json new file mode 100644 index 0000000..78ba32f --- /dev/null +++ b/packages/web/content/docs/meta.json @@ -0,0 +1,4 @@ +{ + "title": "Overview", + "pages": ["index", "fetch", "openapi-generator"] +} diff --git a/packages/web/content/docs/openapi-generator/cli-reference.mdx b/packages/web/content/docs/openapi-generator/cli-reference.mdx new file mode 100644 index 0000000..7ca9658 --- /dev/null +++ b/packages/web/content/docs/openapi-generator/cli-reference.mdx @@ -0,0 +1,175 @@ +--- +title: CLI Reference +description: Complete reference for the OpenAPI Generator command-line interface. +--- + +## Commands + +The OpenAPI Generator provides a single `generate` command for creating API clients. + +### `generate` + +Generate a type-safe API client from an OpenAPI specification. + +```bash +openapi-generator generate [options] +``` + +## Options + +| Option | Alias | Type | Required | Description | +| ------ | ----- | ---- | -------- | ----------- | +| `--input ` | `-i` | string | Yes | Path to the OpenAPI specification file (JSON or YAML). The generator automatically detects the format based on the file extension. | + +| `--output ` | `-o` | string | Yes | Directory where generated files will be saved | + +> **Note:** Both `.json` and `.yaml`/`.yml` files are supported. No additional flags are required. +## Examples + +### Basic Generation + +Generate client files from a local specification: + +```bash +openapi-generator generate \ + --input ./api-spec.yaml \ + --output ./src/api +``` + +### Using Relative Paths + +Paths are resolved relative to the current working directory: + +```bash +openapi-generator generate \ + -i ../specs/api.json \ + -o ./generated +``` + +### With npx + +Run the generator without installation: + +```bash +npx create-freestyle-fetch generate \ + --input ./openapi.json \ + --output ./src/generated +``` + +## Output Files + +The `generate` command creates three files in the output directory: + +### `models.ts` + +Contains Zod schemas for all components defined in your OpenAPI specification: + +```typescript +import { z } from 'zod' + +export const User = z.object({ + id: z.string(), + name: z.string(), + email: z.email() +}) + +export type UserModel = z.infer +``` + +### `api.ts` + +Contains the type-safe router with all API endpoints: + +```typescript +import { f } from '@freestylejs/fetch' +import { z } from 'zod' +import * as Model from './models' + +export const api = f.router('https://api.example.com', { + users: { + $userId: { + GET: f.builder() + .def_json() + .def_response(async ({ json }) => Model.User.parse(await json())) + } + } +}) +``` + +### `index.ts` + +Re-exports all generated code for convenience: + +```typescript +export * from './api' +export * from './models' +``` + +## Error Handling + +The CLI provides clear error messages for common issues: + +### Invalid Specification + +If the OpenAPI file is invalid or cannot be parsed: + +```bash +Error: Failed to parse OpenAPI specification + File: ./api-spec.yaml + Reason: Invalid YAML syntax at line 42 +``` + +### Missing File + +If the input file does not exist: + +```bash +Error: Input file not found + Path: ./api-spec.json +``` + +### Output Directory Issues + +The generator creates the output directory if it doesn't exist. Existing files are overwritten. + +## Best Practices + +### Version Control + +Add generated files to `.gitignore` and regenerate them during your build process: + +```bash +# .gitignore +src/generated/ +``` + +```json +{ + "scripts": { + "generate": "openapi-generator generate -i ./spec.json -o ./src/generated", + "prebuild": "npm run generate" + } +} +``` + +### CI/CD Integration + +Integrate generation into your CI/CD pipeline: + +```yaml +# .github/workflows/build.yml +steps: + - name: Generate API Client + run: | + npx create-freestyle-fetch generate \ + --input ./openapi.json \ + --output ./src/generated + + - name: Build Application + run: npm run build +``` + +## Related + +- [Getting Started](/en/docs/openapi-generator/getting-started) - Installation and basic usage +- [Generated Code](/en/docs/openapi-generator/generated-code) - Understanding the output diff --git a/packages/web/content/docs/openapi-generator/generated-code.mdx b/packages/web/content/docs/openapi-generator/generated-code.mdx new file mode 100644 index 0000000..b5ea493 --- /dev/null +++ b/packages/web/content/docs/openapi-generator/generated-code.mdx @@ -0,0 +1,205 @@ +--- +title: Generated Code +description: Understanding the structure and usage of generated API clients. +--- + +## Overview + +The OpenAPI Generator creates three TypeScript files that work together to provide a complete, type-safe API client. + +## File Structure + +### models.ts + +Contains all Zod schemas derived from your OpenAPI components: + +- **Schemas** - Converted from `components.schemas` definitions +- **Type exports** - TypeScript types inferred from Zod schemas +- **Validation** - Runtime validation using Zod + +Example generated model: + +```typescript +import { z } from 'zod' + +export const User = z.object({ + id: z.string(), + username: z.string(), + email: z.email(), + createdAt: z.iso.datetime() +}) + +export type UserModel = z.infer +``` + +### api.ts + +Contains the hierarchical router mapping all API endpoints: + +- **Base URL** - Extracted from `servers[0].url` in the specification +- **Nested routes** - Path segments become nested objects +- **HTTP methods** - Each method is a pre-configured builder +- **Type inference** - Full TypeScript support for all operations + +Example generated API: + +```typescript +import { f } from '@freestylejs/fetch' +import { z } from 'zod' +import * as Model from './models' + +export const api = f.router('https://api.example.com', { + users: { + $userId: { + GET: f.builder() + .def_json() + .def_response(async ({ json }) => Model.User.parse(await json())), + + posts: { + GET: f.builder() + .def_json() + .def_searchparams(z.object({ + page: z.number().optional(), + limit: z.number().optional() + }).parse) + .def_response(async ({ json }) => z.array(Model.Post).parse(await json())) + } + } + } +}) +``` + +### index.ts + +Re-exports all generated code: + +```typescript +export * from './api' +export * from './models' +``` + +## Usage Patterns + +### Path Parameters + +Dynamic path segments use the `$paramName` syntax: + +```typescript +// OpenAPI: /users/{userId} +// Generated: api.users.$userId + +const user = await api.users.$userId.GET({ + params: { userId: '123' } +}) +``` + +### Query Parameters + +Query parameters are validated with Zod schemas: + +```typescript +// OpenAPI: /users?page=1&limit=10 +// Generated with searchparams + +const users = await api.users.GET({ + query: { + page: 1, + limit: 10 + } +}) +``` + +### Request Bodies + +Request bodies are validated against Zod schemas: + +```typescript +// OpenAPI: POST /users with body +// Generated with def_body + +const newUser = await api.users.POST({ + body: { + username: 'john', + email: 'john@example.com' + } +}) +``` + +### Response Validation + +Responses are automatically validated: + +```typescript +// Response is validated with zod at runtime +const user = await api.users.$userId.GET({ + params: { userId: '123' } +}) + +// TypeScript knows the exact shape +user.username // string +user.email // string +``` + +## Type Inference + +The generated client provides complete type inference: + +### Request Types + +```typescript +// TypeScript infers the shape of params, query, and body +api.users.$userId.GET({ + params: { userId: '123' }, // Type: { userId: string } + query: { include: 'posts' } // Type: { include?: string } +}) +``` + +### Response Types + +```typescript +// Response type is inferred from the Zod schema +const user = await api.users.$userId.GET({ + params: { userId: '123' } +}) + +// user: UserModel +``` + +## Schema Conversion + +The generator converts OpenAPI types to Zod schemas: + +| OpenAPI Type | Zod Schema | Example | +| ------------ | ---------- | ------- | +| `string` | `z.string()` | `"hello"` | +| `number` | `z.number()` | `42` | +| `integer` | `z.number().int()` | `10` | +| `boolean` | `z.boolean()` | `true` | +| `array` | `z.array(...)` | `[1, 2, 3]` | +| `object` | `z.object({...})` | `{ key: "value" }` | +| `string` (format: date-time) | `z.iso.datetime()` | `"2024-01-01T00:00:00Z"` | +| `string` (format: email) | `z.email()` | `"user@example.com"` | +| `string` (format: uuid) | `z.uuid()` | `"123e4567-e89b-12d3-..."` | +| `string` (format: uri) | `z.url()` | `"https://example.com"` | +| `string` (enum) | `z.enum([...])` | `"active"` | +| `allOf` | `Schema1.and(Schema2)` | Intersection | +| `oneOf` | `z.union([...])` | Union | +| `oneOf` (discriminator) | `z.discriminatedUnion(...)` | Discriminated union | + +## Naming Conventions + +The generator converts OpenAPI names to PascalCase: + +- `user-profile` โ†’ `UserProfile` +- `api_key` โ†’ `ApiKey` +- `userSettings` โ†’ `UserSettings` + +Special characters like `$` are preserved: + +- `$special` โ†’ `$Special` + +## Related + +- [Validation](/en/docs/openapi-generator/validation) - Runtime validation details +- [CLI Reference](/en/docs/openapi-generator/cli-reference) - Generation options +- [Fetch Builder](/en/docs/fetch/core-api/builder) - Understanding the builder API diff --git a/packages/web/content/docs/openapi-generator/getting-started.mdx b/packages/web/content/docs/openapi-generator/getting-started.mdx new file mode 100644 index 0000000..18f169b --- /dev/null +++ b/packages/web/content/docs/openapi-generator/getting-started.mdx @@ -0,0 +1,95 @@ +--- +title: Getting Started +description: Install and use the OpenAPI Generator to create type-safe API clients. +--- + +## Installation + +Install the OpenAPI Generator package in your project: + +```bash +npm install --save-dev create-freestyle-fetch +``` + +Or use it directly with `npx` without installation: + +```bash +npx create-freestyle-fetch generate --input ./spec.yaml --output ./src/api +``` + +## Prerequisites + +Before using the OpenAPI Generator, ensure you have: + +- **Node.js 18+** installed on your system +- An **Open API 3.1 specification file** (JSON or YAML format) +- The **@freestylejs/fetch** library installed in your project (added automatically as a dependency) + +## Basic Usage + +### Generate Your First Client + +Create an API client from your OpenAPI specification: + +```bash +npx create-freestyle-fetch generate \ + --input ./openapi.yaml \ + --output ./src/generated +``` + +This command generates three files in the output directory: + +- **`models.ts`** - Zod schemas for all data types +- **`api.ts`** - Type-safe router with all API endpoints +- **`index.ts`** - Re-exports for convenient imports + +### Use the Generated Client + +Import and use the generated API client in your application: + +```typescript +import { api } from './generated' + +// GET request with path parameters +const user = await api.users.$userId.GET({ + params: { userId: '123' } +}) + +// POST request with request body +const newPost = await api.posts.POST({ + body: { + title: 'Hello World', + content: 'This is my first post' + } +}) + +// GET request with query parameters +const posts = await api.posts.GET({ + query: { + page: 1, + limit: 10, + sort: 'created_at' + } +}) +``` + +## Project Structure + +After generation, your project will have: + +``` +your-project/ +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ generated/ +โ”‚ โ”‚ โ”œโ”€โ”€ models.ts # Zod schemas +โ”‚ โ”‚ โ”œโ”€โ”€ api.ts # API router +โ”‚ โ”‚ โ””โ”€โ”€ index.ts # Exports +โ”‚ โ””โ”€โ”€ your-code.ts # Your application +โ””โ”€โ”€ openapi.yaml # API specification +``` + +## Next Steps + +- [CLI Reference](/en/docs/openapi-generator/cli-reference) - Learn all CLI options +- [Generated Code](/en/docs/openapi-generator/generated-code) - Understand the output +- [Validation](/en/docs/openapi-generator/validation) - Use Zod validation diff --git a/packages/web/content/docs/openapi-generator/index.mdx b/packages/web/content/docs/openapi-generator/index.mdx new file mode 100644 index 0000000..9f5676c --- /dev/null +++ b/packages/web/content/docs/openapi-generator/index.mdx @@ -0,0 +1,73 @@ +--- +title: OpenAPI Generator +description: Generate type-safe API clients from OpenAPI specifications. +--- + +import { Card, Cards } from 'fumadocs-ui/components/card' +import { TerminalIcon, FileCodeIcon, ZapIcon, ShieldCheckIcon } from 'lucide-react' + +## What is OpenAPI Generator? + +**OpenAPI Generator** is a CLI tool that transforms OpenAPI specifications into fully type-safe TypeScript API clients powered by the `@freestylejs/fetch` library. + +It eliminates manual API client code and keeps your client in sync with your API specification. The generated code uses Zod for runtime validation and provides complete type inference for requests and responses. + +## Key Features + + + } + title="Simple CLI" + description="Generate clients with a single command. No configuration required." + href="/en/docs/openapi-generator/cli-reference" + /> + } + title="Type-Safe Output" + description="Generates TypeScript code with full type inference for all API operations." + href="/en/docs/openapi-generator/generated-code" + /> + } + title="Zod Validation" + description="Automatic runtime validation using Zod schemas for request and response data." + href="/en/docs/openapi-generator/validation" + /> + } + title="Fetch Integration" + description="Generated clients use the @freestylejs/fetch fluent builder API." + href="/en/docs/fetch" + /> + + +## Quick Example + +Generate a fully typed API client from your OpenAPI specification: + +```bash +npx create-freestyle-fetch generate \ + --input ./api-spec.json \ + --output ./src/api +``` + +Use the generated client in your code: + +```typescript +import { api } from './api' + +// Fully typed request and response +const user = await api.users.$userId.GET({ + params: { userId: '123' } +}) + +// TypeScript knows the shape of `user` +console.log(user.name, user.email) +``` + +## What is Next? + + + + + diff --git a/packages/web/content/docs/openapi-generator/meta.json b/packages/web/content/docs/openapi-generator/meta.json new file mode 100644 index 0000000..4a1c63a --- /dev/null +++ b/packages/web/content/docs/openapi-generator/meta.json @@ -0,0 +1,10 @@ +{ + "title": "OpenAPI Generator", + "pages": [ + "index", + "getting-started", + "cli-reference", + "generated-code", + "validation" + ] +} diff --git a/packages/web/content/docs/openapi-generator/validation.mdx b/packages/web/content/docs/openapi-generator/validation.mdx new file mode 100644 index 0000000..6604afe --- /dev/null +++ b/packages/web/content/docs/openapi-generator/validation.mdx @@ -0,0 +1,290 @@ +--- +title: Validation +description: Runtime validation with Zod in generated API clients. +--- + +## Overview + +The OpenAPI Generator creates Zod schemas for all data types in your API specification. These schemas provide runtime validation for requests and responses, ensuring type safety at both compile-time and runtime. + +## Automatic Validation + +### Response Validation + +All responses are automatically validated before being returned: + +```typescript +const user = await api.users.$userId.GET({ + params: { userId: '123' } +}) + +// If the response doesn't match the User schema, Zod throws an error +// Otherwise, user is guaranteed to match UserModel +``` + +The validation happens in the `.def_response()` method: + +```typescript +.def_response(async ({ json }) => Model.User.parse(await json())) +``` + +### Request Body Validation + +Request bodies are validated using the `.def_body()` method: + +```typescript +POST: f.builder() + .def_json() + .def_body(Model.CreateUser.parse) +``` + +When you make a request, the body is validated: + +```typescript +await api.users.POST({ + body: { + username: 'john', + email: 'invalid-email' // โŒ Zod error: Invalid email + } +}) +``` + +### Query Parameter Validation + +Query parameters are validated with `.def_searchparams()`: + +```typescript +GET: f.builder() + .def_json() + .def_searchparams(z.object({ + page: z.number().optional(), + limit: z.number().max(100).optional() + }).parse) +``` + +Invalid query parameters throw errors: + +```typescript +await api.users.GET({ + query: { + page: 1, + limit: 200 // โŒ Zod error: Number must be less than or equal to 100 + } +}) +``` + +## Handling Validation Errors + +### Zod Error Structure + +When validation fails, Zod throws a `ZodError`: + +```typescript +import { ZodError } from 'zod' + +try { + const user = await api.users.$userId.GET({ + params: { userId: '123' } + }) +} catch (error) { + if (error instanceof ZodError) { + console.error('Validation failed:', error.errors) + // [{ path: ['email'], message: 'Invalid email' }] + } +} +``` + +### Custom Error Handling + +Use the fetch library's error handlers for more control: + +```typescript +const user = await api.users.$userId.GET({ + params: { userId: '123' } +}).fetch({ + onError: (error) => { + if (error instanceof ZodError) { + // Handle validation errors + console.error('Data validation failed:', error.flatten()) + } else { + // Handle other errors + console.error('Request failed:', error) + } + } +}) +``` + +## Schema Constraints + +The generator translates OpenAPI constraints to Zod: + +### String Constraints + +```typescript +// OpenAPI +{ + "type": "string", + "minLength": 3, + "maxLength": 50, + "pattern": "^[a-zA-Z]+$" +} + +// Generated Zod +z.string().min(3).max(50).regex(/^[a-zA-Z]+$/) +``` + +### Number Constraints + +```typescript +// OpenAPI +{ + "type": "number", + "minimum": 0, + "maximum": 100 +} + +// Generated Zod +z.number().min(0).max(100) +``` + +### Array Constraints + +```typescript +// OpenAPI +{ + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "maxItems": 10 +} + +// Generated Zod +z.array(z.string()).min(1).max(10) +``` + +### Object Constraints + +```typescript +// OpenAPI +{ + "type": "object", + "required": ["name", "email"], + "properties": { + "name": { "type": "string" }, + "email": { "type": "string", "format": "email" }, + "age": { "type": "integer" } + } +} + +// Generated Zod +z.object({ + name: z.string(), + email: z.email(), + age: z.number().int().optional() +}) +``` + +## Advanced Validation + +### Nullable Types (OAS 3.1) + +The generator supports OpenAPI 3.1 nullable types: + +```typescript +// OpenAPI +{ + "type": ["string", "null"] +} + +// Generated Zod +z.string().nullable() +``` + +### Enums + +Enums are converted to Zod enums: + +```typescript +// OpenAPI +{ + "type": "string", + "enum": ["active", "inactive", "pending"] +} + +// Generated Zod +z.enum(['active', 'inactive', 'pending']) +``` + +### Discriminated Unions + +The generator creates discriminated unions for `oneOf` with discriminators: + +```typescript +// OpenAPI +{ + "oneOf": [ + { "$ref": "#/components/schemas/Cat" }, + { "$ref": "#/components/schemas/Dog" } + ], + "discriminator": { + "propertyName": "type" + } +} + +// Generated Zod +z.discriminatedUnion('type', [Cat, Dog]) +``` + +### Intersection Types + +`allOf` creates intersection types: + +```typescript +// OpenAPI +{ + "allOf": [ + { "$ref": "#/components/schemas/BaseEntity" }, + { "$ref": "#/components/schemas/Timestamped" } + ] +} + +// Generated Zod +BaseEntity.and(Timestamped) +``` + +## Performance Considerations + +### Schema Caching + +Zod schemas are created once and reused for all requests: + +```typescript +// The schema is defined once +export const User = z.object({ + id: z.string(), + name: z.string() +}) + +// And reused for every request +Model.User.parse(data) // No schema recreation +``` + +### Lazy Validation + +Consider using `.safeParse()` for non-critical validations: + +```typescript +const result = Model.User.safeParse(data) + +if (result.success) { + const user = result.data +} else { + console.warn('Invalid user data:', result.error) +} +``` + +## Related + +- [Generated Code](/en/docs/openapi-generator/generated-code) - Understanding the output structure +- [Zod Documentation](https://zod.dev) - Complete Zod reference +- [Fetch Builder](/en/docs/fetch/core-api/builder) - Builder API details diff --git a/packages/web/content/rules/WRITING_RULES.md b/packages/web/content/rules/WRITING_RULES.md new file mode 100644 index 0000000..f9bf037 --- /dev/null +++ b/packages/web/content/rules/WRITING_RULES.md @@ -0,0 +1,126 @@ +# Documentation Writing Rules + +This document outlines the official guidelines for writing and structuring documentation for FreestyleJS Ani. Adhering to these rules ensures consistency, clarity, and maintainability across all our documentation. + +--- + +## 1. Markdown Syntax (GFM) + +Leverage GitHub Flavoured Markdown (GFM) to create a better reading experience. + +### **Use Lists for Key Points** + +Instead of complex sentences with many connectives, use unordered lists (`-`) to present key features or points. This improves scannability. + +### **Use Tables for Structured Data** + +Tables are essential for presenting structured information like API parameters, props, or return values. This is the required format for documenting APIs. + +**Example:** + +| Name | Type | Description | +| -------------- | ---------------- | ----------------------------------------- | +| `timeline` | `Timeline` | The animation timeline instance to track. | +| `initialValue` | `AniGroup` | The initial value of the animation state. | + +### **Use Headings for Structure** + +- Use headings (`##`, `###`) to break down content into logical, scannable sections. +- Headings create anchor links, allowing for easy cross-referencing. +- Give each step in a process a meaningful heading instead of using an ordered list. + +### **Use Bold for Emphasis** + +Use bold (`**text**`) to highlight important terms, concepts, or keywords. This helps readers who are skimming the content to quickly find relevant information. + +### **Use Code Blocks for Examples** + +- All technical examples, API definitions, and code snippets **must** be in code blocks with the correct language identifier (e.g., `tsx`, `typescript`, `svelte`, `vue`). +- Use comments within code blocks to explain specific lines or concepts. +- Keep examples focused and minimal. + +### **Use Hyperlinks for References** + +When mentioning another component, API, or external resource (e.g., `timeline`, `React`), link to the relevant documentation page or URL. + +--- + +## 2. Writing Style + +Our documentation targets a global audience. Clarity and simplicity are paramount. + +### **Use Simple, Direct Language** + +- Use simple and accurate words. Avoid jargon or overly technical terms where a simpler alternative exists. +- Be direct. Remove redundant or polite phrases. + +| Before | After | +| -------------------------------------------- | ------------------------------------------ | +| `You can configure the library by...` | `Configure the library by...` | +| `If it doesn't work, you may try to...` | `If it doesn't work,...` | +| `To enable B, you can configure C` | `To enable B, configure C` | + +### **"Subject First" Sentence Structure** + +Place the main subject at the beginning of the sentence. This makes the content easier to parse, especially for non-native English speakers. + +| Before | After | +| ------------------------------------------------------------------- | ------------------------------------------------------------------- | +| `One of the core concepts of the library, the timeline, is...` | `The timeline is a core concept of the library. It is...` | +| `Moreover, as a web framework, Next.js is useful for React.js apps` | `Next.js is a web framework that is useful for building React.js apps` | + +### **No Long Paragraphs** + +- Keep paragraphs short and focused on a single idea. +- A paragraph should generally be no longer than 5-7 lines. +- Break up longer explanations into multiple paragraphs, each with a clear topic. + +### **Avoid Sequential Words** + +Do not use words like "first," "then," or "finally" to describe steps. People read from top to bottom, so the order is implicit. Use meaningful headings for each step instead. + +--- + +## 3. Page Content and Organization + +How content is structured within a page is as important as the content itself. + +### **Define Acronyms** + +Spell out any acronyms or abbreviations the first time they are used on a page. + +### **No Duplicated Content** + +Avoid repeating the same information across multiple pages. If a concept is relevant in multiple places, explain it once on a dedicated page and link to it from the other pages. This makes maintenance easier. + +### **Dedicated API Sections** + +- Every exported function, hook, or component must have a dedicated documentation page. +- Each page must include: + 1. A clear title and a one-sentence description. + 2. A minimal, complete, and verifiable code example. + 3. A "Usage & Concepts" section explaining the *why* and *how*. + 4. An "API Reference" section with tables for parameters, props, and return values. + 5. A "Related Components" section with links to other relevant APIs. + +### **"When to Use" Sections** + +For hooks or components that have alternatives (e.g., `useAni` vs. `useAniRef`), include a "When to Use" section that provides clear guidelines on which to choose in different scenarios. Use "Do" and "Don't" bullet points for clarity. + +--- + +## 4. File and Navigation Structure + +A logical file structure is crucial for discoverability and maintainability. + +### **Dedicated Page per Topic** + +Each core concept, API, or feature should have its own dedicated page. This ensures that information is easy to find and update. + +### **No Overlapping Pages** + +Avoid creating multiple pages with similar purposes. For example, "Getting Started" and "Introduction" should be combined if their content overlaps significantly. + +### **Logical Learning Curve** + +Order pages in the navigation (`meta.json`) to follow a logical progression. Start with introductory concepts, move to core APIs, and then cover advanced topics and framework-specific bindings. diff --git a/packages/web/next-env.d.ts b/packages/web/next-env.d.ts new file mode 100644 index 0000000..c4b7818 --- /dev/null +++ b/packages/web/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/dev/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/web/next.config.mjs b/packages/web/next.config.mjs new file mode 100644 index 0000000..6ff62a5 --- /dev/null +++ b/packages/web/next.config.mjs @@ -0,0 +1,13 @@ +import { createMDX } from 'fumadocs-mdx/next' + +const withMdx = createMDX() + +/** @type {import('next').NextConfig} */ +const config = { + // output: 'export', // static build + + reactStrictMode: true, + serverExternalPackages: ['typescript', 'twoslash'], +} + +export default withMdx(config) diff --git a/packages/web/package.json b/packages/web/package.json new file mode 100644 index 0000000..ccac33b --- /dev/null +++ b/packages/web/package.json @@ -0,0 +1,46 @@ +{ + "name": "@freestylejs/fetch-web", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "next build", + "dev": "next dev", + "start": "next start", + "postinstall": "fumadocs-mdx" + }, + "dependencies": { + "@freestylejs/ani-core": "^1.2.0", + "@freestylejs/ani-react": "^1.1.0", + "@freestylejs/fetch": "workspace:^", + "@radix-ui/react-slot": "^1.2.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "fumadocs-core": "16.0.8", + "fumadocs-mdx": "13.0.5", + "fumadocs-twoslash": "^3.1.10", + "fumadocs-typescript": "^4.0.13", + "fumadocs-ui": "16.0.8", + "lucide-react": "^0.552.0", + "mermaid": "^11.12.1", + "next": "16.0.1", + "octokit": "^5.0.5", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "tailwind-merge": "^3.4.0", + "tailwindest": "^3.2.2", + "twoslash": "^0.3.4" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.1.16", + "@takumi-rs/image-response": "^0.51.0", + "@types/mdx": "^2.0.13", + "@types/node": "^24.10.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "feed": "^5.1.0", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.16", + "tw-animate-css": "^1.4.0", + "typescript": "^5.9.3" + } +} diff --git a/packages/web/postcss.config.mjs b/packages/web/postcss.config.mjs new file mode 100644 index 0000000..4d19be2 --- /dev/null +++ b/packages/web/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +} diff --git a/packages/web/public/fetch-banner.png b/packages/web/public/fetch-banner.png new file mode 100644 index 0000000..04e4af9 Binary files /dev/null and b/packages/web/public/fetch-banner.png differ diff --git a/packages/web/source.config.ts b/packages/web/source.config.ts new file mode 100644 index 0000000..072b3fa --- /dev/null +++ b/packages/web/source.config.ts @@ -0,0 +1,57 @@ +import { rehypeCodeDefaultOptions, remarkMdxMermaid } from 'fumadocs-core/mdx-plugins' +import { + defineCollections, + defineConfig, + defineDocs, + frontmatterSchema, + metaSchema, +} from 'fumadocs-mdx/config' +import { transformerTwoslash } from 'fumadocs-twoslash' +import { createGenerator, remarkAutoTypeTable } from 'fumadocs-typescript' +import z from 'zod' + +const generator = createGenerator() + +// You can customise Zod schemas for frontmatter and `meta.json` here +// see https://fumadocs.dev/docs/mdx/collections +export const docs = defineDocs({ + dir: 'content/docs', + docs: { + schema: frontmatterSchema, + postprocess: { + includeProcessedMarkdown: true, + }, + }, + meta: { + schema: metaSchema, + }, +}) + +export const blog = defineCollections({ + type: 'doc', + dir: 'content/blog', + schema: frontmatterSchema.extend({ + title: z.string(), + description: z.string(), + author: z.string(), + update: z.date().or(z.date()), + draft: z.boolean().optional(), + }), +}) + +export default defineConfig({ + lastModifiedTime: 'git', + mdxOptions: { + rehypeCodeOptions: { + themes: { + light: 'github-light', + dark: 'material-theme-palenight', + }, + transformers: [ + ...(rehypeCodeDefaultOptions.transformers ?? []), + transformerTwoslash(), + ], + }, + remarkPlugins: [remarkMdxMermaid, [remarkAutoTypeTable, { generator }]], + }, +}) diff --git a/packages/web/src/app/[lang]/(home)/components/body_validation_demo.tsx b/packages/web/src/app/[lang]/(home)/components/body_validation_demo.tsx new file mode 100644 index 0000000..342697a --- /dev/null +++ b/packages/web/src/app/[lang]/(home)/components/body_validation_demo.tsx @@ -0,0 +1,211 @@ +/** biome-ignore-all lint/style/useConsistentCurlyBraces: */ + +'use client' + +import { useState } from 'react' +import { cn } from '@/lib/utils' + +export const BodyValidationDemo = () => { + const [name, setName] = useState('Alice') + const [age, setAge] = useState('30') + const [status, setStatus] = useState< + 'idle' | 'checking' | 'valid' | 'invalid' + >('idle') + + const isValidName = name.length > 0 + const isValidAge = !isNaN(Number(age)) && age.length > 0 + + const checkType = () => { + setStatus('checking') + setTimeout(() => { + if (isValidName && isValidAge) { + setStatus('valid') + setTimeout(() => setStatus('idle'), 2000) + } else { + setStatus('invalid') + setTimeout(() => setStatus('idle'), 2000) + } + }, 600) + } + + return ( +
+ {/* Left: Input Form */} +
+
+
+
+ + setName(e.target.value)} + className={cn( + 'rounded-md border px-2 py-1 text-sm outline-none transition-colors', + status === 'invalid' && !isValidName + ? 'border-red-400 bg-red-100/50' + : 'border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-black/20' + )} + placeholder="Enter string..." + /> +
+
+ + setAge(e.target.value)} + className={cn( + 'rounded-md border px-2 py-1 text-sm outline-none transition-colors', + status === 'invalid' && !isValidAge + ? 'border-red-400 bg-red-100/50' + : 'border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-black/20' + )} + placeholder="Enter number..." + /> +
+
+ + +
+
+ + {/* Right: Code Sync Visual */} +
+
+ const createUser = + f.builder() +
+ + {/* Validator Def */} +
+ .def_body( + {/* FIX: Escaped open curly brace */} +
t.object({'{'}
+
+ name: t.string, +
+
+ age: t.number +
+
+ {'}'}).parse +
+ ) +
+
.build()
+ +
+ + {/* Runtime Check */} +
+
// Runtime Execution
+
+ await{' '} + {/* FIX: Escaped open curly brace */} + createUser.query({'{'} +
+ {/* FIX: Escaped open curly brace */} +
body: {'{'}
+
+ name: "{name}", +
+
+ age: {age || 'undefined'} +
+
{'}'}
+
{'}'})
+
+
+
+ ) +} + +export const bodyValidationCode = ` +const createUser = f.builder() + .def_body( + // Pass the .parse function directly + t.object({ + name: t.string, + age: t.number + }).parse + ) + .build() + +// TypeScript & Runtime validation active! +await createUser.query({ + body: { + name: 'Alice', + age: 30 + } +}) +` diff --git a/packages/web/src/app/[lang]/(home)/components/error_handling_demo.tsx b/packages/web/src/app/[lang]/(home)/components/error_handling_demo.tsx new file mode 100644 index 0000000..c5ee317 --- /dev/null +++ b/packages/web/src/app/[lang]/(home)/components/error_handling_demo.tsx @@ -0,0 +1,210 @@ +// biome-ignore format: manual indentation needed for code preview + +"use client"; + +import { useState } from 'react' +import { cn } from '@/lib/utils' + +export const ErrorHandlingDemo = () => { + const [responseType, setResponseType] = useState<'200' | '404' | '500'>( + '200' + ) + const [activeBlock, setActiveBlock] = useState< + 'none' | 'request' | 'error' | 'success' | 'finally' + >('none') + const [log, setLog] = useState([]) + + const execute = async () => { + setLog([]) + setActiveBlock('request') + addLog('Sending Request...') + + await new Promise((r) => setTimeout(r, 600)) + + if (responseType === '200') { + setActiveBlock('success') + addLog('โœ“ Status 200 OK') + } else { + setActiveBlock('error') + addLog(`โš  Error: ${responseType}`) + addLog(`Handling ${responseType}...`) + } + + await new Promise((r) => setTimeout(r, 800)) + + setActiveBlock('finally') + addLog('โ„น Finally Block Executed') + + await new Promise((r) => setTimeout(r, 800)) + setActiveBlock('none') + } + + const addLog = (msg: string) => setLog((prev) => [...prev, msg]) + + return ( +
+ {/* Left: Interactive Control */} +
+
+
+ Server Simulation +
+
+ +
+ +
+ +
+
+ +
+
+ Console Output +
+
+ {log.map((l, i) => ( +
+ {l} +
+ ))} + {log.length === 0 && ( +
+ Waiting for execution... +
+ )} +
+
+ + +
+ + {/* Right: Code Sync */} +
+
+ const request = + f.builder() +
+
.def_url('/api')
+ + {/* Request Handler */} +
+ .def_request_handler(req ={'>'} req) +
+ + {/* Success Handler */} +
+ .def_response(res ={'>'}{' '} + // 200 OK {'}'}) +
+ + {/* Error Handler */} +
+ .def_fetch_err_handler(({`{`}status{`}`}) ={'>'}{' '} + { +
+ if (status + !== 200) log('Error') +
+ } +
+ + {/* Finally Handler */} +
+ .def_final_handler(() = {'>'}{' '} + {
log('Finally')
} +
+ +
.build()
+
+
+ ) +} + +export const errorHandlingCode = ` +const request = f.builder() + .def_url('/api') + // 1. Handle HTTP Errors (4xx, 5xx) + .def_fetch_err_handler(({ status }) => { + console.error('HTTP Error:', status) + }) + // 2. Cleanup + .def_final_handler(() => { + console.log('Always executed') + }) + .build() +` diff --git a/packages/web/src/app/[lang]/(home)/components/feature_card.tsx b/packages/web/src/app/[lang]/(home)/components/feature_card.tsx new file mode 100644 index 0000000..26e2958 --- /dev/null +++ b/packages/web/src/app/[lang]/(home)/components/feature_card.tsx @@ -0,0 +1,112 @@ +'use client' + +import { Code, Eye } from 'lucide-react' +import { type ReactNode, useState } from 'react' + +import { CodeBlock } from '@/components/ui' +import { Card, CardLink } from '@/components/ui/card' +import { Glow } from '@/components/ui/glow' +import { cn } from '@/lib/utils' + +interface FeatureCardProps { + title: string + description: string + code: string + link?: string + children: ReactNode +} + +export function FeatureCard({ + title, + description, + code, + link, + children, +}: FeatureCardProps) { + const [view, setView] = useState<'preview' | 'code'>('preview') + + return ( + + + + {/* Header */} +
+
+

+ {title} +

+

+ {description} +

+
+ {link && ( + + )} +
+ + {/* Toolbar */} +
+
+ + +
+
+ + {/* Content Area - Grid Stack for Auto Height */} +
+ {/* Preview */} +
+ {children} +
+ + {/* Code */} +
+ +
+
+
+ ) +} diff --git a/packages/web/src/app/[lang]/(home)/components/fetch_builder_demo.tsx b/packages/web/src/app/[lang]/(home)/components/fetch_builder_demo.tsx new file mode 100644 index 0000000..f875cab --- /dev/null +++ b/packages/web/src/app/[lang]/(home)/components/fetch_builder_demo.tsx @@ -0,0 +1,283 @@ +/** biome-ignore-all lint/style/useConsistentCurlyBraces: */ + +'use client' + +import { useState } from 'react' +import { cn } from '@/lib/utils' + +export const FetchBuilderDemo = () => { + const [config, setConfig] = useState<{ + method: 'GET' | 'POST' + url: boolean + json: boolean + built: boolean + }>({ + method: 'GET', + url: false, + json: false, + built: false, + }) + + const [id, setId] = useState('123') + const [status, setStatus] = useState<'idle' | 'loading' | 'success'>('idle') + + const handleSend = () => { + setStatus('loading') + setTimeout(() => setStatus('success'), 800) + setTimeout(() => { + setStatus('idle') + // Reset demo after success + setTimeout(() => { + setConfig({ + method: 'GET', + url: false, + json: false, + built: false, + }) + setId('123') + }, 1000) + }, 2000) + } + + return ( +
+ {/* Left: Interactive Builder Config */} +
+
+
+ Configure Builder +
+
+ {/* 1. Method */} + + + {/* 2. URL */} + + + {/* 3. JSON */} + + + {/* 4. Build */} + +
+
+ + {/* Execution Control */} +
+
+ Execute Request +
+ + {config.url && ( +
+ + id: + + setId(e.target.value)} + className="w-full bg-transparent font-mono text-sm text-zinc-700 outline-none dark:text-zinc-200" + spellCheck={false} + /> +
+ )} + + +
+
+ + {/* Right: Live Code Generation */} +
+
+ const getUser = + f.builder() +
+ + {/* Method */} +
+ .def_method( + '{config.method}') +
+ + {/* URL */} + {config.url && ( +
+ .def_url( + + "https://api/users/ + $id" + + ) +
+ )} + + {/* JSON */} + {config.json && ( +
+ .def_json() +
+ )} + + {/* Build */} + {config.built && ( +
+ .build() +
+ )} + +
+ + {/* Usage */} +
+
+ await{' '} + {/* Improved: Added ({ for visual accuracy when URL is active */} + getUser.query{config.url ? '({' : '()'} +
+ {config.url && ( + <> +
+ {/* FIX: Escaped the opening curly brace to prevent syntax error */} + path: {'{'} + id:{' '} + "{id}"{' '} + {'}'} +
+
{'}'})
+ + )} +
+
+
+ ) +} + +export const fetchBuilderCode = ` +// 1. Interactive Construction +const getUser = f.builder() + .def_method('GET') + .def_url('https://api/users/$id') + .def_json() + .build() + +// 2. Type-Safe Execution +await getUser.query({ + path: { id: '123' } +}) +` diff --git a/packages/web/src/app/[lang]/(home)/components/index.ts b/packages/web/src/app/[lang]/(home)/components/index.ts new file mode 100644 index 0000000..ffc1c95 --- /dev/null +++ b/packages/web/src/app/[lang]/(home)/components/index.ts @@ -0,0 +1,7 @@ +export * from './body_validation_demo' +export * from './error_handling_demo' +export * from './feature_card' +export * from './fetch_builder_demo' +export * from './middleware_demo' +export * from './response_inference_demo' +export * from './router_demo' diff --git a/packages/web/src/app/[lang]/(home)/components/middleware_demo.tsx b/packages/web/src/app/[lang]/(home)/components/middleware_demo.tsx new file mode 100644 index 0000000..0c2e000 --- /dev/null +++ b/packages/web/src/app/[lang]/(home)/components/middleware_demo.tsx @@ -0,0 +1,317 @@ +/** biome-ignore-all lint/style/useConsistentCurlyBraces: */ + +'use client' + +import { useState } from 'react' +import { cn } from '@/lib/utils' + +export const MiddlewareDemo = () => { + const [stage, setStage] = useState(0) + + // 0: Idle + // 1: Auth Request + // 2: Log Request + // 3: Server + // 4: Log Response + // 5: Auth Response + // 6: Complete (Wait state) + + const run = () => { + setStage(1) + setTimeout(() => setStage(2), 800) + setTimeout(() => setStage(3), 1600) + setTimeout(() => setStage(4), 2400) + setTimeout(() => setStage(5), 3200) + setTimeout(() => setStage(6), 4000) + setTimeout(() => setStage(0), 6000) // Reset after 2s wait + } + + return ( +
+ {/* Left: Pipeline Visual */} +
+
+ {/* Client */} +
+
+
+
+
+ Client +
+
+ + {/* Pipeline */} +
+ {/* The traveling dot */} +
+ +
+ + AUTH + +
+
+
+ + LOG + +
+
+ + {/* Server */} +
+
+
+
+
+ Server +
+
+
+ +
+ + โ†’ Request Phase: Add Headers + + + โ†’ Request Phase: Log Start + + + Processing... + + + โ† Response Phase: Log End + + + โ† Response Phase: Verify + + + โœ“ Request Complete + +
+ + +
+ + {/* Right: Code Sync Visual */} +
+
+ const{' '} + middleware ={' '} + new f.Middleware() +
+
+
+ // 1. Auth Middleware +
+
+ middleware. + use( + async (req, next) = + {'>'} +
+ req.headers.set(...){' '} + // โ†’ Request +
+
+ const res ={' '} + await next(req) +
+
+ return res{' '} + // โ† Response +
+ {'}'} +
+ +
+ +
+ + // 2. Logging Middleware + +
+
+ middleware. + use( + async (req, next) = + {'>'} +
+ console.log('Start'){' '} + // โ†’ Request +
+
+ const res ={' '} + await next(req) +
+
+ console.log('End'){' '} + // โ† Response +
+
+ return res +
+ {'}'} +
+
+
+ ) +} + +export const middlewareCode = ` +const mw = new f.Middleware() + +// 1. Auth +mw.use(async (req, next) => { + req.headers.set('Auth', '...') + const res = await next(req) + // Response verification + return res +}) + +// 2. Logger +mw.use(async (req, next) => { + console.log('Req') + const res = await next(req) + console.log('Res') + return res +}) +` diff --git a/packages/web/src/app/[lang]/(home)/components/response_inference_demo.tsx b/packages/web/src/app/[lang]/(home)/components/response_inference_demo.tsx new file mode 100644 index 0000000..4945c86 --- /dev/null +++ b/packages/web/src/app/[lang]/(home)/components/response_inference_demo.tsx @@ -0,0 +1,176 @@ +/** biome-ignore-all lint/style/useConsistentCurlyBraces: */ + +'use client' + +import { useState } from 'react' +import { cn } from '@/lib/utils' + +export const ResponseInferenceDemo = () => { + const [step, setStep] = useState(0) + + const next = () => { + setStep((s) => (s + 1) % 4) // 0: Idle, 1: Network, 2: Validate, 3: Type Result + } + + return ( +
+ {/* Left: Process Timeline */} +
+
+ {/* Step 1: Network */} +
1 + ? 'border-zinc-200 bg-zinc-50 opacity-50 dark:border-zinc-800 dark:bg-zinc-900' + : 'border-transparent opacity-30' + )} + > + + 1. Network Response + +
+ {`"Hello World"`} +
+
+ + {/* Step 2: Validation */} +
2 + ? 'border-zinc-200 bg-zinc-50 opacity-50 dark:border-zinc-800 dark:bg-zinc-900' + : 'border-transparent opacity-30' + )} + > + + 2. Validate Schema + +
+ t.string.parse() +
+
+ + {/* Step 3: Inference */} +
+ + 3. Inferred Type + +
+ string +
+
+
+ + +
+ + {/* Right: Code Sync Visual */} +
+
+ const getText = + f.builder() +
+
.def_json()
+ + {/* Definition Block */} +
+ .def_response(async{' '} + {/* FIX: Cleaned up syntax and escaped braces correctly */}( + {'{'} json {'}'}) ={'>'} {'{'} +
+ // 1. Fetch JSON +
+ const raw ={' '} + await json() +
+
+ // 2. Validate +
+ return{' '} + t.string. + parse + (raw) +
+ {'}'}) +
+
.build()
+ +
+ + {/* Usage Block */} +
+ + // 3. Result is typed! + +
+ const data ={' '} + await{' '} + getText.query() +
+ + ^ (const) data: string + +
+
+
+ ) +} + +export const responseInferenceCode = ` +const getText = f.builder() + .def_json() + .def_response(async ({ json }) => { + // 1. Get raw JSON + const raw = await json() + + // 2. Parse & Validate + return t.string.parse(raw) + }) + .build() + +// 3. Result is fully typed +const data = await getText.query() +// ^ type: string +` diff --git a/packages/web/src/app/[lang]/(home)/components/router_demo.tsx b/packages/web/src/app/[lang]/(home)/components/router_demo.tsx new file mode 100644 index 0000000..f4e6b57 --- /dev/null +++ b/packages/web/src/app/[lang]/(home)/components/router_demo.tsx @@ -0,0 +1,341 @@ +/** biome-ignore-all lint/style/useConsistentCurlyBraces: */ + +'use client' + +import { useState } from 'react' +import { cn } from '@/lib/utils' + +export const RouterDemo = () => { + const [activePath, setActivePath] = useState([]) + const [hovered, setHovered] = useState(null) + + const isPathActive = (segment: string) => activePath.includes(segment) + const isHovered = (segment: string) => hovered === segment + + const handleSegmentClick = (path: string[]) => { + setActivePath(path) + } + + // Helper to render the inner builder code + const BuilderCode = () => ( + + f. + + builder + + ().build() + + ) + + return ( +
+
+ {/* Left: Router Definition (Interactive Code) */} +
+
+ 1. Define Router +
+
+
+ + const + {' '} + + api + {' '} + = f.router( + {/* FIX: Escaped curly brace correctly */} + BASE, {'{'} +
+ + {/* auth */} +
+ handleSegmentClick(['auth'])} + onMouseEnter={() => setHovered('auth')} + onMouseLeave={() => setHovered(null)} + > + auth + + {/* FIX: Escaped curly brace */}: {'{'} +
+ + {/* login */} +
+ + handleSegmentClick([ + 'auth', + 'login', + 'POST', + ]) + } + onMouseEnter={() => setHovered('login')} + onMouseLeave={() => setHovered(null)} + > + login + + {/* FIX: Escaped curly brace */}: {'{'} + {isPathActive('login') ? ( +
+ + POST + + : , +
+ ) : ( + ... + )} + {/* FIX: Escaped curly brace */} + {'}'} +
+
{'}'},
+ + {/* users */} +
+ handleSegmentClick(['users'])} + onMouseEnter={() => setHovered('users')} + onMouseLeave={() => setHovered(null)} + > + users + + {/* FIX: Escaped curly brace */}: {'{'} +
+ + {/* $id */} +
+ + handleSegmentClick(['users', '$id']) + } + onMouseEnter={() => setHovered('$id')} + onMouseLeave={() => setHovered(null)} + > + $id + + {/* FIX: Escaped curly brace */}: {'{'} +
+ + {/* $id.GET */} +
+ + handleSegmentClick(['users', '$id', 'GET']) + } + onMouseEnter={() => setHovered('GET')} + onMouseLeave={() => setHovered(null)} + > + GET + + : , +
+ + {/* $id.posts */} +
+ + handleSegmentClick([ + 'users', + '$id', + 'posts', + 'GET', + ]) + } + onMouseEnter={() => setHovered('posts')} + onMouseLeave={() => setHovered(null)} + > + posts + + {/* FIX: Escaped curly brace */}: {'{'} + {isPathActive('posts') ? ( +
+ + GET + + : +
+ ) : ( + ... + )} + {'}'} +
+ +
{'}'}
+
{'}'}
+
{'}'})
+
+
+ + {/* Right: Usage Preview (Generated Code) */} +
+
+ 2. Type-Safe Usage + {activePath.length > 0 && ( +
+
+ Synced +
+ )} +
+
+ await{' '} + api + {activePath.length === 0 && ( + + ... + + )} + {/* Render Active Path Segments */} + {activePath.map((segment, i) => { + const isSegmentHovered = hovered === segment + let colorClass = 'text-zinc-100' + if (segment === '$id') colorClass = 'text-amber-400' + else if (['GET', 'POST'].includes(segment)) + colorClass = 'text-emerald-400' + + return ( + + .{segment} + + ) + })} + {/* Query Execution Block */} + {['GET', 'POST', 'PUT', 'DELETE'].includes( + activePath[activePath.length - 1] + ) && ( +
+ .query( + {/* FIX: Replaced invalid nested syntax with explicit string escaping */} + {'{'} + {activePath.includes('$id') && ( +
+ path: {'{'} + + id + + :{' '} + + "123" + {' '} + {'}'}, +
+ )} + {activePath.includes('login') && ( +
+ body: {'{'} + + email + + :{' '} + + "..." + {' '} + {'}'}, +
+ )} + {'}'}) +
+ )} +
+
+
+
+ Select a route definition to generate its type-safe client usage +
+
+ ) +} + +export const routerCode = ` +// 1. Define Nested Router +const api = f.router('https://api.com', { + auth: { + login: { + POST: f.builder().build() + } + }, + users: { + $id: { + GET: f.builder().build(), + }, + posts: { + GET: f.builder().build() + } + } +}) + +// 2. Use with Autocomplete +await api.users.$id.GET.query({ + path: { id: '123' } // Typed & Required! +}) +` diff --git a/packages/web/src/app/[lang]/(home)/layout.tsx b/packages/web/src/app/[lang]/(home)/layout.tsx new file mode 100644 index 0000000..03c0866 --- /dev/null +++ b/packages/web/src/app/[lang]/(home)/layout.tsx @@ -0,0 +1,12 @@ +/** biome-ignore-all lint/style/noDefaultExport: */ +import { HomeLayout } from 'fumadocs-ui/layouts/home' +import { baseOptions } from '@/lib/layout.shared' + +export default async function Layout({ + children, + params, +}: LayoutProps<'/[lang]'>) { + const { lang } = await params + + return {children} +} diff --git a/packages/web/src/app/[lang]/(home)/page.tsx b/packages/web/src/app/[lang]/(home)/page.tsx new file mode 100644 index 0000000..732a62a --- /dev/null +++ b/packages/web/src/app/[lang]/(home)/page.tsx @@ -0,0 +1,88 @@ +import { Banner } from '@/components/banner' +import { CONFIG } from '@/constant/config' +import { + BodyValidationDemo, + bodyValidationCode, + ErrorHandlingDemo, + errorHandlingCode, + FeatureCard, + FetchBuilderDemo, + fetchBuilderCode, + MiddlewareDemo, + middlewareCode, + ResponseInferenceDemo, + RouterDemo, + responseInferenceCode, + routerCode, +} from './components' + +export default function HomePage() { + return ( +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+ ) +} diff --git a/packages/web/src/app/[lang]/docs/[[...slug]]/page.tsx b/packages/web/src/app/[lang]/docs/[[...slug]]/page.tsx new file mode 100644 index 0000000..b6d0875 --- /dev/null +++ b/packages/web/src/app/[lang]/docs/[[...slug]]/page.tsx @@ -0,0 +1,67 @@ +import { createRelativeLink } from 'fumadocs-ui/mdx' +import { + DocsBody, + DocsDescription, + DocsPage, + DocsTitle, +} from 'fumadocs-ui/page' +import type { Metadata } from 'next' +import { Inter } from 'next/font/google' +import { notFound } from 'next/navigation' +import { docSource, getPageImage } from '@/lib/source' +import { getMDXComponents } from '@/mdx_components' + +const inter = Inter({ + subsets: ['latin'], + weight: '300', +}) + +export default async function Page( + props: PageProps<'/[lang]/docs/[[...slug]]'> +) { + const { slug, lang } = await props.params + + const page = docSource.getPage(slug, lang) + + if (!page) notFound() + + const MDX = page.data.body + + return ( + + {page.data.title} + + {page.data.description} + + + + + + ) +} + +export async function generateStaticParams() { + return docSource.generateParams() +} + +export async function generateMetadata( + props: PageProps<'/[lang]/docs/[[...slug]]'> +): Promise { + const { slug, lang } = await props.params + + const page = docSource.getPage(slug, lang) + + if (!page) notFound() + + return { + title: page.data.title, + description: page.data.description, + openGraph: { + images: getPageImage(page).url, + }, + } +} diff --git a/packages/web/src/app/[lang]/docs/layout.tsx b/packages/web/src/app/[lang]/docs/layout.tsx new file mode 100644 index 0000000..4f42128 --- /dev/null +++ b/packages/web/src/app/[lang]/docs/layout.tsx @@ -0,0 +1,15 @@ +import { DocsLayout } from 'fumadocs-ui/layouts/docs' +import { baseOptions } from '@/lib/layout.shared' +import { docSource } from '@/lib/source' + +export default async function Layout({ + params, + children, +}: LayoutProps<'/[lang]/docs'>) { + const { lang } = await params + return ( + + {children} + + ) +} diff --git a/packages/web/src/app/[lang]/layout.tsx b/packages/web/src/app/[lang]/layout.tsx new file mode 100644 index 0000000..0a87514 --- /dev/null +++ b/packages/web/src/app/[lang]/layout.tsx @@ -0,0 +1,54 @@ +import { tw } from '@/lib/utils' +import '../global.css' + +import { defineI18nUI } from 'fumadocs-ui/i18n' +import { RootProvider } from 'fumadocs-ui/provider/next' +import { Darker_Grotesque, Inter } from 'next/font/google' +import { i18n } from '@/lib/i18n/i18n' + +const inter = Inter({ + subsets: ['latin'], +}) + +const darkerGrotesque = Darker_Grotesque({ + subsets: ['latin'], + style: 'normal', +}) + +const { provider } = defineI18nUI(i18n, { + translations: { + en: { + displayName: 'English', + }, + // ko: { + // displayName: 'ํ•œ๊ตญ์–ด', + // }, + }, +}) + +export default async function Layout({ + params, + children, +}: LayoutProps<'/[lang]'>) { + const lang = (await params).lang + + return ( + + + + {children} + + + + ) +} diff --git a/packages/web/src/app/[lang]/llms-full.txt/route.ts b/packages/web/src/app/[lang]/llms-full.txt/route.ts new file mode 100644 index 0000000..0d23517 --- /dev/null +++ b/packages/web/src/app/[lang]/llms-full.txt/route.ts @@ -0,0 +1,11 @@ +import { getLLMText } from '@/lib/llm/get_llm_text' +import { docSource } from '@/lib/source' + +export const revalidate = false + +export async function GET() { + const scan = docSource.getPages().map(getLLMText) + const scanned = await Promise.all(scan) + + return new Response(scanned.join('\n\n')) +} diff --git a/packages/web/src/app/[lang]/llms.txt/route.ts b/packages/web/src/app/[lang]/llms.txt/route.ts new file mode 100644 index 0000000..534c0c3 --- /dev/null +++ b/packages/web/src/app/[lang]/llms.txt/route.ts @@ -0,0 +1,25 @@ +import { docSource } from '@/lib/source' + +export const revalidate = false + +export async function GET() { + const scanned: string[] = [] + scanned.push('# Docs') + const map = new Map() + + for (const page of docSource.getPages()) { + const dir = page.slugs[0] + const list = map.get(dir) ?? [] + list.push( + `- [${page.data.title}](${page.url}): ${page.data.description}` + ) + map.set(dir, list) + } + + for (const [key, value] of map) { + scanned.push(`## ${key}`) + scanned.push(value.join('\n')) + } + + return new Response(scanned.join('\n\n')) +} diff --git a/packages/web/src/app/[lang]/llms/[...slug]/route.ts b/packages/web/src/app/[lang]/llms/[...slug]/route.ts new file mode 100644 index 0000000..27938b7 --- /dev/null +++ b/packages/web/src/app/[lang]/llms/[...slug]/route.ts @@ -0,0 +1,25 @@ +import { notFound } from 'next/navigation' +import { type NextRequest, NextResponse } from 'next/server' +import { getLLMText } from '@/lib/llm/get_llm_text' +import { docSource } from '@/lib/source' + +export const revalidate = false + +export async function GET( + _req: NextRequest, + { params }: RouteContext<'/[lang]/llms/[...slug]'> +) { + const slug = (await params).slug + const page = docSource.getPage(slug) + if (!page) notFound() + + return new NextResponse(await getLLMText(page), { + headers: { + 'Content-Type': 'text/markdown', + }, + }) +} + +export function generateStaticParams() { + return docSource.generateParams().filter((param) => param.slug.length > 0) +} diff --git a/packages/web/src/app/api/search/route.ts b/packages/web/src/app/api/search/route.ts new file mode 100644 index 0000000..9899c83 --- /dev/null +++ b/packages/web/src/app/api/search/route.ts @@ -0,0 +1,4 @@ +import { createFromSource } from 'fumadocs-core/search/server' +import { docSource } from '@/lib/source' + +export const { GET } = createFromSource(docSource, {}) diff --git a/packages/web/src/app/blog/[slug]/page.tsx b/packages/web/src/app/blog/[slug]/page.tsx new file mode 100644 index 0000000..6d334b9 --- /dev/null +++ b/packages/web/src/app/blog/[slug]/page.tsx @@ -0,0 +1,145 @@ +import path from 'node:path' +import DynamicLink from 'fumadocs-core/dynamic-link' +import type { Metadata } from 'next' +import { Darker_Grotesque, DM_Serif_Display, Inter } from 'next/font/google' +import { notFound } from 'next/navigation' +import { ShareButton } from '@/components/ui' +import { buttonVariants } from '@/components/ui/button' +import { createMetadata } from '@/lib/metadata/create_metadata' +import { blogSource } from '@/lib/source' +import { cn, tw } from '@/lib/utils' +import { getMDXComponents } from '@/mdx_components' + +const inter = Inter({ + subsets: ['latin'], + weight: '300', +}) + +const _dmSerif = DM_Serif_Display({ + weight: '400', + subsets: ['latin'], +}) + +const darkerGrotesque = Darker_Grotesque({ + subsets: ['latin'], + style: 'normal', +}) + +export default async function Page(props: PageProps<'/blog/[slug]'>) { + const params = await props.params + const page = blogSource.getPage([params.slug]) + + if (!page) notFound() + if (page.data.draft) notFound() + + const { body: Mdx, toc } = page.data + + return ( +
+
+
    +
  • +

    + Written by +

    +

    {page.data.author}

    +
  • +
  • +

    At

    +

    + {new Date( + page.data.update ?? + path.basename( + page.path, + path.extname(page.path) + ) + ).toDateString()} +

    +
  • +
+ +

+ {page.data.title} +

+

+ {page.data.description} +

+
+ +
+
+ + + + Back + +
+
+ +
+ +
+
+ ) +} + +export async function generateMetadata( + props: PageProps<'/blog/[slug]'> +): Promise { + const params = await props.params + const page = blogSource.getPage([params.slug]) + + if (!page) notFound() + + return createMetadata({ + title: page.data.title, + description: page.data.description, + }) +} + +export function generateStaticParams(): Array<{ slug: string }> { + const staticParams = blogSource + .getPages() + .filter((p) => Boolean(p.data.draft) === false) + .map((p) => p.slugs) + .filter((s) => s.length > 0) + .map((s) => ({ slug: s[0] })) + + return staticParams +} diff --git a/packages/web/src/app/blog/layout.tsx b/packages/web/src/app/blog/layout.tsx new file mode 100644 index 0000000..54bcfd6 --- /dev/null +++ b/packages/web/src/app/blog/layout.tsx @@ -0,0 +1,37 @@ +import '../global.css' + +import { HomeLayout } from 'fumadocs-ui/layouts/home' +import { RootProvider } from 'fumadocs-ui/provider/next' +import { Darker_Grotesque, Inter } from 'next/font/google' + +import { CONFIG } from '@/constant/config' + +import { baseOptions } from '@/lib/layout.shared' +import { tw } from '@/lib/utils' + +const inter = Inter({ + subsets: ['latin'], +}) + +const darkerGrotesque = Darker_Grotesque({ + subsets: ['latin'], + style: 'normal', +}) + +export default function Layout({ children }: LayoutProps<'/blog'>) { + return ( + + + + + {children} + + + + + ) +} diff --git a/packages/web/src/app/blog/page.tsx b/packages/web/src/app/blog/page.tsx new file mode 100644 index 0000000..74dffb2 --- /dev/null +++ b/packages/web/src/app/blog/page.tsx @@ -0,0 +1,65 @@ +/** biome-ignore-all lint/style/noDefaultExport: */ +import { PathUtils } from 'fumadocs-core/source' +import Link from 'next/link' +import { Banner } from '@/components/banner' +import { CONFIG } from '@/constant/config' +import { blogSource } from '@/lib/source' + +function getName(path: string) { + return PathUtils.basename(path, PathUtils.extname(path)) +} + +export default function BlogIntroduction() { + const posts = [...blogSource.getPages()] + .filter((p) => Boolean(p.data.draft) === false) + .sort( + (a, b) => + new Date(b.data.update ?? getName(b.path)).getTime() - + new Date(a.data.update ?? getName(a.path)).getTime() + ) + + const hasNoPost = posts.length === 0 + + return ( +
+ + +
+ {hasNoPost && ( +

+ Will be added soon. +

+ )} + + {hasNoPost === false && + posts.map((post) => ( + +

+ {post.data.title} +

+

+ {post.data.description} +

+ +

+ {new Date( + post.data.update ?? getName(post.path) + ).toDateString()} +

+ + ))} +
+
+ ) +} diff --git a/packages/web/src/app/blog/rss.xml/route.ts b/packages/web/src/app/blog/rss.xml/route.ts new file mode 100644 index 0000000..685ca7b --- /dev/null +++ b/packages/web/src/app/blog/rss.xml/route.ts @@ -0,0 +1,44 @@ +import { Feed } from 'feed' +import { NextResponse } from 'next/server' +import { CONFIG } from '@/constant/config' +import { blogSource } from '@/lib/source' + +export const revalidate = false + +export function GET() { + const baseUrl = CONFIG.siteUrl + + const feed = new Feed({ + title: `${CONFIG.libName} Blog`, + id: `${baseUrl}/blog`, + link: `${baseUrl}/blog`, + language: 'en', + + image: `${baseUrl}/banner.png`, + favicon: `${baseUrl}/icon.png`, + copyright: `All rights reserved 2025, ${CONFIG.authorName}`, + }) + + for (const page of blogSource.getPages().sort((a, b) => { + return ( + new Date(b.data.update).getTime() - + new Date(a.data.update).getTime() + ) + })) { + feed.addItem({ + id: page.url, + title: page.data.title, + description: page.data.description, + link: `${baseUrl}${page.url}`, + date: new Date(page.data.update), + + author: [ + { + name: page.data.author, + }, + ], + }) + } + + return new NextResponse(feed.rss2()) +} diff --git a/packages/web/src/app/global.css b/packages/web/src/app/global.css new file mode 100644 index 0000000..e8e81f0 --- /dev/null +++ b/packages/web/src/app/global.css @@ -0,0 +1,130 @@ +@import "tailwindcss"; + +@source not "../lib/utils/tailwind.ts"; + +@import "./utils.css"; +@import "fumadocs-ui/css/vitepress.css"; +@import "fumadocs-ui/css/preset.css"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.21 0.006 285.885); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.705 0.015 286.067); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.21 0.006 285.885); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.705 0.015 286.067); +} + +.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.92 0.004 286.32); + --primary-foreground: oklch(0.21 0.006 285.885); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.552 0.016 285.938); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.552 0.016 285.938); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +@utility no-scrollbar { + @apply [scrollbar-width:none] [&::-webkit-scrollbar]:hidden; +} diff --git a/packages/web/src/app/og/docs/[...slug]/generate.tsx b/packages/web/src/app/og/docs/[...slug]/generate.tsx new file mode 100644 index 0000000..7dd84d2 --- /dev/null +++ b/packages/web/src/app/og/docs/[...slug]/generate.tsx @@ -0,0 +1,127 @@ +import { readFile } from 'node:fs/promises' +import type { ImageResponseOptions } from '@takumi-rs/image-response' +import type { ReactNode } from 'react' +import { CONFIG } from '@/constant/config' + +export interface GenerateProps { + title: ReactNode + description?: ReactNode +} + +const majorFont = readFile('./lib/og/DarkerGrotesque-Regular.ttf').then( + (data) => ({ + name: 'Mono', + data, + weight: 600, + }) +) + +const logoFont = readFile('./lib/og/DMSerifDisplay-Regular.ttf').then( + (data) => ({ + name: 'Mono', + data, + weight: 400, + }) +) + +export async function getImageResponseOptions(): Promise { + return { + width: 1200, + height: 630, + format: 'webp', + fonts: await Promise.all([logoFont, majorFont]), + } +} + +export function generate({ title, description }: GenerateProps) { + const siteName = CONFIG.libName + const primaryTextColor = 'rgb(240,240,240)' + const logo = ( + + + + + + + + + + + + + ) + + return ( +
+
+ + {title} + +

+ {description} +

+
+ {logo} + + {siteName} + +
+
+
+ ) +} diff --git a/packages/web/src/app/og/docs/[...slug]/route.tsx b/packages/web/src/app/og/docs/[...slug]/route.tsx new file mode 100644 index 0000000..70f7058 --- /dev/null +++ b/packages/web/src/app/og/docs/[...slug]/route.tsx @@ -0,0 +1,35 @@ +import { generate as DefaultImage } from 'fumadocs-ui/og' +import { notFound } from 'next/navigation' +import { ImageResponse } from 'next/og' +import { CONFIG } from '@/constant/config' +import { docSource, getPageImage } from '@/lib/source' + +export const revalidate = false + +export async function GET( + _req: Request, + { params }: RouteContext<'/og/docs/[...slug]'> +) { + const { slug } = await params + const page = docSource.getPage(slug.slice(0, -1)) + if (!page) notFound() + + return new ImageResponse( + , + { + width: 1200, + height: 630, + } + ) +} + +export function generateStaticParams() { + return docSource.getPages().map((page) => ({ + lang: page.locale, + slug: getPageImage(page).segments, + })) +} diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx new file mode 100644 index 0000000..034bf25 --- /dev/null +++ b/packages/web/src/app/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from 'next/navigation' +import { CONFIG } from '@/constant/config' + +export default function RootPage() { + redirect(`/${CONFIG.majorLang}`) +} diff --git a/packages/web/src/app/rss.xml/route.ts b/packages/web/src/app/rss.xml/route.ts new file mode 100644 index 0000000..e0106a6 --- /dev/null +++ b/packages/web/src/app/rss.xml/route.ts @@ -0,0 +1,38 @@ +import { Feed } from 'feed' +import { CONFIG } from '@/constant/config' +import { docSource } from '@/lib/source' + +function getRss() { + const baseUrl = CONFIG.siteUrl + const feed = new Feed({ + title: `${CONFIG.libName} - Blog`, + id: `${baseUrl}/blog`, + link: `${baseUrl}/blog`, + language: 'en', + + image: `${baseUrl}${CONFIG.mainBannerUrl}`, + favicon: `${baseUrl}/icon.png`, + copyright: `All rights reserved 2025, ${CONFIG.authorName}`, + }) + + for (const page of docSource.getPages()) { + feed.addItem({ + id: page.url, + title: page.data.title, + description: page.data.description, + link: `${baseUrl}${page.url}`, + date: new Date(page.data.lastModified ?? new Date()), + + author: [{ name: CONFIG.authorName }], + }) + } + + return feed.rss2() +} + +// NEXT CONFIG +export const revalidate = false + +export function GET() { + return new Response(getRss()) +} diff --git a/packages/web/src/app/sitemap.ts b/packages/web/src/app/sitemap.ts new file mode 100644 index 0000000..b02ca43 --- /dev/null +++ b/packages/web/src/app/sitemap.ts @@ -0,0 +1,32 @@ +import type { MetadataRoute } from 'next' +import { baseUrl } from '@/lib/metadata/create_metadata' +import { docSource } from '@/lib/source' + +export const revalidate = false + +export default async function sitemap(): Promise { + const url = (path: string): string => new URL(path, baseUrl).toString() + + return [ + { + url: url('/'), + changeFrequency: 'monthly', + priority: 1, + }, + { + url: url('/docs'), + changeFrequency: 'monthly', + priority: 0.8, + }, + ...docSource.getPages().flatMap((page) => { + const { lastModified } = page.data + + return { + url: url(page.url), + lastModified: lastModified ? new Date(lastModified) : undefined, + changeFrequency: 'weekly', + priority: 0.5, + } as MetadataRoute.Sitemap[number] + }), + ] +} diff --git a/packages/web/src/app/utils.css b/packages/web/src/app/utils.css new file mode 100644 index 0000000..8b43573 --- /dev/null +++ b/packages/web/src/app/utils.css @@ -0,0 +1,83 @@ +@utility glass-1 { + @apply border-border from-card/80 to-card/40 dark:border-border/10 dark:border-b-border/5 dark:border-t-border/20 dark:from-card/5 dark:to-card/0 border bg-linear-to-b; +} +@utility glass-2 { + @apply border-border from-card/100 to-card/80 dark:border-border/10 dark:border-b-border/5 dark:border-t-border/20 dark:from-card/10 dark:to-card/5 border bg-linear-to-b; +} +@utility glass-3 { + @apply border-border from-card/30 to-card/20 dark:border-border/10 dark:border-t-border/20 dark:border-b-border/5 dark:from-primary/5 dark:to-primary/2 border bg-linear-to-b; +} +@utility glass-4 { + @apply border-border border-b-input/90 from-card/60 to-card/20 dark:border-border/10 dark:border-t-border/30 dark:from-primary/10 dark:to-primary/5 border bg-linear-to-b dark:border-b-0; +} +@utility glass-5 { + @apply border-border border-b-input from-card/100 to-card/20 dark:border-border/10 dark:border-t-border/30 dark:from-primary/15 dark:to-primary/5 border bg-linear-to-b dark:border-b-0; +} + +/* Fade effects */ +@utility fade-x { + mask-image: linear-gradient( + to right, + transparent 0%, + black 25%, + black 75%, + transparent 100% + ); +} +@utility fade-y { + mask-image: linear-gradient( + to top, + transparent 0%, + black 25%, + black 75%, + transparent 100% + ); +} +@utility fade-top { + mask-image: linear-gradient(to bottom, transparent 0%, black 35%); +} +@utility fade-bottom { + mask-image: linear-gradient(to top, transparent 0%, black 35%); +} +@utility fade-top-lg { + mask-image: linear-gradient(to bottom, transparent 15%, black 100%); +} +@utility fade-bottom-lg { + mask-image: linear-gradient(to top, transparent 15%, black 100%); +} +@utility fade-left { + mask-image: linear-gradient(to right, transparent 0%, black 35%); +} +@utility fade-right { + mask-image: linear-gradient(to left, transparent 0%, black 35%); +} +@utility fade-left-lg { + mask-image: linear-gradient(to right, transparent 15%, black 100%); +} +@utility fade-right-lg { + mask-image: linear-gradient(to left, transparent 15%, black 100%); +} + +@utility line-y { + @apply border-border dark:border-border/10; + border-width: 0 var(--line-width, 0); +} + +@utility line-x { + @apply border-border dark:border-border/10; + border-width: var(--line-width, 0) 0; +} + +@utility line-b { + @apply border-border dark:border-border/10; + border-width: 0 0 var(--line-width, 0); +} + +@utility line-t { + @apply border-border dark:border-border/10; + border-width: var(--line-width, 0) 0; +} + +@utility line-dashed { + @apply border-dashed; +} diff --git a/packages/web/src/components/banner/banner.tsx b/packages/web/src/components/banner/banner.tsx new file mode 100644 index 0000000..87b7fa5 --- /dev/null +++ b/packages/web/src/components/banner/banner.tsx @@ -0,0 +1,97 @@ +'use client' + +import { a } from '@freestylejs/ani-core' +import { DM_Serif_Display } from 'next/font/google' +import Image from 'next/image' +import Link from 'next/link' +import { useEffect, useRef } from 'react' +import { CONFIG } from '@/constant/config' +import { tw } from '@/lib/utils' +import { Button } from '../ui' +import BannerImage from './bg.png' + +const dmSerif = DM_Serif_Display({ + weight: '400', + subsets: ['latin'], +}) + +const controller = a.timeline( + a.sequence( + [ + a.ani({ + to: { opacity: 1, skew: 0, translateX: 0, scale: 1 }, + duration: 1.5, + }), + ], + a.timing.spring({ m: 1, k: 100, c: 20 }) + ) +) + +export const Banner = ({ + title, + description, + subDescription, + linkDescription, + linkUrl, + noAnimation, +}: { + title: string + description: string + subDescription: string + linkDescription: string + linkUrl: string + noAnimation: boolean +}) => { + const target = useRef(null) + + useEffect(() => { + if (!target.current) return + if (noAnimation) return + controller.play(target.current, { + from: { opacity: 0, skew: 2, translateX: -10, scale: 0.975 }, + }) + }, [noAnimation]) + + return ( +
+ banner + +
+

+ {title} +

+ +

{description}

+ +

{subDescription}

+ +
+ + + +
+
+
+ ) +} diff --git a/packages/web/src/components/banner/bg.png b/packages/web/src/components/banner/bg.png new file mode 100644 index 0000000..6a0b690 Binary files /dev/null and b/packages/web/src/components/banner/bg.png differ diff --git a/packages/web/src/components/banner/index.ts b/packages/web/src/components/banner/index.ts new file mode 100644 index 0000000..b860eae --- /dev/null +++ b/packages/web/src/components/banner/index.ts @@ -0,0 +1 @@ +export * from './banner' diff --git a/packages/web/src/components/github/action.tsx b/packages/web/src/components/github/action.tsx new file mode 100644 index 0000000..60432d0 --- /dev/null +++ b/packages/web/src/components/github/action.tsx @@ -0,0 +1,193 @@ +'use client' + +import { cva } from 'class-variance-authority' +import { buttonVariants } from 'fumadocs-ui/components/ui/button' +import { + Collapsible, + CollapsibleContent, +} from 'fumadocs-ui/components/ui/collapsible' +import { ThumbsDown, ThumbsUp } from 'lucide-react' +import { usePathname } from 'next/navigation' +import { type SyntheticEvent, useEffect, useState, useTransition } from 'react' +import { cn } from '@/lib/utils' + +const rateButtonVariants = cva( + 'inline-flex items-center gap-2 px-3 py-2 rounded-full font-medium border text-sm [&_svg]:size-4 disabled:cursor-not-allowed', + { + variants: { + active: { + true: 'bg-fd-accent text-fd-accent-foreground [&_svg]:fill-current', + false: 'text-fd-muted-foreground', + }, + }, + } +) + +export interface Feedback { + opinion: 'good' | 'bad' + url?: string + message: string +} + +export interface ActionResponse { + githubUrl: string +} + +interface Result extends Feedback { + response?: ActionResponse +} + +export const Feedback = ({ + onRateAction, +}: { + onRateAction: (url: string, feedback: Feedback) => Promise +}) => { + const url = usePathname() + const [previous, setPrevious] = useState(null) + const [opinion, setOpinion] = useState<'good' | 'bad' | null>(null) + const [message, setMessage] = useState('') + const [isPending, startTransition] = useTransition() + + useEffect(() => { + const item = localStorage.getItem(`docs-feedback-${url}`) + + if (item === null) return + setPrevious(JSON.parse(item) as Result) + }, [url]) + + useEffect(() => { + const key = `docs-feedback-${url}` + + if (previous) localStorage.setItem(key, JSON.stringify(previous)) + else localStorage.removeItem(key) + }, [previous, url]) + + function submit(e?: SyntheticEvent) { + if (opinion == null) return + + startTransition(async () => { + const feedback: Feedback = { + opinion, + message, + } + + void onRateAction(url, feedback).then((response) => { + setPrevious({ + response, + ...feedback, + }) + setMessage('') + setOpinion(null) + }) + }) + + e?.preventDefault() + } + + const activeOpinion = previous?.opinion ?? opinion + + return ( + { + if (!v) setOpinion(null) + }} + className="border-y py-3" + > +
+

How is this guide?

+ + +
+ + {previous ? ( +
+

Thank you for your feedback!

+
+ + View on GitHub + + + +
+
+ ) : ( +
+