diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 7e13807..70dca51 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -1,4 +1,5 @@ name: Publish State Packages + on: workflow_dispatch: inputs: @@ -15,9 +16,108 @@ on: required: true type: string + push: + tags: + - 'v*' + - '*-v*' + +permissions: + contents: read + packages: write + jobs: - placeholder: + setup: + runs-on: ubuntu-latest + outputs: + states: ${{ steps.determine.outputs.states }} + version: ${{ steps.determine.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Determine states and version + id: determine + run: | + # Get all available states from overlay directories + ALL_STATES=$(ls -d packages/schemas/openapi/overlays/*/ 2>/dev/null | \ + xargs -n1 basename | \ + jq -R -s -c 'split("\n") | map(select(length > 0))') + + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + # Manual trigger + VERSION="${{ github.event.inputs.version }}" + if [[ "${{ github.event.inputs.state }}" == "all" ]]; then + STATES="$ALL_STATES" + else + STATES='["${{ github.event.inputs.state }}"]' + fi + else + # Tag push + TAG="${GITHUB_REF#refs/tags/}" + + if [[ "$TAG" =~ ^v([0-9]+\.[0-9]+\.[0-9]+.*) ]]; then + # Format: v1.0.0 or v1.0.0-beta.1 - publish all states + VERSION="${BASH_REMATCH[1]}" + STATES="$ALL_STATES" + elif [[ "$TAG" =~ ^([a-z]+)-v([0-9]+\.[0-9]+\.[0-9]+.*) ]]; then + # Format: california-v1.0.0 - publish single state + STATE="${BASH_REMATCH[1]}" + VERSION="${BASH_REMATCH[2]}" + STATES="[\"$STATE\"]" + else + echo "::error::Invalid tag format: $TAG. Expected v1.0.0 or state-v1.0.0" + exit 1 + fi + fi + + echo "states=$STATES" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Publishing states: $STATES at version $VERSION" + + publish: + needs: setup + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + state: ${{ fromJson(needs.setup.outputs.states) }} + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://npm.pkg.github.com' + scope: '@codeforamerica' + + - name: Install dependencies + run: npm install + + - name: Build state package + run: | + node packages/clients/scripts/build-state-package.js \ + --state=${{ matrix.state }} \ + --version=${{ needs.setup.outputs.version }} + + - name: Publish to GitHub Packages + working-directory: packages/clients/dist-packages/${{ matrix.state }} + run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + summary: + needs: [setup, publish] runs-on: ubuntu-latest + if: always() steps: - - name: Placeholder - run: echo "This workflow is under development. Run from a feature branch to test." \ No newline at end of file + - name: Summary + run: | + echo "## Published Packages" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Version: ${{ needs.setup.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "States:" >> $GITHUB_STEP_SUMMARY + for state in $(echo '${{ needs.setup.outputs.states }}' | jq -r '.[]'); do + echo "- @codeforamerica/safety-net-${state}@${{ needs.setup.outputs.version }}" >> $GITHUB_STEP_SUMMARY + done diff --git a/.gitignore b/.gitignore index 86d5e17..d737ec4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,13 +2,18 @@ node_modules/ package-lock.json .DS_Store *.log +.idea # Generated artifacts (state-specific, regenerate as needed) -generated/ -packages/schemas/openapi/resolved/ +packages/generated +packages/clients/generated +packages/clients/dist-packages +packages/schemas/openapi/resolved # Cursor AI working files cursor/ # Claude Code local settings .claude/ +CLAUDE.md +Agents.md \ No newline at end of file diff --git a/docs/architecture-decisions/openapi-ts-client-generation.md b/docs/architecture-decisions/openapi-ts-client-generation.md new file mode 100644 index 0000000..d1df5d9 --- /dev/null +++ b/docs/architecture-decisions/openapi-ts-client-generation.md @@ -0,0 +1,310 @@ +# ADR: Migration from Zodios to @hey-api/openapi-ts + +**Status:** Accepted + +**Date:** 2026-01-14 + +**Deciders:** Development Team + +--- + +## Context + +The Safety Net OpenAPI toolkit generates TypeScript clients for consuming the API. These clients have three distinct validation and type-safety requirements: + +1. **Request validation** — Validate payloads before sending API requests +2. **Response validation** — Validate API responses at runtime +3. **Static type safety** — TypeScript types for IDE autocomplete, refactoring, and compile-time error detection + +Previously, we used `openapi-zod-client` to generate Zodios clients. This approach had limitations in maintainability and the separation of concerns between runtime validation and static typing. + +### Requirements + +- Generate TypeScript types from OpenAPI specs for compile-time safety +- Generate Zod schemas for runtime validation of both requests and responses +- Generate a typed HTTP client that integrates validation automatically +- Use actively maintained tooling with community support +- Support our state-specific overlay workflow + +### Constraints + +- Must work with OpenAPI 3.1 specifications +- Must integrate with existing build pipeline (`build-state-package.js`) +- Generated code should work with Zod 4.x (latest) + +--- + +## Decision + +Replace `openapi-zod-client` (Zodios) with **@hey-api/openapi-ts** for client generation. + +### How It Works + +`@hey-api/openapi-ts` uses a plugin architecture to generate different outputs from OpenAPI specs: + +```javascript +// openapi-ts.config.js +export default { + input: './bundled-spec.yaml', + output: './src/persons', + plugins: [ + '@hey-api/typescript', // TypeScript types + '@hey-api/sdk', // HTTP client with typed methods + { + name: '@hey-api/zod', // Zod schemas for validation + metadata: true, + }, + { + name: '@hey-api/client-axios', // Axios-based HTTP adapter + }, + ], +}; +``` + +### Generated Output Structure + +``` +src/persons/ +├── index.ts # Re-exports SDK functions and types +├── types.gen.ts # TypeScript interfaces (Person, PersonCreate, etc.) +├── zod.gen.ts # Zod schemas (zPerson, zPersonCreate, etc.) +├── sdk.gen.ts # Typed SDK functions (getPerson, createPerson, etc.) +├── client.gen.ts # Default client instance +└── client/ + ├── index.ts # Client exports (createClient, createConfig) + ├── client.gen.ts # Axios client implementation + └── utils.gen.ts # Request/response utilities +``` + +--- + +## Three Validation Needs + +### 1. Request Validation (Before API Calls) + +The SDK validates request payloads before sending them to the API: + +```typescript +// sdk.gen.ts (generated) +export const createPerson = (options) => client.post({ + requestValidator: async (data) => await zCreatePersonData.parseAsync(data), + url: '/persons', + ...options, +}); +``` + +**Benefit:** Invalid requests fail fast with clear Zod error messages before any network call. + +### 2. Response Validation (After API Calls) + +The SDK validates API responses using generated Zod schemas: + +```typescript +// sdk.gen.ts (generated) +export const getPerson = (options) => client.get({ + responseValidator: async (data) => await zGetPersonResponse.parseAsync(data), + url: '/persons/{personId}', + ...options, +}); +``` + +**Benefit:** Catches API contract violations at runtime. If the backend returns unexpected data, the client fails with a clear validation error rather than silently accepting malformed data. + +### 3. Static Type Safety (Development Experience) + +TypeScript types are generated separately from Zod schemas: + +```typescript +// types.gen.ts (generated) +export interface Person { + id: string; + name: { + firstName: string; + lastName: string; + middleInitial?: string; + }; + email: string; + dateOfBirth?: string; + // ... full type definition +} +``` + +**Benefit:** IDE autocomplete, refactoring support, and compile-time error detection—without the runtime overhead of Zod in type-only contexts. + +### Separation of Concerns + +| Need | Generated File | Runtime Cost | Use Case | +|------|---------------|--------------|----------| +| Request validation | `zod.gen.ts` | Yes (Zod parse) | SDK internal validation | +| Response validation | `zod.gen.ts` | Yes (Zod parse) | SDK internal validation | +| Static types | `types.gen.ts` | None | IDE, compilation, type imports | + +Consumers can import types without pulling in Zod: + +```typescript +// Type-only import — no runtime Zod dependency +import type { Person } from '@codeforamerica/safety-net-/persons'; + +// Runtime import — includes Zod schemas +import { zPerson } from '@codeforamerica/safety-net-/persons/zod.gen'; +``` + +--- + +## Options Considered + +### Option 1: openapi-zod-client / Zodios (Previous) + +```typescript +// Zodios pattern - types derived from Zod schemas +const api = new Zodios(baseUrl, [ + { + method: 'get', + path: '/persons/:id', + response: PersonSchema, // Zod schema doubles as type + }, +]); +``` + +| Pros | Cons | +|------|------| +| Single source of truth (Zod = types) | Types always carry Zod overhead | +| Mature pattern | **Zodios is unmaintained** (no releases in 12+ months) | +| Works well for small APIs | Complex Zod schemas cause TS7056 errors | + +**Rejected because:** Zodios is effectively abandoned. Snyk classifies `@zodios/core` as "Inactive" with no npm releases since early 2024. GitHub discussions from 2024-2025 remain unanswered. + +--- + +### Option 2: openapi-typescript (asteasolutions) + +```typescript +// Generates types only, no runtime validation +import type { paths } from './schema'; +type Person = paths['/persons/{id}']['get']['responses']['200']['content']['application/json']; +``` + +| Pros | Cons | +|------|------| +| Very lightweight | No Zod schemas generated | +| Fast generation | No runtime validation | +| Active maintenance | Awkward path-based type access | + +**Rejected because:** We need runtime validation. Manual Zod schema writing defeats the purpose of code generation. + +--- + +### Option 3: @hey-api/openapi-ts (CHOSEN) + +| Pros | Cons | +|------|------| +| Actively maintained | Plugin configuration complexity | +| Separate types and Zod schemas | Generated client has some TS warnings | +| Used by Vercel, PayPal | Newer project (less history) | +| 20+ plugins available | | +| Request + response validation built-in | | + +**Accepted because:** Best balance of features, maintenance, and architecture. The plugin system cleanly separates types from validation schemas. + +--- + +### Option 4: Hand-written Zod + Types + +Manually write Zod schemas and derive types with `z.infer<>`. + +| Pros | Cons | +|------|------| +| Full control | Defeats purpose of OpenAPI-first | +| No generator bugs | Schema drift from OpenAPI | +| | High maintenance burden | + +**Rejected because:** We have 4 domain APIs with complex schemas. Manual maintenance is not sustainable. + +--- + +## Maintenance Comparison + +| Library | GitHub Stars | Last Release | Maintenance Status | Notable Users | +|---------|-------------|--------------|-------------------|---------------| +| `@hey-api/openapi-ts` | ~3,800 | Active (2025) | Actively maintained | Vercel, PayPal | +| `@zodios/core` | ~1,700 | Early 2024 | **Inactive** (Snyk) | — | +| `openapi-zod-client` | ~800 | Sporadic | Low activity | — | + +The @hey-api/openapi-ts project: +- Has 289+ forks and active issue triage +- Is sponsor-funded with a public roadmap +- Started as a fork specifically to provide better maintenance + +--- + +## Implementation + +### Build Script Changes + +`packages/clients/scripts/build-state-package.js` now: + +1. Bundles each domain spec with `@apidevtools/swagger-cli --dereference` +2. Generates clients per domain using `@hey-api/openapi-ts` +3. Creates domain-specific exports (`./persons`, `./applications`, etc.) + +### Package Exports + +```json +{ + "exports": { + ".": { "import": "./dist/index.js" }, + "./persons": { "import": "./dist/persons/index.js" }, + "./persons/client": { "import": "./dist/persons/client/index.js" }, + "./persons/*": { "import": "./dist/persons/*.js" } + } +} +``` + +### Consumer Usage + +```typescript +// Import SDK functions (includes validation) +import { listPersons, getPerson, createPerson } from '@codeforamerica/safety-net-/persons'; + +// Import client configuration +import { createClient, createConfig } from '@codeforamerica/safety-net-/persons/client'; + +// Configure client with custom base URL +const client = createClient(createConfig({ baseURL: 'http://localhost:1080' })); + +// Use SDK with custom client +const response = await getPerson({ path: { personId: '123' }, client }); +``` + +--- + +## Consequences + +### Positive + +- **Maintained tooling** — Active development and community support +- **Clean separation** — Types for DX, Zod for runtime, SDK for HTTP +- **Automatic validation** — Request/response validation without manual code +- **Better error messages** — Zod errors are descriptive and actionable +- **Future-proof** — Plugin architecture allows adding TanStack Query, etc. + +### Negative + +- **Generated code warnings** — Some TS errors in `client.gen.ts` (suppressed with `noEmitOnError: false`) +- **Larger output** — Separate type and Zod files vs. combined +- **Migration effort** — Consumers must update import paths + +### Mitigations + +1. **TS warnings** — Build script continues despite warnings; runtime code works correctly +2. **Import paths** — Package exports provide clean public API +3. **Documentation** — Update CLAUDE.md with new patterns + +--- + +## References + +- [@hey-api/openapi-ts GitHub](https://github.com/hey-api/openapi-ts) +- [Hey API Documentation](https://heyapi.dev/) +- [Snyk: @zodios/core Package Health](https://snyk.io/advisor/npm-package/@zodios/core) +- [Zodios GitHub Discussions](https://github.com/ecyrbe/zodios/discussions) diff --git a/docs/getting-started/backend-developers.md b/docs/getting-started/backend-developers.md index c2e8fcb..0014641 100644 --- a/docs/getting-started/backend-developers.md +++ b/docs/getting-started/backend-developers.md @@ -27,7 +27,7 @@ cd safety-net-openapi npm install # Set your state (or add to your shell profile) -export STATE=california +export STATE= # Verify installation npm run validate:state @@ -42,8 +42,7 @@ openapi/ ├── examples/ # Example data for seeding the mock server ├── patterns/ # API design patterns and conventions └── overlays/ # State-specific variations - ├── california.overlay.yaml - └── colorado.overlay.yaml + └── /modifications.yaml ``` ## Your First Workflow @@ -68,7 +67,7 @@ Before making changes, ensure everything validates: npm run validate # Validate your state's resolved specs -STATE=california npm run validate:state +STATE= npm run validate:state # Validate all states npm run validate:all-states @@ -95,10 +94,10 @@ If your state needs different enum values, additional fields, or terminology cha ```bash # Edit your state's overlay -code openapi/overlays/california.overlay.yaml +code openapi/overlays//modifications.yaml # Validate the resolved spec -STATE=california npm run validate:state +STATE= npm run validate:state ``` See [State Overlays Guide](../guides/state-overlays.md) for overlay syntax. @@ -125,7 +124,7 @@ See [Creating APIs Guide](../guides/creating-apis.md) for customization. # 2. Validate npm run validate -STATE=california npm run validate:state +STATE= npm run validate:state # 3. Test with mock server npm run mock:reset diff --git a/docs/getting-started/frontend-developers.md b/docs/getting-started/frontend-developers.md index 3d36549..374e59d 100644 --- a/docs/getting-started/frontend-developers.md +++ b/docs/getting-started/frontend-developers.md @@ -1,11 +1,11 @@ # Getting Started: Frontend Developers -This guide is for developers building frontend applications that consume Safety Net APIs. You'll use generated TypeScript clients and the mock server for local development. +This guide is for developers building frontend applications that consume Safety Net APIs. You'll use pre-built npm packages with TypeScript SDK functions and Zod schemas. ## What You'll Do -- Generate type-safe API clients for your state -- Integrate clients into your frontend application +- Install a state-specific npm package with typed SDK and Zod schemas +- Integrate into your frontend application - Develop against the mock server while the backend is in progress - Set up CI/CD to test your frontend @@ -14,198 +14,168 @@ This guide is for developers building frontend applications that consume Safety - Node.js >= 18.0.0 - A frontend project (React, Vue, etc.) - Familiarity with TypeScript +- GitHub account (for package access) ## Initial Setup -```bash -# Clone the repository -git clone https://github.com/codeforamerica/safety-net-openapi.git -cd safety-net-openapi +### 1. Configure npm for GitHub Packages -# Install dependencies -npm install +The packages are published to GitHub Packages. Create or update `.npmrc` in your project root: -# Set your state -export STATE=california +``` +@codeforamerica:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} ``` -## Your First Workflow +Set your GitHub token (needs `read:packages` scope): -### 1. Generate API Clients +```bash +export GITHUB_TOKEN=ghp_your_token_here +``` -Generate TypeScript clients for your state: +### 2. Install the Package ```bash -STATE=california npm run clients:generate +npm install @codeforamerica/safety-net- ``` -This creates type-safe clients in `generated/clients/zodios/`: -- `persons.ts` -- `households.ts` -- `applications.ts` -- etc. - -### 2. Copy Clients to Your Project +### 3. Install Peer Dependencies ```bash -# Copy to your frontend project -cp -r generated/clients/zodios/* ../your-frontend/src/api/ +npm install zod axios ``` -### 3. Install Dependencies in Your Frontend +## Using the Package -The generated clients require: +### Importing SDK Functions and Types -```bash -cd ../your-frontend -npm install @zodios/core zod axios +```typescript +// Direct imports from domain modules +import { + getPerson, + listPersons, + createPerson, + type Person, + type PersonList +} from '@codeforamerica/safety-net-/persons'; + +// Or namespaced imports +import { persons, applications } from '@codeforamerica/safety-net-'; ``` -### 4. Configure and Use +### Configuring the Client -Create a configuration file in your frontend: +Create a client configuration file: ```typescript -// src/api/config.ts -import { Zodios } from '@zodios/core'; -import { personsApi } from './persons'; -import { householdsApi } from './households'; +// src/api/client.ts +import { persons, applications } from '@codeforamerica/safety-net-'; +import { createClient, createConfig } from '@codeforamerica/safety-net-/persons/client'; const BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:1080'; -export const personsClient = new Zodios(BASE_URL, personsApi); -export const householdsClient = new Zodios(BASE_URL, householdsApi); +// Create a configured client +export const client = createClient(createConfig({ + baseURL: BASE_URL, +})); -// Add authentication -export function setAuthToken(token: string) { - const authHeader = { Authorization: `Bearer ${token}` }; - personsClient.axios.defaults.headers.common = authHeader; - householdsClient.axios.defaults.headers.common = authHeader; -} +// Bind SDK functions to your client +export const listPersons = (options?: Parameters[0]) => + persons.listPersons({ ...options, client }); + +export const getPerson = (options: Parameters[0]) => + persons.getPerson({ ...options, client }); + +export const createPerson = (options: Parameters[0]) => + persons.createPerson({ ...options, client }); + +// Re-export types for convenience +export type { Person, PersonList } from '@codeforamerica/safety-net-/persons'; ``` -Use in your components: +### Using in Components ```typescript // src/components/PersonList.tsx -import { personsClient } from '../api/config'; +import { useEffect, useState } from 'react'; +import { listPersons, type Person } from '../api/client'; export function PersonList() { - const [persons, setPersons] = useState([]); + const [persons, setPersons] = useState([]); + const [loading, setLoading] = useState(true); useEffect(() => { - personsClient - .listPersons({ queries: { limit: 25 } }) - .then((response) => setPersons(response.items)); + async function fetchPersons() { + const response = await listPersons({ query: { limit: 25 } }); + if ('data' in response && response.data) { + setPersons(response.data.items ?? []); + } + setLoading(false); + } + fetchPersons(); }, []); + if (loading) return
Loading...
; + return (
    {persons.map((person) => ( -
  • {person.name.firstName}
  • +
  • {person.name?.firstName} {person.name?.lastName}
  • ))}
); } ``` -### 5. Develop Against the Mock Server - -While the backend is being built, use the mock server for development: +### Runtime Validation with Zod -```bash -# In the safety-net-openapi directory -STATE=california npm start -``` +For custom validation scenarios, import Zod schemas directly: -The mock server runs at http://localhost:1080 with realistic test data. - -Point your frontend at it: - -```bash -# In your frontend -REACT_APP_API_URL=http://localhost:1080 npm start +```typescript +import { zPerson } from '@codeforamerica/safety-net-/persons/zod.gen'; + +// Validate API response +const parseResult = zPerson.safeParse(apiResponse); +if (parseResult.success) { + return parseResult.data; +} else { + console.error('Validation failed:', parseResult.error); +} ``` ## Development Workflow -### Daily Development +### Develop Against the Mock Server + +While the backend is being built, use the mock server: ```bash -# Terminal 1: Start mock server +# Clone the toolkit (one-time setup) +git clone https://github.com/codeforamerica/safety-net-openapi.git cd safety-net-openapi -STATE=california npm start +npm install -# Terminal 2: Start your frontend -cd your-frontend -REACT_APP_API_URL=http://localhost:1080 npm start +# Start the mock server +STATE= npm start ``` -### When the API Spec Changes - -```bash -# Pull latest specs -cd safety-net-openapi -git pull +The mock server runs at http://localhost:1080 with realistic test data. -# Regenerate clients -STATE=california npm run clients:generate +Point your frontend at it: -# Copy to your project -cp -r generated/clients/zodios/* ../your-frontend/src/api/ +```bash +# In your frontend +REACT_APP_API_URL=http://localhost:1080 npm start ``` -Consider adding a script to your frontend's `package.json`: +### When the Package Updates -```json -{ - "scripts": { - "api:update": "cd ../safety-net-openapi && git pull && STATE=california npm run clients:generate && cp -r generated/clients/zodios/* src/api/" - } -} -``` +Simply update your package version: -## CI/CD Integration - -### Testing Against Mock Server - -```yaml -# .github/workflows/test.yml -name: Frontend Tests - -on: [push, pull_request] - -jobs: - test: - runs-on: ubuntu-latest - steps: - - name: Checkout frontend - uses: actions/checkout@v4 - - - name: Checkout API toolkit - uses: actions/checkout@v4 - with: - repository: codeforamerica/safety-net-openapi - path: openapi-toolkit - - - name: Start mock server - working-directory: openapi-toolkit - run: | - npm install - STATE=california npm run mock:setup - STATE=california npm run mock:start & - sleep 5 - - - name: Run frontend tests - env: - REACT_APP_API_URL: http://localhost:1080 - run: | - npm install - npm test +```bash +npm update @codeforamerica/safety-net- ``` -See [CI/CD for Frontend](../integration/ci-cd-frontend.md) for more options. - ## Exploring the API ### Swagger UI @@ -214,13 +184,10 @@ Browse the API documentation interactively: ```bash cd safety-net-openapi -STATE=california npm start +STATE= npm start ``` -Visit http://localhost:3000 to: -- See all available endpoints -- View request/response schemas -- Try out requests +Visit http://localhost:3000 to see all endpoints and schemas. ### Example Requests @@ -235,16 +202,42 @@ curl "http://localhost:1080/persons?q=status:active" curl http://localhost:1080/persons/{id} ``` -## Key Commands +## Package Contents + +Each state package exports domain modules: + +| Module | SDK Functions | +|--------|---------------| +| `persons` | `listPersons`, `getPerson`, `createPerson`, `updatePerson`, `deletePerson` | +| `applications` | `listApplications`, `getApplication`, `createApplication`, `updateApplication`, `deleteApplication` | +| `households` | `listHouseholds`, `getHousehold`, `createHousehold`, `updateHousehold`, `deleteHousehold` | +| `incomes` | `listIncomes`, `getIncome`, `createIncome`, `updateIncome`, `deleteIncome` | + +Each module also exports: +- TypeScript types (`Person`, `PersonList`, `PersonCreate`, etc.) +- Zod schemas via `./zod.gen` subpath (`zPerson`, `zPersonList`, etc.) +- Client utilities via `./client` subpath (`createClient`, `createConfig`) + +### Search Helpers + +The package also exports utilities for building search queries: + +```typescript +import { q, search } from '@codeforamerica/safety-net-'; + +const query = q( + search.contains('name.firstName', 'john'), + search.gte('monthlyIncome', 2000), + search.eq('status', 'active') +); + +const response = await listPersons({ query: { q: query } }); +``` -| Command | When to Use | -|---------|-------------| -| `npm run clients:generate` | After spec changes, to get new types | -| `npm start` | To run mock server for development | -| `npm run mock:reset` | To reset test data | +See [API Clients - Search Helpers](../integration/api-clients.md#search-helpers) for the full reference. ## Next Steps -- [API Clients](../integration/api-clients.md) — Detailed client usage and React Query integration +- [API Clients](../integration/api-clients.md) — Detailed client usage and framework integrations - [Mock Server](../guides/mock-server.md) — Search, pagination, and data management - [CI/CD for Frontend](../integration/ci-cd-frontend.md) — Testing setup diff --git a/docs/guides/mock-server.md b/docs/guides/mock-server.md index 12640b6..8eff5d5 100644 --- a/docs/guides/mock-server.md +++ b/docs/guides/mock-server.md @@ -6,7 +6,7 @@ Auto-generated mock APIs from OpenAPI specs with SQLite persistence. ```bash # Set your state first -export STATE=california +export STATE= npm run mock:start # Start server (port 1080) npm run mock:reset # Reset database to example data diff --git a/docs/guides/search-patterns.md b/docs/guides/search-patterns.md index aabd40b..7df3ff4 100644 --- a/docs/guides/search-patterns.md +++ b/docs/guides/search-patterns.md @@ -48,3 +48,20 @@ curl "http://localhost:1080/persons?q=status:active%20income:>=1000" | `-field:*` | Field does not exist | `-email:*` | - | | `field.nested:value` | Nested field | `address.state:CA` | Yes | | `term1 term2` | Multiple conditions (AND) | `status:active income:>=1000` | - | + +## TypeScript Search Helpers + +When using the generated npm packages, you can build queries programmatically: + +```typescript +import { q, search } from '@codeforamerica/safety-net-'; + +const query = q( + search.eq('status', 'active'), + search.gte('monthlyIncome', 1000), + search.contains('name.lastName', 'smith') +); +// Result: "status:active monthlyIncome:>=1000 name.lastName:*smith*" +``` + +See [API Clients - Search Helpers](../integration/api-clients.md#search-helpers) for the complete reference. diff --git a/docs/guides/state-overlays.md b/docs/guides/state-overlays.md index ba1933b..6289bf3 100644 --- a/docs/guides/state-overlays.md +++ b/docs/guides/state-overlays.md @@ -13,19 +13,19 @@ State overlays allow you to customize API specifications for different states wi ```bash # Set via environment variable -export STATE=california +export STATE= # Or prefix commands -STATE=california npm start -STATE=colorado npm run validate:state +STATE= npm start +STATE= npm run validate:state ``` ## Available States ```bash -# List available states +# List available states (run without STATE set) npm run overlay:resolve -# Output: Available states: california, colorado +# Output: Available states: ``` ## Overlay File Structure @@ -33,12 +33,12 @@ npm run overlay:resolve Overlays use the [OpenAPI Overlay Specification 1.0.0](https://github.com/OAI/Overlay-Specification): ```yaml -# openapi/overlays/california/modifications.yaml +# openapi/overlays//modifications.yaml overlay: 1.0.0 info: - title: California State Overlay + title: Overlay version: 1.0.0 - description: California-specific modifications + description: -specific modifications actions: # Replace enum values @@ -137,8 +137,8 @@ Targets use JSONPath-like syntax: ```bash # Create state directory and copy an existing overlay as a template -mkdir openapi/overlays/newstate -cp openapi/overlays/california/modifications.yaml openapi/overlays/newstate/modifications.yaml +mkdir openapi/overlays/ +cp openapi/overlays//modifications.yaml openapi/overlays//modifications.yaml ``` ### 2. Update the Metadata @@ -169,7 +169,7 @@ actions: ### 4. Validate ```bash -STATE=newstate npm run validate:state +STATE= npm run validate:state ``` The resolver will warn you about any invalid targets: @@ -222,7 +222,7 @@ Each action should do one thing. Don't combine unrelated changes: Always validate after modifying overlays: ```bash -STATE=yourstate npm run validate:state +STATE= npm run validate:state ``` ### Document State Differences diff --git a/docs/guides/validation.md b/docs/guides/validation.md index 2f9d4e5..dfd7217 100644 --- a/docs/guides/validation.md +++ b/docs/guides/validation.md @@ -14,9 +14,9 @@ npm run validate:patterns # API design patterns only When working with state overlays, use these commands to validate resolved specs: ```bash -STATE=california npm run validate:state # Resolve + validate one state -npm run validate:state -- --state=colorado -npm run validate:all-states # Resolve + validate all states +STATE= npm run validate:state # Resolve + validate one state +npm run validate:state -- --state= +npm run validate:all-states # Resolve + validate all states ``` This runs the overlay resolution first (reporting any invalid targets), then validates the resolved specs. diff --git a/docs/integration/api-clients.md b/docs/integration/api-clients.md index 4842c3e..d17d3b6 100644 --- a/docs/integration/api-clients.md +++ b/docs/integration/api-clients.md @@ -1,132 +1,333 @@ -# API Client Generator +# API Client Packages -Generate type-safe Zodios/TypeScript clients from OpenAPI specs. +State-specific npm packages with typed SDK functions and Zod schemas for runtime validation. -## Quick Start +## Installation + +### 1. Configure GitHub Packages + +Create `.npmrc` in your project root: + +``` +@codeforamerica:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} +``` + +### 2. Install Your State Package ```bash -STATE=california npm run clients:generate +npm install @codeforamerica/safety-net- + +# Peer dependencies +npm install zod axios ``` -Output: `generated/clients/zodios/*.ts` +## Package Structure -## Integrating into Your Front-End Application +Each package exports domain modules: -The generated clients are state-specific and designed to be copied into your front-end project. Here's how to integrate them: +```typescript +import { persons, applications, households, incomes } from '@codeforamerica/safety-net-'; +``` -### Step 1: Generate Clients for Your State +Each domain module provides: -```bash -# In the safety-net-openapi toolkit -STATE=california npm run clients:generate +| Export | Description | +|--------|-------------| +| SDK functions | `getPerson`, `createPerson`, `listPersons`, etc. | +| Types | `Person`, `PersonCreate`, `PersonList`, etc. | +| Client utilities | `createClient`, `createConfig` | + +The root export also provides search utilities: + +| Export | Description | +|--------|-------------| +| `q()` | Combines multiple search conditions into a query string | +| `search` | Object with methods like `eq()`, `contains()`, `gte()`, etc. | + +### Import Paths + +```typescript +// Root - namespaced access to all domains + search helpers +import { persons, applications, q, search } from '@codeforamerica/safety-net-'; + +// Domain-specific - direct imports +import { getPerson, createPerson, type Person } from '@codeforamerica/safety-net-/persons'; + +// Client configuration +import { createClient, createConfig } from '@codeforamerica/safety-net-/persons/client'; + +// Zod schemas for custom validation +import { zPerson, zPersonList } from '@codeforamerica/safety-net-/persons/zod.gen'; + +// Search helpers (alternative import path) +import { q, search } from '@codeforamerica/safety-net-/search'; ``` -### Step 2: Copy to Your Front-End Project +## Basic Usage -```bash -# Copy the generated clients to your project -cp -r generated/clients/zodios/* ../your-frontend/src/api/ +### Configure the Client + +```typescript +// src/api/client.ts +import { persons, applications, households } from '@codeforamerica/safety-net-'; +import { createClient, createConfig } from '@codeforamerica/safety-net-/persons/client'; + +const BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:1080'; + +// Create a configured client +export const client = createClient(createConfig({ + baseURL: BASE_URL, +})); + +// Bind SDK functions to your client +export const listPersons = (options?: Parameters[0]) => + persons.listPersons({ ...options, client }); -# Or set up a script in your frontend's package.json +export const getPerson = (options: Parameters[0]) => + persons.getPerson({ ...options, client }); + +export const createPerson = (options: Parameters[0]) => + persons.createPerson({ ...options, client }); + +export const updatePerson = (options: Parameters[0]) => + persons.updatePerson({ ...options, client }); + +export const deletePerson = (options: Parameters[0]) => + persons.deletePerson({ ...options, client }); + +// Re-export types +export type { Person, PersonList, PersonCreate } from '@codeforamerica/safety-net-/persons'; ``` -### Step 3: Install Dependencies +### Using SDK Functions -The generated clients require these packages: +```typescript +import { getPerson, listPersons, createPerson, updatePerson, deletePerson } from './api/client'; -```bash -npm install @zodios/core zod axios +// List with pagination and search +const response = await listPersons({ + query: { limit: 10, offset: 0, q: 'status:active' } +}); + +if ('data' in response && response.data) { + console.log('Persons:', response.data.items); +} + +// Get by ID +const personResponse = await getPerson({ + path: { personId: '123e4567-e89b-12d3-a456-426614174000' } +}); + +// Create +const newPersonResponse = await createPerson({ + body: { + name: { firstName: 'Jane', lastName: 'Doe' }, + email: 'jane@example.com', + dateOfBirth: '1990-01-15', + phoneNumber: '555-123-4567', + citizenshipStatus: 'citizen', + householdSize: 1, + monthlyIncome: 3500 + } +}); + +// Update +const updatedResponse = await updatePerson({ + path: { personId: '...' }, + body: { monthlyIncome: 4000 } +}); + +// Delete +await deletePerson({ path: { personId: '...' } }); ``` -### Step 4: Configure the Client +### Response Handling -Create a configuration file to set your API base URL and authentication: +The SDK returns responses with automatic Zod validation. Handle responses like this: ```typescript -// src/api/config.ts -import { Zodios } from '@zodios/core'; -import { personsApi } from './persons'; -import { householdsApi } from './households'; -import { applicationsApi } from './applications'; - -const BASE_URL = process.env.REACT_APP_API_URL || 'https://api.example.com'; - -// Create configured clients -export const personsClient = new Zodios(BASE_URL, personsApi, { - axiosConfig: { - headers: { - 'Content-Type': 'application/json', - }, - }, -}); +const response = await getPerson({ path: { personId: id } }); + +if ('data' in response && response.data) { + // Success - data is validated + return response.data; +} else if ('error' in response) { + // Error response from API + console.error('API error:', response.error); +} +``` + +## Using Types -export const householdsClient = new Zodios(BASE_URL, householdsApi); -export const applicationsClient = new Zodios(BASE_URL, applicationsApi); +### Type-Only Imports (No Runtime Cost) + +```typescript +import type { Person, PersonCreate, PersonList } from '@codeforamerica/safety-net-/persons'; -// Add auth token dynamically -export function setAuthToken(token: string) { - const authHeader = { Authorization: `Bearer ${token}` }; - personsClient.axios.defaults.headers.common = authHeader; - householdsClient.axios.defaults.headers.common = authHeader; - applicationsClient.axios.defaults.headers.common = authHeader; +function displayPerson(person: Person) { + console.log(`${person.name?.firstName} ${person.name?.lastName}`); } ``` -### Step 5: Use in Components +### Zod Schemas for Custom Validation ```typescript -// src/components/PersonList.tsx -import { useEffect, useState } from 'react'; -import { personsClient } from '../api/config'; +import { zPerson, zPersonCreate } from '@codeforamerica/safety-net-/persons/zod.gen'; + +// Validate data manually +const result = zPerson.safeParse(unknownData); +if (result.success) { + console.log('Valid person:', result.data); +} else { + console.error('Validation errors:', result.error.issues); +} -// Types are automatically inferred from the API definition -type Person = Awaited>; +// Strict parse (throws on failure) +const person = zPerson.parse(apiResponse); +``` -export function PersonList() { - const [persons, setPersons] = useState([]); - const [loading, setLoading] = useState(true); +## Search Query Syntax - useEffect(() => { - personsClient - .listPersons({ queries: { limit: 25 } }) - .then((response) => setPersons(response.items)) - .finally(() => setLoading(false)); - }, []); +All list endpoints support a `q` parameter for filtering using `field:value` syntax. - if (loading) return
Loading...
; +### Query Syntax Reference - return ( -
    - {persons.map((person) => ( -
  • - {person.name.firstName} {person.name.lastName} -
  • - ))} -
- ); -} +| Pattern | Description | Example | +|---------|-------------|---------| +| `field:value` | Exact match | `status:approved` | +| `field:*value*` | Contains (case-insensitive) | `name:*john*` | +| `field:value*` | Starts with | `name:john*` | +| `field:*value` | Ends with | `email:*@example.com` | +| `field:"value"` | Quoted value (for spaces) | `name:"john doe"` | +| `field.nested:value` | Nested field | `address.state:CA` | +| `field:>value` | Greater than | `income:>1000` | +| `field:>=value` | Greater than or equal | `income:>=1000` | +| `field:'; +// Or from dedicated path +import { q, search } from '@codeforamerica/safety-net-/search'; +``` + +**Available search methods:** + +| Method | Description | Example Output | +|--------|-------------|----------------| +| `search.eq(field, value)` | Exact match | `status:active` | +| `search.contains(field, value)` | Contains (case-insensitive) | `name:*john*` | +| `search.startsWith(field, value)` | Starts with | `name:john*` | +| `search.endsWith(field, value)` | Ends with | `email:*@example.com` | +| `search.gt(field, value)` | Greater than | `income:>1000` | +| `search.gte(field, value)` | Greater than or equal | `income:>=1000` | +| `search.lt(field, value)` | Less than | `income:<5000` | +| `search.lte(field, value)` | Less than or equal | `income:<=5000` | +| `search.exists(field)` | Field is not null | `email:*` | +| `search.notExists(field)` | Field is null | `-email:*` | +| `search.oneOf(field, values)` | Match any value | `status:active,pending` | +| `search.not(field, value)` | Exclude value | `-status:denied` | + +**Combining conditions with `q()`:** + +```typescript +import { q, search, persons } from '@codeforamerica/safety-net-'; + +// Build a type-safe query +const query = q( + search.eq('status', 'active'), + search.gte('monthlyIncome', 2000), + search.contains('name.lastName', 'smith'), + search.not('countyName', 'Denver') +); +// Result: "status:active monthlyIncome:>=2000 name.lastName:*smith* -countyName:Denver" + +const response = await persons.listPersons({ + query: { q: query, limit: 25 }, + client +}); ``` -### With React Query +### Building Queries Manually + +You can also build query strings directly: + +```typescript +// Multiple conditions are ANDed together +const query = 'status:active monthlyIncome:>=1000 -county:Denver'; + +const response = await listPersons({ + query: { q: query, limit: 25 } +}); +``` + +### Real-World Examples + +```typescript +import { q, search } from '@codeforamerica/safety-net-'; + +// Find active persons in a specific county with income above threshold +const eligiblePersons = q( + search.eq('status', 'active'), + search.eq('countyName', 'Denver'), + search.gte('monthlyIncome', 2000), + search.exists('email') +); + +// Find applications submitted this year, excluding denied +const recentApplications = q( + search.gte('submittedAt', '2024-01-01'), + search.not('status', 'denied') +); + +// Search for persons by partial name match +const nameSearch = q( + search.contains('name.lastName', 'smith') +); +``` + +## With React Query For better caching and state management: ```typescript // src/hooks/usePersons.ts import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { personsClient } from '../api/config'; +import { listPersons, getPerson, createPerson, updatePerson, deletePerson } from '../api/client'; +import type { Person } from '../api/client'; -export function usePersons(options?: { limit?: number; offset?: number }) { +export function usePersons(options?: { limit?: number; offset?: number; q?: string }) { return useQuery({ queryKey: ['persons', options], - queryFn: () => personsClient.listPersons({ queries: options }), + queryFn: async () => { + const response = await listPersons({ query: options }); + if ('data' in response && response.data) { + return response.data; + } + throw new Error('Failed to fetch persons'); + }, }); } export function usePerson(personId: string) { return useQuery({ queryKey: ['persons', personId], - queryFn: () => personsClient.getPerson({ params: { personId } }), + queryFn: async () => { + const response = await getPerson({ path: { personId } }); + if ('data' in response && response.data) { + return response.data; + } + throw new Error('Failed to fetch person'); + }, enabled: !!personId, }); } @@ -135,111 +336,118 @@ export function useCreatePerson() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (data: Parameters[0]) => - personsClient.createPerson(data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['persons'] }); + mutationFn: async (data: Partial) => { + const response = await createPerson({ body: data }); + if ('data' in response && response.data) { + return response.data; + } + throw new Error('Failed to create person'); }, - }); -} - -export function useUpdatePerson() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: ({ - personId, - data, - }: { - personId: string; - data: Parameters[0]; - }) => personsClient.updatePerson({ params: { personId }, ...data }), - onSuccess: (_, { personId }) => { - queryClient.invalidateQueries({ queryKey: ['persons', personId] }); + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['persons'] }); }, }); } ``` -### Keeping Clients Updated +Usage in components: -When the API spec changes, regenerate and copy the clients: - -```bash -# In safety-net-openapi toolkit -git pull -STATE=california npm run clients:generate +```typescript +// src/components/PersonList.tsx +import { usePersons, useDeletePerson } from '../hooks/usePersons'; -# Copy to your project -cp -r generated/clients/zodios/* ../your-frontend/src/api/ -``` +export function PersonList() { + const { data, isLoading, error } = usePersons({ + limit: 25, + q: 'status:active email:*' + }); -Consider adding a script to your front-end's `package.json`: + if (isLoading) return
Loading...
; + if (error) return
Error: {error.message}
; -```json -{ - "scripts": { - "api:update": "cd ../safety-net-openapi && git pull && STATE=california npm run clients:generate && cp -r generated/clients/zodios/* ../your-frontend/src/api/" - } + return ( +
    + {data?.items.map((person) => ( +
  • + {person.name?.firstName} {person.name?.lastName} +
  • + ))} +
+ ); } ``` ---- - -## Basic Usage +## With Redux Toolkit ```typescript -import { personsClient } from './generated/clients/zodios/persons'; - -// List with pagination and search -const persons = await personsClient.listPersons({ - queries: { limit: 10, offset: 0, q: 'status:active' } -}); +// src/store/slices/personSlice.ts +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import { getPerson, createPerson as createPersonApi, type Person } from '../../api/client'; + +export const fetchPerson = createAsyncThunk( + 'persons/fetchById', + async (id: string, { rejectWithValue }) => { + try { + const response = await getPerson({ path: { personId: id } }); + if ('data' in response && response.data) { + return response.data; + } + return rejectWithValue('Failed to fetch person'); + } catch (err) { + return rejectWithValue(err instanceof Error ? err.message : 'Unknown error'); + } + } +); + +export const createPerson = createAsyncThunk( + 'persons/create', + async (payload: Partial, { rejectWithValue }) => { + try { + const response = await createPersonApi({ body: payload }); + if ('data' in response && response.data) { + return response.data; + } + return rejectWithValue('Failed to create person'); + } catch (err) { + return rejectWithValue(err instanceof Error ? err.message : 'Unknown error'); + } + } +); +``` -// Get by ID -const person = await personsClient.getPerson({ - params: { personId: '123e4567-e89b-12d3-a456-426614174000' } -}); +## State-Specific Fields -// Create -const newPerson = await personsClient.createPerson({ - firstName: 'Jane', - lastName: 'Doe', - email: 'jane@example.com' -}); +Each state package includes state-specific schema fields defined by that state's overlay. These may include: -// Update -const updated = await personsClient.updatePerson({ - params: { personId: '...' }, - body: { monthlyIncome: 7500 } -}); +- State-specific county enums and codes +- State benefit program identifiers +- Eligibility flags for state programs +- State-specific income source types -// Delete -await personsClient.deletePerson({ params: { personId: '...' } }); -``` +Check your state's overlay file (`packages/schemas/openapi/overlays//modifications.yaml`) to see what customizations are applied. -## What's Generated +## Updating the Package -- Full TypeScript types from OpenAPI schemas -- Zod schemas for runtime validation -- Type-safe function parameters and return values -- All endpoints with proper HTTP methods +When a new version is released: -## Requirements +```bash +npm update @codeforamerica/safety-net- +``` -Your OpenAPI spec needs: -- `operationId` on each endpoint (used for function names) -- Schemas for request/response bodies -- Parameters defined (path, query) +Check the changelog for breaking changes to schema fields or API endpoints. ## Troubleshooting -**Generation fails:** -```bash -npm run validate # Check for spec errors -``` +**401 Unauthorized during install:** +- Ensure `GITHUB_TOKEN` is set with `read:packages` scope +- Check `.npmrc` configuration -**Type errors:** Regenerate clients after spec changes. +**Type errors after update:** +- Schema fields may have changed +- Check for renamed or removed fields +- Run TypeScript compilation to find issues -**Runtime validation errors:** Zod validates responses against the schema. If your backend returns unexpected data, you'll get a Zod error. Check that your backend matches the spec. +**Runtime validation errors:** +- The SDK validates responses automatically via Zod +- Ensure your API returns data matching the expected schema +- Check for missing required fields or incorrect types diff --git a/docs/integration/ci-cd-backend.md b/docs/integration/ci-cd-backend.md index b8de5df..4e5cc5a 100644 --- a/docs/integration/ci-cd-backend.md +++ b/docs/integration/ci-cd-backend.md @@ -98,7 +98,7 @@ jobs: working-directory: openapi-toolkit run: | npm install - STATE=california npm run postman:generate + STATE= npm run postman:generate - name: Run contract tests run: | @@ -129,7 +129,7 @@ If you prefer not to clone the toolkit in CI, generate the collection locally an ```bash # In the safety-net-openapi toolkit -STATE=california npm run postman:generate +STATE= npm run postman:generate # Copy to your backend repo cp generated/postman-collection.json ../your-backend/tests/contract/ @@ -171,7 +171,7 @@ Check for spec changes and regenerate only when needed: run: | cd safety-net-openapi npm install - STATE=california npm run postman:generate + STATE= npm run postman:generate cp generated/postman-collection.json ../tests/contract/ echo "${{ steps.spec-check.outputs.new }}" > ../tests/contract/.spec-hash ``` diff --git a/docs/integration/ci-cd-frontend.md b/docs/integration/ci-cd-frontend.md index 9f81dbf..6b39932 100644 --- a/docs/integration/ci-cd-frontend.md +++ b/docs/integration/ci-cd-frontend.md @@ -38,8 +38,8 @@ jobs: working-directory: openapi-toolkit run: | npm install - STATE=california npm run mock:setup - STATE=california npm run mock:start & + STATE= npm run mock:setup + STATE= npm run mock:start & # Wait for server to be ready sleep 5 @@ -70,7 +70,7 @@ test: services: - name: node:18 alias: mock-server - command: ["sh", "-c", "git clone https://github.com/codeforamerica/safety-net-openapi.git && cd safety-net-openapi && npm install && STATE=california npm run mock:start"] + command: ["sh", "-c", "git clone https://github.com/codeforamerica/safety-net-openapi.git && cd safety-net-openapi && npm install && STATE= npm run mock:start"] script: - npm install @@ -107,7 +107,7 @@ Add to your frontend's `package.json`: ```json { "scripts": { - "api:update": "cd ../safety-net-openapi && git pull && STATE=california npm run clients:generate && cp -r generated/clients/zodios/* ../your-frontend/src/api/" + "api:update": "cd ../safety-net-openapi && git pull && STATE= npm run clients:generate && cp -r generated/clients/* ../your-frontend/src/api/" } } ``` @@ -146,7 +146,7 @@ jobs: - name: Generate clients run: | cd safety-net-openapi - STATE=california npm run clients:generate + STATE= npm run clients:generate - name: Check for changes id: diff @@ -245,7 +245,7 @@ describe('Persons', () => { export default defineConfig({ webServer: [ { - command: 'cd ../safety-net-openapi && STATE=california npm run mock:start', + command: 'cd ../safety-net-openapi && STATE= npm run mock:start', port: 1080, reuseExistingServer: !process.env.CI, }, @@ -297,7 +297,7 @@ Speed up CI by caching the toolkit: - name: Start mock server with logs working-directory: openapi-toolkit run: | - STATE=california npm run mock:start 2>&1 | tee mock-server.log & + STATE= npm run mock:start 2>&1 | tee mock-server.log & sleep 5 - name: Upload mock server logs diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 435d2ba..87a1710 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -10,9 +10,6 @@ All available npm scripts in the Safety Net OpenAPI toolkit. | `npm run validate` | Validate base specs | | `npm run validate:state` | Validate specs for current STATE | | `npm run validate:all-states` | Validate all states | -| `npm run clients:generate` | Generate Zodios TypeScript clients | -| `npm run clients:validate` | Type-check generated clients | -| `npm run postman:generate` | Generate Postman collection | | `npm run mock:start` | Start mock server only | | `npm run mock:reset` | Reset database to example data | | `npm test` | Run unit tests | @@ -69,9 +66,9 @@ npm run validate:patterns Resolves the overlay for the current STATE and validates the resolved specs. ```bash -STATE=california npm run validate:state +STATE= npm run validate:state # or -npm run validate:state -- --state=colorado +npm run validate:state -- --state= ``` ### `npm run validate:all-states` @@ -91,12 +88,12 @@ Resolves the overlay for the current STATE, writing to `openapi/resolved/`. Without STATE set, lists available states: ```bash npm run overlay:resolve -# Output: Available states: california, colorado +# Output: Available states: ``` With STATE set: ```bash -STATE=california npm run overlay:resolve +STATE= npm run overlay:resolve ``` ## Generation Commands @@ -114,35 +111,25 @@ Creates: - `openapi/components/benefit.yaml` - `openapi/examples/benefits.yaml` -### `npm run clients:generate` +### Building State Packages -Generates TypeScript/Zodios clients from specs. +Build a state-specific npm package with TypeScript SDK and Zod schemas: ```bash -npm run clients:generate +node packages/clients/scripts/build-state-package.js --state= --version=1.0.0 ``` -Output: `packages/clients/generated/clients/zodios/*.ts` +This generates a complete npm package in `packages/clients/dist-packages/{state}/` containing: +- Typed SDK functions (`getPerson`, `createPerson`, etc.) +- TypeScript interfaces +- Zod schemas for runtime validation +- Axios-based HTTP client -### `npm run clients:validate` - -Type-checks the generated Zodios clients using TypeScript. - -```bash -npm run clients:validate -``` - -Runs `tsc --noEmit` to verify all generated clients compile without errors. - -### `npm run postman:generate` - -Generates a Postman collection from specs. - -```bash -npm run postman:generate -``` - -Output: `packages/clients/generated/postman-collection.json` +The package is built using `@hey-api/openapi-ts` with the following plugins: +- `@hey-api/typescript` - TypeScript types +- `@hey-api/sdk` - SDK functions with validation +- `@hey-api/zod` - Zod schemas +- `@hey-api/client-axios` - Axios HTTP client ## Server Commands @@ -151,7 +138,7 @@ Output: `packages/clients/generated/postman-collection.json` Starts both the mock server and Swagger UI. ```bash -STATE=california npm start +STATE= npm start ``` - Mock server: http://localhost:1080 @@ -162,7 +149,7 @@ STATE=california npm start Starts only the mock server. ```bash -STATE=california npm run mock:start +STATE= npm run mock:start ``` Default: http://localhost:1080 @@ -256,8 +243,8 @@ npm run validate && npm run validate:all-states # Reset and start npm run mock:reset && npm start -# Generate and validate all artifacts -npm run clients:generate && npm run clients:validate && npm run postman:generate +# Build state package (resolve overlay + generate + compile) +STATE= npm run overlay:resolve && node packages/clients/scripts/build-state-package.js --state= --version=1.0.0 # Full test suite npm run validate && npm test && npm run test:integration diff --git a/docs/reference/project-structure.md b/docs/reference/project-structure.md index 1f8a81c..d36db8e 100644 --- a/docs/reference/project-structure.md +++ b/docs/reference/project-structure.md @@ -25,9 +25,7 @@ safety-net-openapi/ │ │ │ ├── patterns/ # API design patterns │ │ │ │ └── api-patterns.yaml │ │ │ ├── overlays/ # State-specific variations -│ │ │ │ ├── california/ -│ │ │ │ │ └── modifications.yaml -│ │ │ │ └── colorado/ +│ │ │ │ └── / │ │ │ │ └── modifications.yaml │ │ │ └── resolved/ # Generated state specs (gitignored) │ │ ├── src/ @@ -51,14 +49,15 @@ safety-net-openapi/ │ │ ├── unit/ │ │ └── integration/ │ │ -│ └── clients/ # Generated API clients +│ └── clients/ # API client generation │ ├── package.json -│ ├── scripts/ # Generator scripts -│ │ ├── generate-zodios.js -│ │ └── generate-postman.js -│ └── generated/ # Output directory -│ ├── zodios/ # TypeScript clients -│ └── postman-collection.json +│ ├── scripts/ +│ │ └── build-state-package.js # Main build script +│ ├── templates/ +│ │ ├── package.template.json # npm package template +│ │ └── search-helpers.ts # Query builder utilities (q, search) +│ └── dist-packages/ # Output directory (gitignored) +│ └── {state}/ # State-specific packages │ └── docs/ # Documentation ├── getting-started/ # Persona-based onboarding @@ -74,7 +73,7 @@ safety-net-openapi/ |---------|---------|------------------| | `@safety-net/schemas` | OpenAPI specs, validation, overlay resolution | `js-yaml`, `ajv` | | `@safety-net/mock-server` | Mock API server for development | `express`, `better-sqlite3` | -| `@safety-net/clients` | Generate TypeScript clients, Postman collections | `openapi-zod-client` | +| `@safety-net/clients` | Generate TypeScript SDK packages | `@hey-api/openapi-ts`, `zod` | ### CI/CD Usage @@ -97,7 +96,7 @@ npm install -w @safety-net/schemas -w @safety-net/mock-server | API specs | kebab-case | `case-workers.yaml` | | Component schemas | kebab-case | `case-worker.yaml` | | Example files | kebab-case | `case-workers.yaml` | -| Overlay files | `{state}/modifications.yaml` | `california/modifications.yaml` | +| Overlay files | `{state}/modifications.yaml` | `/modifications.yaml` | | Scripts | kebab-case | `generate-clients.js` | | Tests | kebab-case + `.test` | `overlay-resolver.test.js` | @@ -136,9 +135,8 @@ npm install -w @safety-net/schemas -w @safety-net/mock-server | File | Purpose | Regenerate | |------|---------|------------| | `packages/schemas/openapi/resolved/*.yaml` | State-resolved specs | `npm run overlay:resolve` | -| `packages/clients/generated/zodios/*.ts` | TypeScript clients | `npm run clients:generate` | -| `packages/clients/generated/postman-collection.json` | Postman collection | `npm run postman:generate` | -| `generated/mock-data/*.db` | SQLite databases | `npm run mock:reset` | +| `packages/clients/dist-packages/{state}/` | State npm packages | `node packages/clients/scripts/build-state-package.js` | +| `packages/mock-server/data/*.db` | SQLite databases | `npm run mock:reset` | ## Adding New Resources diff --git a/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md index 5892632..7106266 100644 --- a/docs/reference/troubleshooting.md +++ b/docs/reference/troubleshooting.md @@ -114,12 +114,12 @@ Error: status must be equal to one of the allowed values ``` Error: Unknown state 'newstate' -Available states: california, colorado +Available states: ``` **Cause:** No overlay file exists for the specified state. -**Solution:** Create `openapi/overlays/newstate.overlay.yaml`. +**Solution:** Create `openapi/overlays//modifications.yaml`. ## Mock Server Issues @@ -195,7 +195,7 @@ npm install **Solution:** Regenerate clients. ```bash -STATE=california npm run clients:generate +STATE= npm run clients:generate ``` ### Zod Validation Error at Runtime @@ -219,7 +219,7 @@ Could not find collection file **Solution:** Generate the collection first. ```bash -STATE=california npm run postman:generate +STATE= npm run postman:generate ``` ### Tests Fail with 404 @@ -264,7 +264,7 @@ Error: connect ECONNREFUSED 127.0.0.1:1080 ```yaml env: - STATE: california + STATE: ``` ### Permission Denied diff --git a/package.json b/package.json index 57dcf6c..8f7354e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "safety-net-openapi", "version": "1.0.0", "private": true, - "description": "Generate Mock API server, Swagger UI, and Zodios API clients from OpenApi specifications", + "description": "Generate Mock API server, Swagger UI, and typed API clients from OpenAPI specifications", "type": "module", "engines": { "node": ">=18.0.0" @@ -20,10 +20,11 @@ "api:new": "npm run api:new -w @safety-net/schemas", "overlay:resolve": "npm run overlay:resolve -w @safety-net/schemas", "overlay:list": "npm run overlay:list -w @safety-net/schemas", - "clients:generate": "npm run generate -w @safety-net/clients", - "clients:validate": "npm run validate -w @safety-net/clients", + "clients:build-package": "node packages/clients/scripts/build-state-package.js", "postman:generate": "npm run postman -w @safety-net/clients", "mock:start": "npm start -w @safety-net/mock-server", + "mock:start:all": "npm run start:all -w @safety-net/mock-server", + "mock:swagger": "npm run swagger -w @safety-net/mock-server", "mock:setup": "npm run setup -w @safety-net/mock-server", "mock:reset": "npm run reset -w @safety-net/mock-server", "test": "npm test --workspaces --if-present", diff --git a/packages/clients/generated/.gitkeep b/packages/clients/generated/.gitkeep deleted file mode 100644 index cd87379..0000000 --- a/packages/clients/generated/.gitkeep +++ /dev/null @@ -1,9 +0,0 @@ -# This file ensures the /generated folder exists in git. -# All generated artifacts are gitignored since they are state-specific. -# -# Contents (regenerate as needed): -# clients/zodios/*.ts - TypeScript API clients (npm run clients:generate) -# postman-collection.json - Postman collection (npm run postman:generate) -# mock-data/*.db - SQLite databases (npm run mock:reset) - - diff --git a/packages/clients/package.json b/packages/clients/package.json index e57d29b..52af5ab 100644 --- a/packages/clients/package.json +++ b/packages/clients/package.json @@ -4,19 +4,16 @@ "description": "Generated API clients and collections for Safety Net APIs", "type": "module", "scripts": { - "generate": "node scripts/generate-zodios.js", "postman": "node scripts/generate-postman.js", - "validate": "tsc --noEmit -p tsconfig.validate.json" + "build-package": "node scripts/build-state-package.js" }, "dependencies": { "@apidevtools/json-schema-ref-parser": "^11.7.2", "@safety-net/schemas": "*", - "@zodios/core": "^10.9.6", - "js-yaml": "^4.1.0", - "zod": "^3.23.8" + "js-yaml": "^4.1.0" }, "devDependencies": { - "openapi-zod-client": "^1.18.2", + "@hey-api/openapi-ts": "^0.90.3", "typescript": "^5.3.3" } } diff --git a/packages/clients/scripts/build-state-package.js b/packages/clients/scripts/build-state-package.js new file mode 100644 index 0000000..ef43c81 --- /dev/null +++ b/packages/clients/scripts/build-state-package.js @@ -0,0 +1,264 @@ +#!/usr/bin/env node +/** + * Build a state-specific npm package for publishing to GitHub Packages. + * + * Usage: + * node scripts/build-state-package.js --state=california --version=1.0.0 + * + * This script: + * 1. Resolves the state overlay + * 2. Bundles resolved specs into a single OpenAPI file + * 3. Generates typed API client using @hey-api/openapi-ts + * 4. Creates package directory with package.json + * 5. Compiles TypeScript to JavaScript + * 6. Outputs ready-to-publish package in dist-packages/{state}/ + */ + +import { spawn } from 'child_process'; +import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync, readdirSync, copyFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const clientsRoot = join(__dirname, '..'); +const repoRoot = join(clientsRoot, '..', '..'); + +/** + * Parse command line arguments + */ +function parseArgs() { + const args = { state: null, version: null }; + + for (const arg of process.argv.slice(2)) { + if (arg.startsWith('--state=')) { + args.state = arg.split('=')[1]; + } else if (arg.startsWith('--version=')) { + args.version = arg.split('=')[1]; + } + } + + if (!args.state) { + console.error('Error: --state is required'); + console.error('Usage: node scripts/build-state-package.js --state=california --version=1.0.0'); + process.exit(1); + } + + if (!args.version) { + console.error('Error: --version is required'); + console.error('Usage: node scripts/build-state-package.js --state=california --version=1.0.0'); + process.exit(1); + } + + return args; +} + +/** + * Execute a command and return a promise + */ +function exec(command, args, options = {}) { + return new Promise((resolve, reject) => { + console.log(` Running: ${command} ${args.join(' ')}`); + const child = spawn(command, args, { + stdio: 'inherit', + shell: true, + cwd: options.cwd || repoRoot, + ...options + }); + + child.on('close', (code) => { + if (code !== 0) { + reject(new Error(`Command failed with exit code ${code}`)); + } else { + resolve(); + } + }); + + child.on('error', reject); + }); +} + +/** + * Title case a state name + */ +function titleCase(str) { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +/** + * Create openapi-ts config file + */ +function createOpenApiTsConfig(inputPath, outputPath) { + const config = `// Auto-generated openapi-ts config +export default { + input: '${inputPath}', + output: '${outputPath}', + plugins: [ + '@hey-api/client-axios', + '@hey-api/typescript', + 'zod', + { + name: '@hey-api/sdk', + validator: true, + }, + ], +}; +`; + return config; +} + +/** + * Main build function + */ +async function main() { + const { state, version } = parseArgs(); + const stateTitle = titleCase(state); + const outputDir = join(clientsRoot, 'dist-packages', state); + const srcDir = join(outputDir, 'src'); + const templatesDir = join(clientsRoot, 'templates'); + const resolvedDir = join(repoRoot, 'packages', 'schemas', 'openapi', 'resolved'); + + console.log(`\nBuilding package for ${stateTitle}...`); + console.log(` State: ${state}`); + console.log(` Version: ${version}`); + console.log(` Output: ${outputDir}\n`); + + // Clean output directory + if (existsSync(outputDir)) { + console.log('Cleaning previous build...'); + rmSync(outputDir, { recursive: true }); + } + mkdirSync(srcDir, { recursive: true }); + + // Step 1: Resolve overlay for this state + console.log('\n1. Resolving state overlay...'); + await exec('npm', ['run', 'overlay:resolve', '-w', '@safety-net/schemas', '--', `--state=${state}`]); + + // Step 2: Generate client for each domain spec + console.log('\n2. Generating domain clients...'); + const specFiles = readdirSync(resolvedDir).filter(f => f.endsWith('.yaml') && !f.startsWith('.')); + + if (specFiles.length === 0) { + throw new Error('No resolved spec files found'); + } + + console.log(` Found specs: ${specFiles.join(', ')}`); + const domains = []; + + for (const file of specFiles) { + const domain = file.replace('.yaml', ''); + domains.push(domain); + const specPath = join(resolvedDir, file); + const domainBundled = join(outputDir, `${domain}-bundled.yaml`); + const domainSrcDir = join(srcDir, domain); + const domainConfigPath = join(outputDir, `${domain}.config.js`); + + console.log(`\n Processing ${domain}...`); + + // Bundle spec (dereference $refs) + await exec('npx', [ + '@apidevtools/swagger-cli', 'bundle', + specPath, + '-o', domainBundled, + '--dereference' + ]); + + // Generate client for this domain + mkdirSync(domainSrcDir, { recursive: true }); + const configContent = createOpenApiTsConfig(domainBundled, domainSrcDir); + writeFileSync(domainConfigPath, configContent); + + await exec('npx', ['@hey-api/openapi-ts', '-f', domainConfigPath], { cwd: outputDir }); + + // Post-process: Remove unused @ts-expect-error directives + const clientGenPath = join(domainSrcDir, 'client', 'client.gen.ts'); + if (existsSync(clientGenPath)) { + let content = readFileSync(clientGenPath, 'utf8'); + content = content.replace(/^\s*\/\/\s*@ts-expect-error\s*$/gm, ''); + writeFileSync(clientGenPath, content); + } + + // Clean up temp files + rmSync(domainBundled, { force: true }); + rmSync(domainConfigPath, { force: true }); + + console.log(` Generated: ${domain}`); + } + + // Step 3: Create index.ts that re-exports all domains and search helpers + console.log('\n3. Creating index exports...'); + const domainExports = domains.map(d => `export * as ${d} from './${d}/index.js';`).join('\n'); + const indexContent = `${domainExports} +export { q, search } from './search-helpers.js'; +`; + writeFileSync(join(srcDir, 'index.ts'), indexContent); + console.log(' Created index.ts'); + + // Step 3b: Copy search helpers + const searchHelpersSource = join(templatesDir, 'search-helpers.ts'); + const searchHelpersDest = join(srcDir, 'search-helpers.ts'); + copyFileSync(searchHelpersSource, searchHelpersDest); + console.log(' Copied search-helpers.ts'); + + // Step 4: Generate package.json from template + console.log('\n4. Generating package.json...'); + const packageTemplate = readFileSync(join(templatesDir, 'package.template.json'), 'utf8'); + const packageJson = packageTemplate + .replace(/\{\{STATE\}\}/g, state) + .replace(/\{\{VERSION\}\}/g, version) + .replace(/\{\{STATE_TITLE\}\}/g, stateTitle); + writeFileSync(join(outputDir, 'package.json'), packageJson); + console.log(' Generated package.json'); + + // Step 5: Create tsconfig for compilation + console.log('\n5. Setting up TypeScript compilation...'); + const tsconfig = { + compilerOptions: { + target: 'ES2020', + module: 'ESNext', + moduleResolution: 'bundler', + declaration: true, + outDir: 'dist', + rootDir: 'src', + skipLibCheck: true, + esModuleInterop: true, + strict: false, + noEmitOnError: false + }, + include: ['src/**/*.ts'] + }; + writeFileSync(join(outputDir, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2)); + console.log(' Created tsconfig.json'); + + // Step 6: Install build dependencies (peer deps needed for type checking) + console.log('\n6. Installing build dependencies...'); + await exec('npm', ['install', 'zod@^4.3.5', 'axios@^1.6.0', '--save-dev'], { cwd: outputDir }); + console.log(' Dependencies installed'); + + // Step 7: Compile TypeScript + console.log('\n7. Compiling TypeScript...'); + try { + await exec('npx', ['tsc'], { cwd: outputDir }); + } catch (error) { + // Check if dist files were still generated despite type errors + if (existsSync(join(outputDir, 'dist', 'index.js'))) { + console.log(' Compilation complete (with type warnings in generated code)'); + } else { + throw error; + } + } + console.log(' Compilation complete'); + + // Summary + console.log('\n========================================'); + console.log(`Package built successfully!`); + console.log(` Name: @codeforamerica/safety-net-${state}`); + console.log(` Version: ${version}`); + console.log(` Location: ${outputDir}`); + console.log('========================================\n'); +} + +main().catch((error) => { + console.error('\nBuild failed:', error.message); + process.exit(1); +}); diff --git a/packages/clients/scripts/generate-zodios.js b/packages/clients/scripts/generate-zodios.js deleted file mode 100644 index 66f42de..0000000 --- a/packages/clients/scripts/generate-zodios.js +++ /dev/null @@ -1,180 +0,0 @@ -#!/usr/bin/env node -import { readdir, writeFile, unlink } from 'fs/promises'; -import { join, basename, extname } from 'path'; -import { spawn } from 'child_process'; -import { fileURLToPath } from 'url'; -import { dirname } from 'path'; -import $RefParser from '@apidevtools/json-schema-ref-parser'; -import { validateSpec } from '@safety-net/schemas/validation'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const workspaceRoot = join(__dirname, '..'); -const schemasRoot = join(__dirname, '..', '..', 'schemas'); - -/** - * Executes a command and returns a promise - */ -function executeCommand(command, args) { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { - stdio: 'inherit', - shell: true, - cwd: workspaceRoot - }); - - child.on('close', (code) => { - if (code !== 0) { - reject(new Error(`Command failed with exit code ${code}`)); - } else { - resolve(); - } - }); - - child.on('error', (error) => { - reject(error); - }); - }); -} - -/** - * Finds all OpenAPI specification files in the root of the openapi directory - * (excludes subdirectories) - */ -async function findOpenAPISpecs(directory) { - const specs = []; - const entries = await readdir(directory, { withFileTypes: true }); - - for (const entry of entries) { - if (entry.isFile()) { - const ext = extname(entry.name).toLowerCase(); - // Check for YAML or JSON OpenAPI specs - if (ext === '.yaml' || ext === '.yml' || ext === '.json') { - const fullPath = join(directory, entry.name); - specs.push(fullPath); - } - } - } - - return specs; -} - -/** - * Generates a Zodios client for a given OpenAPI spec - */ -async function generateClient(specPath) { - const specName = basename(specPath, extname(specPath)); - const outputPath = join(workspaceRoot, 'generated', 'clients', 'zodios', `${specName}.ts`); - const tempSpecPath = join(workspaceRoot, 'generated', 'clients', 'zodios', `.${specName}.dereferenced.json`); - - console.log(`\n📝 Generating client for: ${specName}`); - console.log(` Input: ${specPath}`); - console.log(` Output: ${outputPath}`); - - try { - // Validate spec first - console.log(` ✓ Validating specification...`); - const validation = await validateSpec(specPath); - - if (!validation.valid) { - console.error(` ❌ Specification validation failed:`); - for (const error of validation.errors) { - console.error(` - ${error.message}`); - } - throw new Error('Specification validation failed'); - } - - if (validation.warnings.length > 0) { - console.log(` ⚠️ ${validation.warnings.length} warning(s):`); - for (const warning of validation.warnings.slice(0, 3)) { - console.log(` - ${warning.message}`); - } - } - - // Dereference the spec to resolve all external $ref - console.log(` 🔗 Dereferencing external references...`); - const dereferencedSpec = await $RefParser.dereference(specPath, { - dereference: { - circular: 'ignore' - } - }); - - // Write dereferenced spec to temp file - await writeFile(tempSpecPath, JSON.stringify(dereferencedSpec, null, 2)); - - // Generate client from dereferenced spec - await executeCommand('npx', [ - 'openapi-zod-client', - tempSpecPath, - '-o', - outputPath - ]); - - console.log(`✅ Successfully generated client for: ${specName}`); - } catch (error) { - console.error(`❌ Failed to generate client for: ${specName}`); - console.error(error.message); - throw error; - } finally { - // Clean up temp file - try { - await unlink(tempSpecPath); - } catch (e) { - // Ignore cleanup errors - } - } -} - -/** - * Main function to generate all clients - */ -async function main() { - const openAPIDir = join(schemasRoot, 'openapi'); - - console.log('🚀 Starting Zodios API client generation...'); - console.log(`📂 Searching for OpenAPI specs in: ${openAPIDir}\n`); - - try { - const specs = await findOpenAPISpecs(openAPIDir); - - if (specs.length === 0) { - console.log('⚠️ No OpenAPI specifications found.'); - return; - } - - console.log(`📋 Found ${specs.length} specification(s):`); - specs.forEach(spec => console.log(` - ${spec}`)); - - const results = { success: [], failed: [] }; - - for (const spec of specs) { - try { - await generateClient(spec); - results.success.push(basename(spec)); - } catch (error) { - results.failed.push(basename(spec)); - // Continue with next spec instead of exiting - } - } - - console.log(`\n✨ Generation complete!`); - console.log(` ✅ Successful: ${results.success.length}/${specs.length}`); - if (results.success.length > 0) { - console.log(` ${results.success.join(', ')}`); - } - if (results.failed.length > 0) { - console.log(` ❌ Failed: ${results.failed.length}/${specs.length}`); - console.log(` ${results.failed.join(', ')}`); - } - console.log(`\n💡 Clients are available in: ${join(workspaceRoot, 'generated', 'clients', 'zodios')}`); - - if (results.failed.length > 0) { - process.exit(1); - } - } catch (error) { - console.error('\n❌ Generation failed:', error.message); - process.exit(1); - } -} - -main(); diff --git a/packages/clients/templates/package.template.json b/packages/clients/templates/package.template.json new file mode 100644 index 0000000..639116a --- /dev/null +++ b/packages/clients/templates/package.template.json @@ -0,0 +1,80 @@ +{ + "name": "@codeforamerica/safety-net-{{STATE}}", + "version": "{{VERSION}}", + "description": "Safety Net API client for {{STATE_TITLE}} with Zod validation", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": ["dist", "src"], + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./search": { + "import": "./dist/search-helpers.js", + "types": "./dist/search-helpers.d.ts" + }, + "./persons": { + "import": "./dist/persons/index.js", + "types": "./dist/persons/index.d.ts" + }, + "./persons/client": { + "import": "./dist/persons/client/index.js", + "types": "./dist/persons/client/index.d.ts" + }, + "./persons/*": { + "import": "./dist/persons/*.js", + "types": "./dist/persons/*.d.ts" + }, + "./applications": { + "import": "./dist/applications/index.js", + "types": "./dist/applications/index.d.ts" + }, + "./applications/client": { + "import": "./dist/applications/client/index.js", + "types": "./dist/applications/client/index.d.ts" + }, + "./applications/*": { + "import": "./dist/applications/*.js", + "types": "./dist/applications/*.d.ts" + }, + "./households": { + "import": "./dist/households/index.js", + "types": "./dist/households/index.d.ts" + }, + "./households/client": { + "import": "./dist/households/client/index.js", + "types": "./dist/households/client/index.d.ts" + }, + "./households/*": { + "import": "./dist/households/*.js", + "types": "./dist/households/*.d.ts" + }, + "./incomes": { + "import": "./dist/incomes/index.js", + "types": "./dist/incomes/index.d.ts" + }, + "./incomes/client": { + "import": "./dist/incomes/client/index.js", + "types": "./dist/incomes/client/index.d.ts" + }, + "./incomes/*": { + "import": "./dist/incomes/*.js", + "types": "./dist/incomes/*.d.ts" + } + }, + "peerDependencies": { + "zod": "^4.0.0", + "axios": "^1.6.0" + }, + "keywords": ["safety-net", "{{STATE}}", "zod", "openapi", "api-client"], + "repository": { + "type": "git", + "url": "https://github.com/codeforamerica/safety-net-openapi.git" + }, + "license": "MIT", + "publishConfig": { + "registry": "https://npm.pkg.github.com" + } +} diff --git a/packages/clients/templates/search-helpers.ts b/packages/clients/templates/search-helpers.ts new file mode 100644 index 0000000..2f1d488 --- /dev/null +++ b/packages/clients/templates/search-helpers.ts @@ -0,0 +1,264 @@ +/** + * Search Query Helpers for Safety Net API Clients + * + * This module provides utilities for building search queries using the + * field:value syntax supported by all list endpoints. + * + * @module search-helpers + * + * @example + * import { q, search } from '@codeforamerica/safety-net-colorado'; + * + * const query = q( + * search.eq("status", "approved"), + * search.gte("income", 1000), + * search.in("programs", ["snap", "cash_programs"]) + * ); + * // => "status:approved income:>=1000 programs:snap,cash_programs" + * + * const results = await personsClient.listPersons({ + * queries: { q: query, limit: 25 } + * }); + */ + +/** + * Combines multiple search conditions into a single query string. + * + * Multiple conditions are ANDed together (all must match). + * Use comma-separated values within a single condition for OR logic. + * + * ## Syntax Reference + * + * | Pattern | Description | Example | + * |---------|-------------|---------| + * | `term` | Full-text exact match | `john` | + * | `*term*` | Full-text contains | `*john*` | + * | `term*` | Full-text starts with | `john*` | + * | `*term` | Full-text ends with | `*smith` | + * | `field:value` | Exact match | `status:approved` | + * | `field:*value*` | Contains (case-insensitive) | `name:*john*` | + * | `field:value*` | Starts with | `name:john*` | + * | `field:*value` | Ends with | `email:*@example.com` | + * | `field:"value"` | Quoted value (for spaces) | `name:"john doe"` | + * | `field.nested:value` | Nested field (dot notation) | `address.state:CA` | + * | `field:>value` | Greater than | `income:>1000` | + * | `field:>=value` | Greater than or equal | `income:>=1000` | + * | `field: "john" + * + * @example + * // Exact match on a field + * q("status:approved") + * // => "status:approved" + * + * @example + * // Multiple conditions (AND) + * q("status:approved", "income:>=1000") + * // => "status:approved income:>=1000" + * + * @example + * // Using the search builder + * q(search.eq("status", "approved"), search.gte("income", 1000)) + * // => "status:approved income:>=1000" + * + * @param conditions - One or more search conditions to combine + * @returns A search query string to pass to the `q` parameter + */ +export function q(...conditions: string[]): string { + return conditions.filter(Boolean).join(" "); +} + +/** + * Search query builder with type-safe methods for each operator. + * + * Use these methods to construct search conditions without memorizing + * the query syntax. All methods return strings that can be passed to `q()`. + * + * @example + * import { q, search } from '@codeforamerica/safety-net-colorado'; + * + * // Build a complex query + * const query = q( + * search.eq("status", "approved"), + * search.gte("income", 1000), + * search.in("programs", ["snap", "medical_assistance"]), + * search.not("state", "TX") + * ); + * + * // Use with Zodios client + * const results = await personsClient.listPersons({ + * queries: { q: query, limit: 25 } + * }); + */ +export const search = { + /** + * Exact match: `field:value` + * + * @example + * search.eq("status", "approved") + * // => "status:approved" + * + * @example + * // Nested field + * search.eq("address.state", "CA") + * // => "address.state:CA" + */ + eq: (field: string, value: string | number | boolean): string => + `${field}:${value}`, + + /** + * Greater than: `field:>value` + * + * @example + * search.gt("income", 1000) + * // => "income:>1000" + */ + gt: (field: string, value: string | number): string => `${field}:>${value}`, + + /** + * Greater than or equal: `field:>=value` + * + * @example + * search.gte("income", 1000) + * // => "income:>=1000" + */ + gte: (field: string, value: string | number): string => `${field}:>=${value}`, + + /** + * Less than: `field: "age:<65" + */ + lt: (field: string, value: string | number): string => `${field}:<${value}`, + + /** + * Less than or equal: `field:<=value` + * + * @example + * search.lte("income", 5000) + * // => "income:<=5000" + */ + lte: (field: string, value: string | number): string => `${field}:<=${value}`, + + /** + * Match any of the values (OR): `field:val1,val2,val3` + * + * @example + * search.in("status", ["approved", "pending", "under_review"]) + * // => "status:approved,pending,under_review" + */ + in: (field: string, values: (string | number)[]): string => + `${field}:${values.join(",")}`, + + /** + * Exclude / negate: `-field:value` + * + * @example + * search.not("status", "denied") + * // => "-status:denied" + */ + not: (field: string, value: string | number): string => `-${field}:${value}`, + + /** + * Field exists (is not null): `field:*` + * + * @example + * search.exists("email") + * // => "email:*" + */ + exists: (field: string): string => `${field}:*`, + + /** + * Field does not exist (is null): `-field:*` + * + * @example + * search.notExists("deletedAt") + * // => "-deletedAt:*" + */ + notExists: (field: string): string => `-${field}:*`, + + /** + * Contains (case-insensitive): `field:*value*` + * + * @example + * search.contains("name", "john") + * // => "name:*john*" + */ + contains: (field: string, value: string): string => `${field}:*${value}*`, + + /** + * Starts with (case-insensitive): `field:value*` + * + * @example + * search.startsWith("name", "john") + * // => "name:john*" + */ + startsWith: (field: string, value: string): string => `${field}:${value}*`, + + /** + * Ends with (case-insensitive): `field:*value` + * + * @example + * search.endsWith("email", "@example.com") + * // => "email:*@example.com" + */ + endsWith: (field: string, value: string): string => `${field}:*${value}`, + + /** + * Quoted value (for values containing spaces): `field:"value with spaces"` + * + * @example + * search.quoted("name", "john doe") + * // => 'name:"john doe"' + */ + quoted: (field: string, value: string): string => `${field}:"${value}"`, + + /** + * Full-text exact match (no field specified) + * + * Searches across all searchable fields for an exact match of the term. + * + * @example + * search.text("john") + * // => "john" + */ + text: (term: string): string => term, + + /** + * Full-text contains search (no field specified) + * + * @example + * search.textContains("john") + * // => "*john*" + */ + textContains: (term: string): string => `*${term}*`, + + /** + * Full-text starts with search (no field specified) + * + * @example + * search.textStartsWith("john") + * // => "john*" + */ + textStartsWith: (term: string): string => `${term}*`, + + /** + * Full-text ends with search (no field specified) + * + * @example + * search.textEndsWith("smith") + * // => "*smith" + */ + textEndsWith: (term: string): string => `*${term}`, +}; diff --git a/packages/mock-server/package.json b/packages/mock-server/package.json index 2f65a21..a143be7 100644 --- a/packages/mock-server/package.json +++ b/packages/mock-server/package.json @@ -5,6 +5,8 @@ "type": "module", "scripts": { "start": "node scripts/server.js", + "start:all": "node scripts/start-all.js", + "swagger": "node scripts/swagger/server.js", "setup": "node scripts/setup.js", "reset": "node scripts/reset.js", "test": "node tests/run-all-tests.js --unit",