Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 47 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,48 @@
# Template API Project
# Fake Algora API

This is a template project with best-practice modules:
- Winterspec for defining the API
- bun testing
- Zustand store with zod definition for database state
Small Winterspec API used to simulate Algora-style payments in tests and
development.

## Payments

### Send a payment

`POST /payments/send`

```json
{
"recipient": "github:octocat",
"amount_cents": 500,
"currency": "USD",
"memo": "Bounty payout",
"idempotency_key": "issue-123"
}
```

Returns the created payment. If an `idempotency_key` has already been used, the
existing payment is returned instead of creating a duplicate.

### List payments

`GET /payments/list`

Returns all fake payments currently stored in memory.

### Get a payment

`GET /payments/get?payment_id=0`

Returns a single payment or `null` if it does not exist.

### Update payment status

`POST /payments/update-status`

```json
{
"payment_id": "0",
"status": "completed"
}
```

Valid statuses are `sent`, `completed`, `canceled`, and `failed`.
67 changes: 63 additions & 4 deletions lib/db/db-client.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import { createStore, type StoreApi } from "zustand/vanilla"
import { type HoistedStoreApi, hoist } from "zustand-hoist"
import { immer } from "zustand/middleware/immer"
import { hoist, type HoistedStoreApi } from "zustand-hoist"
import { type StoreApi, createStore } from "zustand/vanilla"

import { databaseSchema, type DatabaseSchema, type Thing } from "./schema.ts"
import { combine } from "zustand/middleware"
import {
type DatabaseSchema,
type Payment,
type PaymentStatus,
type Thing,
databaseSchema,
} from "./schema.ts"

export const createDatabase = () => {
return hoist(createStore(initializer))
}

export type DbClient = ReturnType<typeof createDatabase>

