diff --git a/.claude/skills/grid-api/SKILL.md b/.claude/skills/grid-api/SKILL.md index 47c2dd3f..463cf94b 100644 --- a/.claude/skills/grid-api/SKILL.md +++ b/.claude/skills/grid-api/SKILL.md @@ -434,7 +434,7 @@ Use this flow when the user asks for a "realtime quote" or "just in time" funded ## Error Handling -API responses follow this structure on success: +Single-resource responses: ```json { @@ -444,6 +444,19 @@ API responses follow this structure on success: } ``` +List responses return results in a `data` array with pagination fields: + +```json +{ + "data": [ ... ], + "hasMore": true, + "nextCursor": "...", + "totalCount": 42 +} +``` + +Use `jq '.data[]'` to iterate results or `jq '[.data[] | select(.currency == "NGN")]'` to filter. + On error: ```json diff --git a/.gitignore b/.gitignore index 23810883..ef5b47fa 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,9 @@ local.properties # CLI build output cli/dist/ +# Frontend build output +samples/frontend/dist/ + # Figma design tokens (local reference only) mintlify/tokens/ @@ -48,3 +51,6 @@ figma-*.md # Personal todo files TODO-*.md + +# Icon build script (local tool, requires license key) +scripts/export-icons.js diff --git a/CLAUDE.md b/CLAUDE.md index 50456e43..a7ae0235 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,237 +1,59 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Documentation-only repo (no application code): OpenAPI spec (YAML) + Mintlify docs (MDX). +Grid is an API for global payments across fiat, stablecoins, and Bitcoin. -## Project Overview +## Critical Rules -This is the **Grid API** documentation repository. Grid is an API that enables modern financial institutions to send and receive global payments across fiat, stablecoins, and Bitcoin. The repository contains: +- **Edit OpenAPI in `openapi/`** — never edit the root `openapi.yaml` directly (it's generated by bundling) +- Run `make build` after any OpenAPI changes to rebundle +- Run `make lint` before committing +- **Mintlify CLI must be version 4.2.284** — newer versions (e.g., 4.2.312) have a bug where API reference pages render blank. Install with `npm install -g mintlify@4.2.284 --force` +- **Requires Node.js v20 or v22** — Mintlify does not support Node 25+. If needed: `export PATH="/opt/homebrew/opt/node@22/bin:$PATH"` +- Use snippets from `mintlify/snippets/` instead of duplicating content across use cases +- Follow writing standards in `mintlify/CLAUDE.md` for all documentation content +- MDX files require frontmatter with `title` and `description` +- Do not use `React.useEffect` in MDX — it breaks Mintlify's acorn parser. `React.useState` is fine. -1. **OpenAPI specification** (split into modular YAML files) -2. **Mintlify documentation** (MDX files with guides, tutorials, and API reference) - -This is a documentation-only repository - there is no application code. - -## Common Commands - -### Building and Linting - -```bash -# Install dependencies -npm install -# or -make install - -# Build OpenAPI schema (bundles split files into single openapi.yaml) -npm run build:openapi -# or -make build - -# Lint OpenAPI schema and markdown files -npm run lint -# or -make lint - -# Lint only OpenAPI -npm run lint:openapi -# or -make lint-openapi - -# Lint only markdown -npm run lint:markdown -# or -make lint-markdown -``` - -### Documentation Development +## Commands ```bash -# Serve Mintlify documentation locally (requires mint CLI installed globally) -cd mintlify && mint dev -# or -make mint +make install # Install dependencies +make build # Bundle OpenAPI spec (openapi/ → openapi.yaml + mintlify/openapi.yaml) +make lint # Lint OpenAPI + markdown + run mint openapi-check +make lint-openapi # Lint OpenAPI only +make lint-markdown # Lint markdown only +make mint # Serve docs locally (cd mintlify && mint dev) ``` -## Architecture - -### OpenAPI Schema Structure - -The OpenAPI specification uses a **split-file architecture** managed by Redocly: - -- **Source files**: `openapi/` directory contains modular YAML files - - `openapi/openapi.yaml` - Root specification with references - - `openapi/paths/` - Endpoint definitions organized by domain - - `openapi/components/schemas/` - Reusable schema definitions - - `openapi/webhooks/` - Webhook event definitions - -- **Built file**: `openapi.yaml` - Bundled specification at repository root (also copied to `mintlify/openapi.yaml`) - -**Important**: When editing OpenAPI specs, edit files in `openapi/` directory, then run `npm run build:openapi` to bundle. The root `openapi.yaml` is generated and should not be edited directly. - -### Domain Organization - -The API is organized into four main use cases, reflected in both the OpenAPI paths and Mintlify docs: - -1. **Payouts** - Send value instantly across currencies and borders - - Customer management (`/customers`) - - Internal accounts (`/customers/internal-accounts`, `/platform/internal-accounts`) - - External accounts (`/customers/external-accounts`, `/platform/external-accounts`) - - Quotes and transactions - -2. **Ramps** - Convert between crypto and fiat - - Customer onboarding with KYC - - Plaid integration for bank account linking - - Fiat-to-crypto and crypto-to-fiat conversion flows - - Self-custody wallet support - -3. **Rewards & Cashback** - Deliver micro-payouts at scale - - Similar structure to Payouts - - Optimized for high-volume, low-value transactions - -4. **Global P2P (Remittances)** - Accept funds via bank transfers, wallets, or UMAs - - User management with UMA addresses (`/users` endpoints in actual API) - - UMA address resolution (`/receiver/uma/{receiverUmaAddress}`) - - Payment approval/rejection flows - - Invitations system (`/invitations`) - -### Key Concepts - -- **Customers**: End users of the platform (used in Payouts, Ramps, Rewards) -- **Users**: Distinction unclear from docs, but appears related to UMA-based flows -- **Internal Accounts**: Platform-managed accounts for holding funds -- **External Accounts**: Bank accounts connected for deposits/withdrawals -- **Quotes**: Time-limited exchange rate locks for cross-currency transactions -- **Transactions**: Payment records (incoming/outgoing) -- **UMA Addresses**: Universal Money Addresses (e.g., `$alice@example.com`) for P2P payments - -### Mintlify Documentation Structure - -- `mintlify/docs.json` - Navigation configuration with tabs for each use case -- `mintlify/index.mdx` - Landing page -- `mintlify/{use-case}/` - Use case-specific documentation - - `index.mdx` - Use case overview - - `quickstart.mdx` - Quick start guide - - `onboarding/` or `developer-guides/` - Implementation guides - - `accounts/`, `payment-flow/`, etc. - Topic-specific guides -- `mintlify/snippets/` - Reusable MDX snippets (imported into multiple docs) -- `mintlify/api-reference/` - API authentication and environment docs -- `mintlify/developer-resources/` - SDKs, tools, Postman collections -- `mintlify/changelog.mdx` - API changelog - -### Shared Documentation Patterns - -The repository uses **MDX snippets** to avoid duplication across use cases. Common snippets in `mintlify/snippets/`: - -- `platform-config-currency-api-webhooks.mdx` - Platform configuration -- `internal-accounts.mdx` - Internal account management -- `external-accounts.mdx` - External account management -- `webhooks.mdx` - Webhook setup and verification -- `kyc-onboarding.mdx` - KYC onboarding process -- `plaid-integration.mdx` - Plaid integration -- `terminology.mdx` - Terminology definitions - -Import these snippets rather than duplicating content. - -## Authentication - -The API uses HTTP Basic Authentication with format `:` (Base64 encoded). All endpoints require authentication except the webhook endpoints (which use signature verification instead). +## File Structure -Webhooks use **P-256 ECDSA signatures** in the `X-Grid-Signature` header for verification. - -## Important Notes - -### OpenAPI Development - -- Use Redocly for bundling and linting: `@redocly/cli` -- Configuration in `.redocly.yaml` -- Lint rules include operation descriptions, operation IDs, security definitions -- Always run `npm run lint:openapi` before committing OpenAPI changes - -### Mintlify Development - -- MDX files must include frontmatter with `title` and `description` -- Follow the writing standards in `mintlify/CLAUDE.md` -- Use second-person voice ("you") -- Test all code examples -- Use relative paths for internal links -- The mintlify subdirectory has its own CLAUDE.md with additional guidance - -### Mintlify CLI Version (Important) - -**Use Mintlify CLI version 4.2.284** for local development. Newer versions (e.g., 4.2.312) have a bug where the API reference pages render blank when using the palm theme with OpenAPI auto-generation. - -**Requires Node.js LTS (v20 or v22)** - Mintlify does not support Node 25+. If you have a newer Node version installed, use Node 22 LTS: - -```bash -# Install Node 22 via Homebrew (if needed) -brew install node@22 - -# Run mint dev with Node 22 -export PATH="/opt/homebrew/opt/node@22/bin:$PATH" -cd mintlify && mint dev - -# Or add to ~/.zshrc to make permanent: -# export PATH="/opt/homebrew/opt/node@22/bin:$PATH" ``` - -```bash -# Check current version -mintlify --version - -# If needed, install the working version -npm install -g mintlify@4.2.284 --force +openapi/ # Source OpenAPI YAML (edit here) + openapi.yaml # Root spec with $ref references + paths/ # Endpoint definitions by domain + components/schemas/ # Reusable schema definitions + webhooks/ # Webhook event definitions +openapi.yaml # Generated bundle (don't edit) +mintlify/ # Mintlify documentation (MDX) + docs.json # Navigation and theme config + snippets/ # Shared MDX snippets (use these to avoid duplication) + styles/base.css # CSS overrides +.redocly.yaml # Redocly bundler/linter config ``` -### Troubleshooting: API Reference Not Showing - -If the API reference pages appear blank (only showing title and navigation, no endpoint details): - -1. **Restart the dev server** - hot reload sometimes fails: - ```bash - pkill -f "mint.*dev" - cd mintlify && mint dev - ``` - -2. **Check CLI version** - ensure you're on 4.2.284 (see above) - -3. **Verify OpenAPI spec** - run `mint openapi-check openapi.yaml` in the mintlify folder - -### Documentation Philosophy - -- **Document just enough** for user success - balance between too much and too little -- **Avoid duplication** - use snippets for shared content across use cases -- **Make content evergreen** when possible -- **Check existing patterns** for consistency before making changes -- **Search before adding** - look for existing information before creating new content - -### CSS Styling Tips (Mintlify Overrides) - -When overriding Mintlify's default styles in `mintlify/styles/base.css`: - -- **Tailwind utility classes are hard to override directly** - Classes like `mb-3.5` have high specificity. Even with `!important` and complex selectors, they often won't budge. - -- **Workaround: Use negative margins on sibling elements** - Instead of reducing `margin-bottom` on an element, add negative `margin-top` to the following sibling. This achieves the same visual effect. - -- **Test selectors with visible properties first** - If a style isn't applying, add `border: 2px solid red !important;` to confirm the selector is matching. If the border shows, the selector works but something else is overriding your property. - -- **HeadlessUI portal elements** - Mobile nav and modals render inside `#headlessui-portal-root`. Use this in selectors for higher specificity: `#headlessui-portal-root #mobile-nav ...` - -- **Mobile nav lives in `#mobile-nav`** - Target mobile-specific styles with `#mobile-nav` or `div#mobile-nav` selectors to avoid affecting desktop sidebar. - -- **Negative margins for edge-to-edge layouts** - To break out of parent padding (e.g., make nav items edge-to-edge), use negative margins equal to the parent's padding, then add your own padding inside. - -### MDX Component Limitations - -- **`React.useEffect` breaks the MDX parser** - Mintlify uses acorn to parse MDX, and it chokes on `useEffect`. Avoid using hooks that require cleanup or side effects. - -- **`React.useState` works fine** - Simple state management is supported. +## OpenAPI -- **Keep components simple** - If you need complex interactivity, consider using CSS-only solutions or restructuring to avoid problematic hooks. +Bundled and linted with Redocly (`@redocly/cli`), configured in `.redocly.yaml`. Lint rules enforce operation descriptions, operation IDs, and security definitions. -## Environments +## CSS Overrides (mintlify/styles/base.css) -- **Production**: `https://api.lightspark.com/grid/2025-10-13` -- **Sandbox**: Available for testing (see sandbox endpoints `/sandbox/send`, `/sandbox/receive`) +- Tailwind utility classes (e.g., `mb-3.5`) are hard to override even with `!important` — use negative margins on sibling elements as a workaround +- Test selectors with `border: 2px solid red !important` to confirm they match before debugging property conflicts +- Mobile nav is in `#mobile-nav`; modals/portals are in `#headlessui-portal-root` -## Support +## Troubleshooting: Blank API Reference Pages -For questions or issues: support@lightspark.com +1. Restart dev server: `pkill -f "mint.*dev" && cd mintlify && mint dev` +2. Verify CLI version is 4.2.284 +3. Run `cd mintlify && mint openapi-check openapi.yaml` diff --git a/docs/plans/2026-02-12-grid-sample-app-design.md b/docs/plans/2026-02-12-grid-sample-app-design.md new file mode 100644 index 00000000..2defeea5 --- /dev/null +++ b/docs/plans/2026-02-12-grid-sample-app-design.md @@ -0,0 +1,202 @@ +# Grid API Sample Application Design + +## Overview + +A sample application demonstrating the Grid API payout flow using the Grid Kotlin SDK. Consists of a shared Vite/React frontend and a Kotlin (Ktor) backend. The frontend is reusable with future backend implementations in other languages. + +## Architecture + +**Approach:** Thin backend proxy. The Kotlin backend holds API credentials, translates frontend JSON requests into Grid SDK builder calls, and returns raw JSON responses. The frontend orchestrates the step-by-step wizard flow. Webhooks stream from backend to frontend via SSE. + +## Directory Structure + +``` +samples/ +├── frontend/ # Shared Vite + React + Tailwind frontend +│ ├── package.json +│ ├── vite.config.ts # Proxies /api → localhost:8080 +│ ├── tsconfig.json +│ ├── index.html +│ └── src/ +│ ├── main.tsx +│ ├── App.tsx # Wizard flow + webhook panel +│ ├── components/ +│ │ ├── StepWizard.tsx # Step container with progress indicator +│ │ ├── JsonEditor.tsx # Editable JSON textarea +│ │ ├── ResponsePanel.tsx # Shows API response JSON +│ │ └── WebhookStream.tsx # SSE-connected live webhook feed +│ ├── steps/ +│ │ ├── CreateCustomer.tsx +│ │ ├── CreateExternalAccount.tsx +│ │ ├── CreateQuote.tsx +│ │ ├── ExecuteQuote.tsx +│ │ └── SandboxFund.tsx +│ └── lib/ +│ └── api.ts # fetch wrappers for /api/* endpoints +│ +├── kotlin/ # Kotlin backend sample +│ ├── README.md +│ ├── .env.example +│ ├── build.gradle.kts +│ ├── settings.gradle.kts +│ ├── gradle.properties +│ ├── gradlew / gradlew.bat +│ └── src/main/kotlin/com/grid/sample/ +│ ├── Application.kt +│ ├── Config.kt +│ ├── GridClientBuilder.kt +│ ├── Routing.kt +│ ├── WebhookStream.kt +│ ├── JsonUtils.kt +│ └── routes/ +│ ├── Customers.kt +│ ├── ExternalAccounts.kt +│ ├── Quotes.kt +│ ├── Sandbox.kt +│ ├── Webhooks.kt +│ └── Sse.kt +│ +└── README.md +``` + +## API Contract + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/customers` | Create an individual customer | +| `POST` | `/api/customers/{customerId}/external-accounts` | Create a USD external bank account | +| `POST` | `/api/quotes` | Create a quote (USDC internal → USD external) | +| `POST` | `/api/quotes/{quoteId}/execute` | Execute the quote to initiate payment | +| `POST` | `/api/sandbox/send-funds` | Simulate funding for the quote | +| `POST` | `/api/webhooks` | Receives webhooks from Grid (not called by frontend) | +| `GET` | `/api/sse` | SSE stream of webhook events to frontend | + +## Step-by-Step Flow + +### Step 1 — Create Customer + +```json +// POST /api/customers +{ + "customerType": "INDIVIDUAL", + "platformCustomerId": "sample-customer-001" +} +// Response includes `id` → used in Step 2 +``` + +### Step 2 — Create External Account + +```json +// POST /api/customers/{customerId}/external-accounts +{ + "currency": "USD", + "accountInfo": { + "accountType": "CHECKING", + "routingNumber": "021000021", + "accountNumber": "123456789" + } +} +// Response includes `id` → used in Step 3 +``` + +### Step 3 — Create Quote + +```json +// POST /api/quotes +{ + "source": { "internalAccountId": "" }, + "destination": { "externalAccountId": "" }, + "lockedCurrencyAmount": 1000, + "lockedCurrencySide": "SENDING" +} +// Response includes `quoteId` → used in Steps 4 and 5 +``` + +### Step 4 — Execute Quote + +```json +// POST /api/quotes/{quoteId}/execute +// No body needed +// Response: updated quote with status change +``` + +### Step 5 — Sandbox Fund + +```json +// POST /api/sandbox/send-funds +{ + "quoteId": "" +} +// Response: sandbox funding confirmation +``` + +## Frontend Design + +### Layout + +Two-panel layout: + +- **Left (60%):** Step wizard with vertical stepper. Active step shows editable JSON textarea and submit button. Response panel below. Completed steps collapse to summary. Future steps grayed out. +- **Right (40%):** Webhook stream panel. SSE connection on page load with auto-reconnect. Newest events at top. Each shows timestamp, event type badge, expandable raw JSON. + +### Tech Stack + +React 18, TypeScript, Vite 5, Tailwind CSS 4. No component libraries. + +### Data Flow Between Steps + +The frontend auto-populates IDs from previous responses into the next step's JSON template. Users can edit any value before submitting. + +## Backend Design (Kotlin) + +### Server + +Ktor 3.x with Netty engine. CORS enabled for all origins. SSE plugin installed. + +### Request Handling Pattern + +Each route handler: +1. Receives raw JSON string from request body +2. Parses with Jackson into `JsonNode` +3. Builds Grid SDK params using builder pattern +4. Calls Grid SDK +5. Returns SDK response as JSON + +### Key Components + +- **`Config.kt`** — Loads `GRID_API_TOKEN_ID`, `GRID_API_CLIENT_SECRET`, `GRID_WEBHOOK_PUBLIC_KEY` from `.env` or system env vars via dotenv-kotlin +- **`GridClientBuilder.kt`** — Lazy singleton `GridOkHttpClient` +- **`WebhookStream.kt`** — `MutableSharedFlow(replay = 10)` for broadcasting webhook events +- **`Webhooks.kt`** — Verifies P-256 ECDSA signature via `X-Grid-Signature` header, broadcasts to `WebhookStream` +- **`Sse.kt`** — Collects from `WebhookStream.eventFlow`, sends as `ServerSentEvent`. Heartbeat endpoint for keep-alive. + +### Dependencies + +- Grid Kotlin SDK (published Maven artifact from `com.grid:grid-kotlin`) +- Ktor 3.x (server-core, server-netty, server-cors, server-sse, server-content-negotiation) +- Jackson (kotlin module) +- dotenv-kotlin +- Logback + +### Error Handling + +Minimal. SDK exceptions caught and returned as JSON with appropriate HTTP status. + +## README Structure + +### `samples/README.md` + +Overview of the sample apps, directory structure, links to sub-READMEs. + +### `samples/kotlin/README.md` + +1. Overview of what the sample demonstrates +2. Prerequisites: Java 21+, Node.js 18+, Grid API sandbox credentials +3. Setup: copy `.env.example`, fill in credentials +4. Running: two terminals (backend `./gradlew run` on :8080, frontend `npm run dev` on :5173) +5. Webhook setup: ngrok for local dev, configure webhook URL in Grid dashboard +6. Walkthrough of each wizard step + +### `samples/frontend/README.md` + +How to run, how to configure proxy target for different backends. diff --git a/docs/plans/2026-02-12-grid-sample-app-plan.md b/docs/plans/2026-02-12-grid-sample-app-plan.md new file mode 100644 index 00000000..ef4b0983 --- /dev/null +++ b/docs/plans/2026-02-12-grid-sample-app-plan.md @@ -0,0 +1,2029 @@ +# Grid API Sample Application Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a sample app (Kotlin backend + shared React frontend) demonstrating the Grid API payout flow: create customer → external account → quote → execute → sandbox fund, with live webhook streaming. + +**Architecture:** Thin backend proxy pattern. Ktor server translates JSON requests to Grid SDK builder calls. React frontend runs a step-by-step wizard, passing IDs between steps. Webhooks stream via SSE from backend to frontend. + +**Tech Stack:** Kotlin 2.1 + Ktor 3.x + Grid Kotlin SDK (backend), React 18 + TypeScript + Vite 5 + Tailwind CSS 4 (frontend) + +**Design doc:** `docs/plans/2026-02-12-grid-sample-app-design.md` + +--- + +### Task 1: Kotlin Backend — Gradle Project Scaffold + +**Files:** +- Create: `samples/kotlin/build.gradle.kts` +- Create: `samples/kotlin/settings.gradle.kts` +- Create: `samples/kotlin/gradle.properties` +- Create: `samples/kotlin/.env.example` +- Create: `samples/kotlin/src/main/resources/application.yaml` +- Create: `samples/kotlin/src/main/resources/logback.xml` + +**Step 1: Initialize Gradle wrapper** + +```bash +cd samples/kotlin +gradle wrapper --gradle-version 8.12 +``` + +If `gradle` is not installed locally, copy wrapper files from `/Users/pengying/Src/grid-api/sdks/grid-kotlin/`: + +```bash +mkdir -p samples/kotlin +cp -r sdks/grid-kotlin/gradle samples/kotlin/gradle +cp sdks/grid-kotlin/gradlew samples/kotlin/gradlew +cp sdks/grid-kotlin/gradlew.bat samples/kotlin/gradlew.bat +``` + +**Step 2: Create settings.gradle.kts** + +```kotlin +rootProject.name = "grid-sample" +``` + +**Step 3: Create gradle.properties** + +```properties +kotlin.code.style=official +org.gradle.jvmargs=-Xmx1024m +``` + +**Step 4: Create build.gradle.kts** + +```kotlin +plugins { + kotlin("jvm") version "2.1.21" + kotlin("plugin.serialization") version "2.1.21" + id("io.ktor.plugin") version "3.1.3" +} + +kotlin { + jvmToolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +group = "com.grid.sample" +version = "0.0.1" + +application { + mainClass = "io.ktor.server.netty.EngineMain" + applicationDefaultJvmArgs = listOf("-Dio.ktor.development=true") +} + +repositories { + mavenCentral() +} + +dependencies { + // Ktor server + implementation("io.ktor:ktor-server-core:3.1.3") + implementation("io.ktor:ktor-server-netty:3.1.3") + implementation("io.ktor:ktor-server-cors:3.1.3") + implementation("io.ktor:ktor-server-sse:3.1.3") + implementation("io.ktor:ktor-server-content-negotiation:3.1.3") + implementation("io.ktor:ktor-server-config-yaml:3.1.3") + + // Grid Kotlin SDK + implementation("com.lightspark.grid:lightspark-grid-kotlin:0.4.0") + + // JSON + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.18.2") + + // Environment + implementation("io.github.cdimascio:dotenv-kotlin:6.4.1") + + // Logging + implementation("ch.qos.logback:logback-classic:1.5.6") +} +``` + +**Step 5: Create .env.example** + +```bash +# Grid API Credentials (from https://app.lightspark.com) +GRID_API_TOKEN_ID=your_api_token_id +GRID_API_CLIENT_SECRET=your_api_client_secret + +# Webhook verification (P-256 public key, PEM format) +GRID_WEBHOOK_PUBLIC_KEY=your_webhook_public_key +``` + +**Step 6: Create application.yaml** + +```yaml +ktor: + application: + modules: + - com.grid.sample.ApplicationKt.module + deployment: + port: 8080 +``` + +**Step 7: Create logback.xml** + +```xml + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + +``` + +**Step 8: Verify compilation** + +```bash +cd samples/kotlin && ./gradlew build +``` + +Expected: BUILD SUCCESSFUL (may have warnings about no source files yet, that's fine) + +**Step 9: Commit** + +```bash +git add samples/kotlin/ +git commit -m "feat(samples): scaffold Kotlin backend Gradle project" +``` + +--- + +### Task 2: Kotlin Backend — Core Infrastructure + +**Files:** +- Create: `samples/kotlin/src/main/kotlin/com/grid/sample/Application.kt` +- Create: `samples/kotlin/src/main/kotlin/com/grid/sample/Config.kt` +- Create: `samples/kotlin/src/main/kotlin/com/grid/sample/GridClientBuilder.kt` +- Create: `samples/kotlin/src/main/kotlin/com/grid/sample/WebhookStream.kt` +- Create: `samples/kotlin/src/main/kotlin/com/grid/sample/JsonUtils.kt` +- Create: `samples/kotlin/src/main/kotlin/com/grid/sample/Routing.kt` + +**Step 1: Create Config.kt** + +```kotlin +package com.grid.sample + +import io.github.cdimascio.dotenv.dotenv + +object Config { + private val dotenv = dotenv { + directory = "./" + ignoreIfMalformed = true + ignoreIfMissing = true + } + + val apiTokenId: String = getEnvVar("GRID_API_TOKEN_ID") + val apiClientSecret: String = getEnvVar("GRID_API_CLIENT_SECRET") + val webhookPublicKey: String = getEnvVar("GRID_WEBHOOK_PUBLIC_KEY").replace("\\n", "\n") + + private fun getEnvVar(key: String): String = + System.getProperty(key) + ?: dotenv[key] + ?: System.getenv(key) + ?: throw IllegalStateException("$key environment variable not set") +} +``` + +**Step 2: Create GridClientBuilder.kt** + +```kotlin +package com.grid.sample + +import com.grid.api.client.GridClient +import com.grid.api.client.okhttp.GridOkHttpClient + +object GridClientBuilder { + val client: GridClient by lazy { + GridOkHttpClient.builder() + .username(Config.apiTokenId) + .password(Config.apiClientSecret) + .build() + } +} +``` + +**Step 3: Create WebhookStream.kt** + +```kotlin +package com.grid.sample + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +object WebhookStream { + private val _eventFlow = MutableSharedFlow(replay = 10) + val eventFlow: SharedFlow = _eventFlow.asSharedFlow() + + fun addEvent(event: String) { + println("Broadcasting webhook: $event") + _eventFlow.tryEmit(event) + } +} +``` + +**Step 4: Create JsonUtils.kt** + +```kotlin +package com.grid.sample + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper + +object JsonUtils { + val mapper: ObjectMapper = jacksonObjectMapper().apply { + enable(SerializationFeature.INDENT_OUTPUT) + } + + fun prettyPrint(obj: Any): String = + try { + mapper.writeValueAsString(obj) + } catch (e: Exception) { + """{"error": "Failed to serialize response: ${e.message}"}""" + } +} +``` + +**Step 5: Create Routing.kt** + +Minimal routing that just installs CORS and SSE — route modules added in later tasks. + +```kotlin +package com.grid.sample + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.plugins.cors.routing.* +import io.ktor.server.routing.* +import io.ktor.server.sse.* + +fun Application.module() { + install(CORS) { + allowMethod(HttpMethod.Options) + allowMethod(HttpMethod.Get) + allowMethod(HttpMethod.Post) + allowHeader(HttpHeaders.ContentType) + allowHeader(HttpHeaders.Authorization) + allowCredentials = true + anyHost() + } + install(SSE) + routing { + // Route modules will be added here + } +} +``` + +**Step 6: Create Application.kt** + +```kotlin +package com.grid.sample + +import io.ktor.server.application.* + +fun main(args: Array) { + io.ktor.server.netty.EngineMain.main(args) +} +``` + +**Step 7: Verify compilation** + +```bash +cd samples/kotlin && ./gradlew build +``` + +Expected: BUILD SUCCESSFUL + +**Step 8: Commit** + +```bash +git add samples/kotlin/src/ +git commit -m "feat(samples): add Kotlin backend core infrastructure" +``` + +--- + +### Task 3: Kotlin Backend — Customer Route + +**Files:** +- Create: `samples/kotlin/src/main/kotlin/com/grid/sample/routes/Customers.kt` +- Modify: `samples/kotlin/src/main/kotlin/com/grid/sample/Routing.kt` + +**Step 1: Create Customers.kt** + +```kotlin +package com.grid.sample.routes + +import com.fasterxml.jackson.databind.JsonNode +import com.grid.api.models.customers.CustomerCreateParams +import com.grid.api.models.customers.CustomerCreateParams.CreateCustomerRequest +import com.grid.api.models.customers.CustomerType +import com.grid.sample.GridClientBuilder +import com.grid.sample.JsonUtils +import io.ktor.http.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +fun Route.customerRoutes() { + route("/api/customers") { + post { + try { + val body = call.receiveText() + val json = JsonUtils.mapper.readTree(body) + + val individualRequest = CreateCustomerRequest + .IndividualCustomerCreateRequest.builder() + .customerType(CustomerType.INDIVIDUAL) + .apply { + json.optText("platformCustomerId")?.let { platformCustomerId(it) } + json.optText("fullName")?.let { fullName(it) } + json.optText("nationality")?.let { nationality(it) } + } + .build() + + val params = CustomerCreateParams.builder() + .createCustomerRequest( + CreateCustomerRequest.ofIndividualCustomerCreate(individualRequest) + ) + .build() + + val customer = GridClientBuilder.client.customers().create(params) + call.respondText( + JsonUtils.prettyPrint(customer), + ContentType.Application.Json, + HttpStatusCode.Created + ) + } catch (e: Exception) { + call.respondText( + """{"error": "${e.message}"}""", + ContentType.Application.Json, + HttpStatusCode.InternalServerError + ) + } + } + } +} + +private fun JsonNode.optText(field: String): String? = + if (has(field) && !get(field).isNull) get(field).asText() else null +``` + +**Step 2: Register route in Routing.kt** + +Add inside the `routing { }` block: + +```kotlin +import com.grid.sample.routes.customerRoutes + +// Inside routing { }: +customerRoutes() +``` + +**Step 3: Verify compilation** + +```bash +cd samples/kotlin && ./gradlew build +``` + +Expected: BUILD SUCCESSFUL + +**Step 4: Commit** + +```bash +git add samples/kotlin/src/ +git commit -m "feat(samples): add customer creation route" +``` + +--- + +### Task 4: Kotlin Backend — External Account Route + +**Files:** +- Create: `samples/kotlin/src/main/kotlin/com/grid/sample/routes/ExternalAccounts.kt` +- Modify: `samples/kotlin/src/main/kotlin/com/grid/sample/Routing.kt` + +**Step 1: Create ExternalAccounts.kt** + +```kotlin +package com.grid.sample.routes + +import com.fasterxml.jackson.databind.JsonNode +import com.grid.api.models.customers.externalaccounts.ExternalAccountCreate +import com.grid.api.models.customers.externalaccounts.ExternalAccountCreateParams +import com.grid.api.models.customers.externalaccounts.ExternalAccountInfoOneOf +import com.grid.api.models.customers.externalaccounts.IndividualBeneficiary +import com.grid.sample.GridClientBuilder +import com.grid.sample.JsonUtils +import io.ktor.http.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +fun Route.externalAccountRoutes() { + route("/api/customers/{customerId}/external-accounts") { + post { + try { + val customerId = call.parameters["customerId"] + ?: return@post call.respondText( + """{"error": "customerId is required"}""", + ContentType.Application.Json, + HttpStatusCode.BadRequest + ) + + val body = call.receiveText() + val json = JsonUtils.mapper.readTree(body) + val accountInfo = json.get("accountInfo") + + val usAccountInfo = ExternalAccountInfoOneOf + .UsAccountExternalAccountInfo.builder() + .accountNumber(accountInfo.get("accountNumber").asText()) + .routingNumber(accountInfo.get("routingNumber").asText()) + .accountType(accountInfo.optText("accountType") ?: "CHECKING") + .apply { + val beneficiaryNode = json.get("beneficiary") + if (beneficiaryNode != null && !beneficiaryNode.isNull) { + beneficiary( + IndividualBeneficiary.builder() + .firstName(beneficiaryNode.optText("firstName") ?: "") + .lastName(beneficiaryNode.optText("lastName") ?: "") + .build() + ) + } + } + .build() + + val externalAccountCreate = ExternalAccountCreate.builder() + .accountInfo( + ExternalAccountInfoOneOf.ofUsAccountExternalAccountInfo(usAccountInfo) + ) + .currency(json.optText("currency") ?: "USD") + .customerId(customerId) + .apply { + json.optText("platformAccountId")?.let { platformAccountId(it) } + } + .build() + + val params = ExternalAccountCreateParams.builder() + .externalAccountCreate(externalAccountCreate) + .build() + + val account = GridClientBuilder.client.customers().externalAccounts().create(params) + call.respondText( + JsonUtils.prettyPrint(account), + ContentType.Application.Json, + HttpStatusCode.Created + ) + } catch (e: Exception) { + call.respondText( + """{"error": "${e.message}"}""", + ContentType.Application.Json, + HttpStatusCode.InternalServerError + ) + } + } + } +} + +private fun JsonNode.optText(field: String): String? = + if (has(field) && !get(field).isNull) get(field).asText() else null +``` + +**Step 2: Register route in Routing.kt** + +```kotlin +import com.grid.sample.routes.externalAccountRoutes + +// Inside routing { }: +externalAccountRoutes() +``` + +**Step 3: Verify compilation** + +```bash +cd samples/kotlin && ./gradlew build +``` + +**Step 4: Commit** + +```bash +git add samples/kotlin/src/ +git commit -m "feat(samples): add external account creation route" +``` + +--- + +### Task 5: Kotlin Backend — Quote Routes (Create + Execute) + +**Files:** +- Create: `samples/kotlin/src/main/kotlin/com/grid/sample/routes/Quotes.kt` +- Modify: `samples/kotlin/src/main/kotlin/com/grid/sample/Routing.kt` + +**Step 1: Create Quotes.kt** + +```kotlin +package com.grid.sample.routes + +import com.fasterxml.jackson.databind.JsonNode +import com.grid.api.models.quotes.BaseDestination +import com.grid.api.models.quotes.BaseQuoteSource +import com.grid.api.models.quotes.QuoteCreateParams +import com.grid.api.models.quotes.QuoteSourceOneOf +import com.grid.api.models.quotes.QuoteDestinationOneOf +import com.grid.sample.GridClientBuilder +import com.grid.sample.JsonUtils +import io.ktor.http.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +fun Route.quoteRoutes() { + route("/api/quotes") { + post { + try { + val body = call.receiveText() + val json = JsonUtils.mapper.readTree(body) + + val sourceNode = json.get("source") + val source = buildQuoteSource(sourceNode) + + val destNode = json.get("destination") + val destination = buildQuoteDestination(destNode) + + val params = QuoteCreateParams.builder() + .source(source) + .destination(destination) + .lockedCurrencyAmount(json.get("lockedCurrencyAmount").asLong()) + .lockedCurrencySide( + when (json.optText("lockedCurrencySide")?.uppercase()) { + "RECEIVING" -> QuoteCreateParams.LockedCurrencySide.RECEIVING + else -> QuoteCreateParams.LockedCurrencySide.SENDING + } + ) + .apply { + json.optText("description")?.let { description(it) } + } + .build() + + val quote = GridClientBuilder.client.quotes().create(params) + call.respondText( + JsonUtils.prettyPrint(quote), + ContentType.Application.Json, + HttpStatusCode.Created + ) + } catch (e: Exception) { + call.respondText( + """{"error": "${e.message}"}""", + ContentType.Application.Json, + HttpStatusCode.InternalServerError + ) + } + } + + post("/{quoteId}/execute") { + try { + val quoteId = call.parameters["quoteId"] + ?: return@post call.respondText( + """{"error": "quoteId is required"}""", + ContentType.Application.Json, + HttpStatusCode.BadRequest + ) + + val quote = GridClientBuilder.client.quotes().execute(quoteId) + call.respondText( + JsonUtils.prettyPrint(quote), + ContentType.Application.Json, + HttpStatusCode.OK + ) + } catch (e: Exception) { + call.respondText( + """{"error": "${e.message}"}""", + ContentType.Application.Json, + HttpStatusCode.InternalServerError + ) + } + } + } +} + +private fun buildQuoteSource(sourceNode: JsonNode): QuoteSourceOneOf { + val sourceType = sourceNode.optText("sourceType") + + if (sourceType == "REALTIME_FUNDING" || sourceNode.has("currency")) { + return QuoteSourceOneOf.ofRealtimeFundingQuoteSource( + QuoteSourceOneOf.RealtimeFundingQuoteSource.builder() + .sourceType(BaseQuoteSource.SourceType.REALTIME_FUNDING) + .currency(sourceNode.get("currency").asText()) + .apply { + sourceNode.optText("customerId")?.let { customerId(it) } + } + .build() + ) + } + + return QuoteSourceOneOf.ofAccountQuoteSource( + QuoteSourceOneOf.AccountQuoteSource.builder() + .sourceType(BaseQuoteSource.SourceType.ACCOUNT) + .accountId(sourceNode.get("accountId").asText()) + .apply { + sourceNode.optText("customerId")?.let { customerId(it) } + } + .build() + ) +} + +private fun buildQuoteDestination(destNode: JsonNode): QuoteDestinationOneOf { + if (destNode.has("umaAddress")) { + return QuoteDestinationOneOf.ofUmaAddressDestination( + QuoteDestinationOneOf.UmaAddressDestination.builder() + .destinationType(BaseDestination.DestinationType.UMA_ADDRESS) + .umaAddress(destNode.get("umaAddress").asText()) + .build() + ) + } + + return QuoteDestinationOneOf.ofAccountDestination( + QuoteDestinationOneOf.AccountDestination.builder() + .destinationType(BaseDestination.DestinationType.ACCOUNT) + .accountId(destNode.get("accountId").asText()) + .build() + ) +} + +private fun JsonNode.optText(field: String): String? = + if (has(field) && !get(field).isNull) get(field).asText() else null +``` + +**Step 2: Register route in Routing.kt** + +```kotlin +import com.grid.sample.routes.quoteRoutes + +// Inside routing { }: +quoteRoutes() +``` + +**Step 3: Verify compilation** + +```bash +cd samples/kotlin && ./gradlew build +``` + +**Step 4: Commit** + +```bash +git add samples/kotlin/src/ +git commit -m "feat(samples): add quote creation and execution routes" +``` + +--- + +### Task 6: Kotlin Backend — Sandbox + Webhooks + SSE Routes + +**Files:** +- Create: `samples/kotlin/src/main/kotlin/com/grid/sample/routes/Sandbox.kt` +- Create: `samples/kotlin/src/main/kotlin/com/grid/sample/routes/Webhooks.kt` +- Create: `samples/kotlin/src/main/kotlin/com/grid/sample/routes/Sse.kt` +- Modify: `samples/kotlin/src/main/kotlin/com/grid/sample/Routing.kt` + +**Step 1: Create Sandbox.kt** + +```kotlin +package com.grid.sample.routes + +import com.grid.api.models.sandbox.SandboxSendFundsParams +import com.grid.sample.GridClientBuilder +import com.grid.sample.JsonUtils +import io.ktor.http.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +fun Route.sandboxRoutes() { + route("/api/sandbox") { + post("/send-funds") { + try { + val body = call.receiveText() + val json = JsonUtils.mapper.readTree(body) + + val params = SandboxSendFundsParams.builder() + .quoteId(json.get("quoteId").asText()) + .currencyCode(json.optText("currencyCode") ?: "USD") + .apply { + if (json.has("currencyAmount") && !json.get("currencyAmount").isNull) { + currencyAmount(json.get("currencyAmount").asLong()) + } + } + .build() + + val response = GridClientBuilder.client.sandbox().sendFunds(params) + call.respondText( + JsonUtils.prettyPrint(response), + ContentType.Application.Json, + HttpStatusCode.OK + ) + } catch (e: Exception) { + call.respondText( + """{"error": "${e.message}"}""", + ContentType.Application.Json, + HttpStatusCode.InternalServerError + ) + } + } + } +} + +private fun com.fasterxml.jackson.databind.JsonNode.optText(field: String): String? = + if (has(field) && !get(field).isNull) get(field).asText() else null +``` + +**Step 2: Create Webhooks.kt** + +```kotlin +package com.grid.sample.routes + +import com.grid.sample.Config +import com.grid.sample.WebhookStream +import io.ktor.http.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import java.security.KeyFactory +import java.security.Signature +import java.security.spec.X509EncodedKeySpec +import java.util.Base64 + +fun Route.webhookRoutes() { + route("/api/webhooks") { + post { + val rawBody = call.receiveText() + val signatureHeader = call.request.headers["X-Grid-Signature"] + + if (signatureHeader != null) { + val isValid = verifyWebhookSignature(rawBody, signatureHeader) + if (!isValid) { + call.respondText( + """{"error": "Invalid webhook signature"}""", + ContentType.Application.Json, + HttpStatusCode.Unauthorized + ) + return@post + } + } + + WebhookStream.addEvent(rawBody) + call.respond(HttpStatusCode.OK) + } + } +} + +private fun verifyWebhookSignature(body: String, signature: String): Boolean { + return try { + val publicKeyPem = Config.webhookPublicKey + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replace("\\s".toRegex(), "") + + val keyBytes = Base64.getDecoder().decode(publicKeyPem) + val keySpec = X509EncodedKeySpec(keyBytes) + val keyFactory = KeyFactory.getInstance("EC") + val publicKey = keyFactory.generatePublic(keySpec) + + val sig = Signature.getInstance("SHA256withECDSA") + sig.initVerify(publicKey) + sig.update(body.toByteArray()) + + val decodedSignature = Base64.getDecoder().decode(signature) + sig.verify(decodedSignature) + } catch (e: Exception) { + println("Webhook signature verification failed: ${e.message}") + false + } +} +``` + +**Step 3: Create Sse.kt** + +```kotlin +package com.grid.sample.routes + +import com.grid.sample.WebhookStream +import io.ktor.server.routing.* +import io.ktor.server.sse.* +import io.ktor.sse.* +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlin.time.Duration.Companion.seconds + +fun Route.sseRoutes() { + sse("/api/sse") { + val connected = """{"type":"connected","timestamp":${System.currentTimeMillis()}}""" + send(ServerSentEvent(connected)) + + WebhookStream.eventFlow + .onEach { event -> send(ServerSentEvent(event)) } + .catch { e -> println("SSE stream error: ${e.message}") } + .launchIn(this) + } + + sse("/api/sse/heartbeat") { + heartbeat { + period = 30.seconds + event = ServerSentEvent("heartbeat") + } + } +} +``` + +**Step 4: Update Routing.kt with all routes** + +Replace the full `Routing.kt` with: + +```kotlin +package com.grid.sample + +import com.grid.sample.routes.* +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.plugins.cors.routing.* +import io.ktor.server.routing.* +import io.ktor.server.sse.* + +fun Application.module() { + install(CORS) { + allowMethod(HttpMethod.Options) + allowMethod(HttpMethod.Get) + allowMethod(HttpMethod.Post) + allowHeader(HttpHeaders.ContentType) + allowHeader(HttpHeaders.Authorization) + allowCredentials = true + anyHost() + } + install(SSE) + routing { + customerRoutes() + externalAccountRoutes() + quoteRoutes() + sandboxRoutes() + webhookRoutes() + sseRoutes() + } +} +``` + +**Step 5: Verify compilation** + +```bash +cd samples/kotlin && ./gradlew build +``` + +Expected: BUILD SUCCESSFUL + +**Step 6: Commit** + +```bash +git add samples/kotlin/src/ +git commit -m "feat(samples): add sandbox, webhook, and SSE routes" +``` + +--- + +### Task 7: Frontend — Vite + React + Tailwind Scaffold + +**Files:** +- Create: `samples/frontend/package.json` +- Create: `samples/frontend/vite.config.ts` +- Create: `samples/frontend/tsconfig.json` +- Create: `samples/frontend/tsconfig.node.json` +- Create: `samples/frontend/index.html` +- Create: `samples/frontend/src/main.tsx` +- Create: `samples/frontend/src/index.css` + +**Step 1: Create package.json** + +```json +{ + "name": "grid-sample-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.10", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "tailwindcss": "^4.1.10", + "typescript": "^5.6.3", + "vite": "^6.0.0" + } +} +``` + +**Step 2: Create vite.config.ts** + +```typescript +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + secure: false, + } + } + } +}) +``` + +**Step 3: Create tsconfig.json** + +```json +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} +``` + +**Step 4: Create tsconfig.node.json** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} +``` + +**Step 5: Create index.html** + +```html + + + + + + Grid API Sample + + +
+ + + +``` + +**Step 6: Create src/index.css** + +```css +@import "tailwindcss"; +``` + +**Step 7: Create src/main.tsx** + +```tsx +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) +``` + +**Step 8: Create placeholder src/App.tsx** + +```tsx +export default function App() { + return ( +
+

Grid API Sample

+

Frontend scaffold — components coming next.

+
+ ) +} +``` + +**Step 9: Install dependencies and verify** + +```bash +cd samples/frontend && npm install && npm run build +``` + +Expected: Build succeeds, output in `dist/` + +**Step 10: Commit** + +```bash +git add samples/frontend/ +git commit -m "feat(samples): scaffold Vite + React + Tailwind frontend" +``` + +--- + +### Task 8: Frontend — API Client + Shared Components + +**Files:** +- Create: `samples/frontend/src/lib/api.ts` +- Create: `samples/frontend/src/components/JsonEditor.tsx` +- Create: `samples/frontend/src/components/ResponsePanel.tsx` + +**Step 1: Create api.ts** + +```typescript +export async function apiPost(path: string, body?: unknown): Promise { + const res = await fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + }) + const text = await res.text() + let data: T + try { + data = JSON.parse(text) + } catch { + throw new Error(text) + } + if (!res.ok) { + throw new Error((data as Record).error ?? text) + } + return data +} +``` + +**Step 2: Create JsonEditor.tsx** + +```tsx +import { useState, useEffect } from 'react' + +interface JsonEditorProps { + value: string + onChange: (value: string) => void + disabled?: boolean +} + +export default function JsonEditor({ value, onChange, disabled }: JsonEditorProps) { + const [error, setError] = useState(null) + + useEffect(() => { + try { + JSON.parse(value) + setError(null) + } catch (e) { + setError((e as Error).message) + } + }, [value]) + + return ( +
+