Track and save on groceries

feat(backend): implement orpc contract

Signed-off-by: Sefa Eyeoglu <contact@scrumplex.net>

+116 -19
+3
apps/backend/package.json
··· 7 7 "start": "node dist/index.js" 8 8 }, 9 9 "dependencies": { 10 + "@cherries-app/api": "workspace:*", 10 11 "@hono/node-server": "^1.19.9", 12 + "@orpc/openapi": "^1.13.5", 13 + "@orpc/server": "^1.13.5", 11 14 "hono": "^4.11.9" 12 15 }, 13 16 "devDependencies": {
+4
apps/backend/src/impl/base.ts
··· 1 + import { apiContract } from "@cherries-app/api/contract"; 2 + import { implement } from "@orpc/server"; 3 + 4 + export const os = implement(apiContract);
+6
apps/backend/src/impl/index.ts
··· 1 + import { os } from "./base.js"; 2 + import { storeRouter } from "./stores.js"; 3 + 4 + export const apiRouter = os.router({ 5 + stores: storeRouter, 6 + });
+36
apps/backend/src/impl/stores.ts
··· 1 + import { ORPCError } from "@orpc/contract"; 2 + import { os } from "./base.js"; 3 + 4 + export const listStores = os.stores.list.handler(({ input }) => { 5 + return { 6 + status: 200, 7 + body: { 8 + total: 0, 9 + limit: input.query.limit, 10 + items: [], 11 + }, 12 + }; 13 + }); 14 + 15 + export const getStore = os.stores.get.handler(({ input }) => { 16 + throw new ORPCError("NOT_FOUND", { 17 + message: `Store ${input.params.id} not found`, 18 + }); 19 + }); 20 + 21 + export const createStore = os.stores.create.handler(() => { 22 + throw new Error(); 23 + }); 24 + 25 + export const deleteStore = os.stores.delete.handler(() => { 26 + return { 27 + status: 204, 28 + }; 29 + }); 30 + 31 + export const storeRouter = { 32 + list: listStores, 33 + get: getStore, 34 + create: createStore, 35 + delete: deleteStore, 36 + };
+24 -3
apps/backend/src/index.ts
··· 1 + import { Hono } from "hono"; 2 + import { onError } from "@orpc/server"; 3 + import { OpenAPIHandler } from "@orpc/openapi/fetch"; 4 + import { apiRouter } from "./impl/index.js"; 1 5 import { serve } from "@hono/node-server"; 2 - import { Hono } from "hono"; 3 6 4 7 const app = new Hono(); 5 8 6 - app.get("/", (c) => { 7 - return c.text("Hello Hono!"); 9 + const handler = new OpenAPIHandler(apiRouter, { 10 + interceptors: [ 11 + onError((error) => { 12 + console.error(error); 13 + }), 14 + ], 15 + }); 16 + 17 + app.use("/*", async (c, next) => { 18 + const { matched, response } = await handler.handle(c.req.raw, { 19 + context: {}, // Provide initial context if needed 20 + }); 21 + 22 + console.log(matched); 23 + 24 + if (matched) { 25 + return c.newResponse(response.body, response); 26 + } 27 + 28 + await next(); 8 29 }); 9 30 10 31 serve(
+10
packages/api/package.json
··· 19 19 "devDependencies": { 20 20 "@types/node": "^25.2.3", 21 21 "typescript": "^5.9.3" 22 + }, 23 + "exports": { 24 + ".": { 25 + "types": "./src/", 26 + "default": "./dist/" 27 + }, 28 + "./contract": { 29 + "types": "./src/contract/index.ts", 30 + "default": "./dist/contract/index.js" 31 + } 22 32 } 23 33 }
+10
packages/api/src/contract/base.ts
··· 1 + import { oc } from "@orpc/contract"; 2 + 3 + export const base = oc 4 + .$route({ 5 + inputStructure: "detailed", 6 + outputStructure: "detailed", 7 + }) 8 + .errors({ 9 + UNAUTHORIZED: {}, 10 + });
+4 -9
packages/api/src/contract/index.ts
··· 1 - import { oc } from "@orpc/contract"; 1 + import { storeContract } from "./stores.js"; 2 2 3 - export const base = oc 4 - .$route({ 5 - inputStructure: "detailed", 6 - outputStructure: "detailed", 7 - }) 8 - .errors({ 9 - UNAUTHORIZED: {}, 10 - }); 3 + export const apiContract = { 4 + stores: storeContract, 5 + };
+6 -3
packages/api/src/contract/store.ts packages/api/src/contract/stores.ts
··· 1 1 import z from "zod/v4"; 2 - import { CreateStore, Store, Stores, StoresQuery } from "../schema/store.js"; 3 - import { base } from "./index.js"; 2 + import { CreateStore, Store, Stores, StoresQuery } from "../schema/stores.js"; 3 + import { base } from "./base.js"; 4 4 5 5 export const listStore = base 6 6 .route({ ··· 36 36 status: z.literal(200).describe("store found"), 37 37 body: Store, 38 38 }), 39 - ); 39 + ) 40 + .errors({ 41 + NOT_FOUND: {}, 42 + }); 40 43 41 44 export const createStore = base 42 45 .route({
+1 -1
packages/api/src/schema/store.ts packages/api/src/schema/stores.ts
··· 3 3 4 4 export const Store = z 5 5 .object({ 6 - id: z.number().int().min(0), 6 + id: z.coerce.number().int().min(0), 7 7 name: z.string(), 8 8 }) 9 9 .meta({
+3 -3
packages/api/src/spec.ts
··· 1 1 import { OpenAPIGenerator } from "@orpc/openapi"; 2 2 import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; 3 - import { storeContract } from "./contract/store.js"; 4 - import { Store, Stores } from "./schema/store.js"; 3 + import { Store, Stores } from "./schema/stores.js"; 5 4 import { mkdir, writeFile } from "node:fs/promises"; 5 + import { apiContract } from "./contract/index.js"; 6 6 7 7 const generator = new OpenAPIGenerator({ 8 8 schemaConverters: [new ZodToJsonSchemaConverter()], 9 9 }); 10 10 11 - const spec = await generator.generate(storeContract, { 11 + const spec = await generator.generate(apiContract, { 12 12 info: { 13 13 title: "Cherries", 14 14 version: "1.0.0",
+9
pnpm-lock.yaml
··· 17 17 18 18 apps/backend: 19 19 dependencies: 20 + "@cherries-app/api": 21 + specifier: workspace:* 22 + version: link:../../packages/api 20 23 "@hono/node-server": 21 24 specifier: ^1.19.9 22 25 version: 1.19.9(hono@4.11.9) 26 + "@orpc/openapi": 27 + specifier: ^1.13.5 28 + version: 1.13.5 29 + "@orpc/server": 30 + specifier: ^1.13.5 31 + version: 1.13.5 23 32 hono: 24 33 specifier: ^4.11.9 25 34 version: 4.11.9