const initializer = combine(databaseSchema.parse({}), (set) => ({
const initializer = combine(databaseSchema.parse({}), (set, get) => ({
addThing: (thing: Omit<Thing, "thing_id">) => {
set((state) => ({
things: [
Expand All @@ -21,4 +27,57 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({
idCounter: state.idCounter + 1,
}))
},
sendPayment: (
payment: Omit<
Payment,
"payment_id" | "status" | "created_at" | "updated_at"
>,
) => {
const existingPayment =
payment.idempotency_key &&
get().payments.find(
(item) => item.idempotency_key === payment.idempotency_key,
)

if (existingPayment) return existingPayment

const timestamp = new Date().toISOString()
const paymentToCreate: Payment = {
...payment,
currency: payment.currency.toUpperCase(),
payment_id: get().paymentIdCounter.toString(),
status: "sent",
created_at: timestamp,
updated_at: timestamp,
}

set((state) => ({
payments: [...state.payments, paymentToCreate],
paymentIdCounter: state.paymentIdCounter + 1,
}))

return paymentToCreate
},
getPayment: (payment_id: string) => {
return get().payments.find((payment) => payment.payment_id === payment_id)
},
updatePaymentStatus: (payment_id: string, status: PaymentStatus) => {
const timestamp = new Date().toISOString()
let updatedPayment: Payment | undefined

set((state) => ({
payments: state.payments.map((payment) => {
if (payment.payment_id !== payment_id) return payment

updatedPayment = {
...payment,
status,
updated_at: timestamp,
}
return updatedPayment
}),
}))

return updatedPayment
},
}))
23 changes: 23 additions & 0 deletions lib/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,31 @@ export const thingSchema = z.object({
})
export type Thing = z.infer<typeof thingSchema>

export const paymentStatusSchema = z.enum([
"sent",
"completed",
"canceled",
"failed",
])
export type PaymentStatus = z.infer<typeof paymentStatusSchema>

export const paymentSchema = z.object({
payment_id: z.string(),
recipient: z.string(),
amount_cents: z.number().int().positive(),
currency: z.string(),
memo: z.string().optional(),
idempotency_key: z.string().optional(),
status: paymentStatusSchema,
created_at: z.string(),
updated_at: z.string(),
})
export type Payment = z.infer<typeof paymentSchema>

export const databaseSchema = z.object({
idCounter: z.number().default(0),
things: z.array(thingSchema).default([]),
paymentIdCounter: z.number().default(0),
payments: z.array(paymentSchema).default([]),
})
export type DatabaseSchema = z.infer<typeof databaseSchema>
16 changes: 16 additions & 0 deletions routes/payments/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import { paymentSchema } from "lib/db/schema"
import { z } from "zod"

export default withRouteSpec({
methods: ["GET"],
queryParams: z.object({
payment_id: z.string(),
}),
jsonResponse: z.object({
payment: paymentSchema.nullable(),
}),
})((req, ctx) => {
const payment_id = new URL(req.url).searchParams.get("payment_id") ?? ""
return ctx.json({ payment: ctx.db.getPayment(payment_id) ?? null })
})
12 changes: 12 additions & 0 deletions routes/payments/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import { paymentSchema } from "lib/db/schema"
import { z } from "zod"

export default withRouteSpec({
methods: ["GET"],
jsonResponse: z.object({
payments: z.array(paymentSchema),
}),
})((req, ctx) => {
return ctx.json({ payments: ctx.db.payments })
})
25 changes: 25 additions & 0 deletions routes/payments/send.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import { paymentSchema } from "lib/db/schema"
import { z } from "zod"

export default withRouteSpec({
methods: ["POST"],
jsonBody: z.object({
recipient: z.string().min(1),
amount_cents: z.number().int().positive(),
currency: z.string().length(3).default("USD"),
memo: z.string().optional(),
idempotency_key: z.string().optional(),
}),
jsonResponse: z.object({
payment: paymentSchema,
}),
})(async (req, ctx) => {
const paymentRequest = await req.json()
const payment = ctx.db.sendPayment({
...paymentRequest,
currency: paymentRequest.currency ?? "USD",
})

return ctx.json({ payment })
})
19 changes: 19 additions & 0 deletions routes/payments/update-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { paymentSchema, paymentStatusSchema } from "lib/db/schema"
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import { z } from "zod"

export default withRouteSpec({
methods: ["POST"],
jsonBody: z.object({
payment_id: z.string(),
status: paymentStatusSchema,
}),
jsonResponse: z.object({
payment: paymentSchema.nullable(),
}),
})(async (req, ctx) => {
const { payment_id, status } = await req.json()
return ctx.json({
payment: ctx.db.updatePaymentStatus(payment_id, status) ?? null,
})
})
112 changes: 112 additions & 0 deletions tests/routes/payments/send.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { expect, test } from "bun:test"
import { getTestServer } from "tests/fixtures/get-test-server"

test("send a payment", async () => {
const { axios } = await getTestServer()

const { data } = await axios.post("/payments/send", {
recipient: "github:user",
amount_cents: 500,
currency: "usd",
memo: "Test bounty payout",
})

expect(data.payment).toMatchObject({
payment_id: "0",
recipient: "github:user",
amount_cents: 500,
currency: "USD",
memo: "Test bounty payout",
status: "sent",
})

const listResponse = await axios.get("/payments/list")
expect(listResponse.data.payments).toHaveLength(1)
})

test("deduplicates payments by idempotency key", async () => {
const { axios } = await getTestServer()

const payload = {
recipient: "github:user",
amount_cents: 1000,
currency: "USD",
idempotency_key: "github-issue-1",
}

const firstResponse = await axios.post("/payments/send", payload)
const secondResponse = await axios.post("/payments/send", payload)

expect(secondResponse.data.payment.payment_id).toBe(
firstResponse.data.payment.payment_id,
)

const listResponse = await axios.get("/payments/list")
expect(listResponse.data.payments).toHaveLength(1)
})

test("defaults payment currency to USD", async () => {
const { axios } = await getTestServer()

const { data } = await axios.post("/payments/send", {
recipient: "github:user",
amount_cents: 500,
})

expect(data.payment.currency).toBe("USD")
})

test("get a payment by id", async () => {
const { axios } = await getTestServer()

await axios.post("/payments/send", {
recipient: "github:user",
amount_cents: 750,
currency: "USD",
})

const { data } = await axios.get("/payments/get", {
params: { payment_id: "0" },
})

expect(data.payment).toMatchObject({
payment_id: "0",
amount_cents: 750,
})
})

test("updates a payment status", async () => {
const { axios } = await getTestServer()

await axios.post("/payments/send", {
recipient: "github:user",
amount_cents: 500,
currency: "USD",
})

const { data } = await axios.post("/payments/update-status", {
payment_id: "0",
status: "completed",
})

expect(data.payment).toMatchObject({
payment_id: "0",
status: "completed",
})

const getResponse = await axios.get("/payments/get", {
params: { payment_id: "0" },
})
expect(getResponse.data.payment.status).toBe("completed")
})

test("returns null when updating a missing payment", async () => {
const { axios } = await getTestServer()

const { data } = await axios.post("/payments/update-status", {
payment_id: "missing",
status: "failed",
})

expect(data.payment).toBeNull()
})