diff --git a/packages/api/package.json b/packages/api/package.json new file mode 100644 index 0000000..42c7511 --- /dev/null +++ b/packages/api/package.json @@ -0,0 +1,10 @@ +{ + "name": "@trails-cool/api", + "private": true, + "version": "0.0.1", + "type": "module", + "main": "src/index.ts", + "dependencies": { + "zod": "^3.25.0" + } +} diff --git a/packages/api/src/activities.ts b/packages/api/src/activities.ts new file mode 100644 index 0000000..479fbc6 --- /dev/null +++ b/packages/api/src/activities.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; + +/** Activity summary for list views */ +export const ActivitySummarySchema = z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string(), + routeId: z.string().uuid().nullable(), + routeName: z.string().nullable(), + distance: z.number().nullable(), + duration: z.number().nullable(), + elevationGain: z.number().nullable(), + elevationLoss: z.number().nullable(), + startedAt: z.string().datetime().nullable(), + geojson: z.string().nullable(), + createdAt: z.string().datetime(), +}); + +/** Full activity detail */ +export const ActivityDetailSchema = ActivitySummarySchema.extend({ + gpx: z.string().nullable(), + photos: z.array(z.string().url()), +}); + +/** Paginated activity list response */ +export const ActivityListResponseSchema = z.object({ + activities: z.array(ActivitySummarySchema), + nextCursor: z.string().nullable(), +}); + +/** Create activity request */ +export const CreateActivityRequestSchema = z.object({ + name: z.string().min(1).max(200), + description: z.string().max(5000).default(""), + gpx: z.string().optional(), + routeId: z.string().uuid().optional(), + startedAt: z.string().datetime().optional(), + duration: z.number().optional(), + distance: z.number().optional(), +}); + +export type ActivitySummary = z.infer; +export type ActivityDetail = z.infer; +export type ActivityListResponse = z.infer; +export type CreateActivityRequest = z.infer; diff --git a/packages/api/src/auth.ts b/packages/api/src/auth.ts new file mode 100644 index 0000000..8440738 --- /dev/null +++ b/packages/api/src/auth.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; + +export const TokenExchangeRequestSchema = z.object({ + grant_type: z.enum(["authorization_code", "refresh_token"]), + code: z.string().optional(), + code_verifier: z.string().optional(), + refresh_token: z.string().optional(), + client_id: z.string(), + redirect_uri: z.string().optional(), + device_name: z.string().max(100).optional(), +}); + +export const TokenResponseSchema = z.object({ + access_token: z.string(), + refresh_token: z.string(), + token_type: z.literal("Bearer"), + expires_in: z.number(), +}); + +export const DeviceSchema = z.object({ + id: z.string(), + deviceName: z.string().nullable(), + lastActiveAt: z.string().datetime(), + createdAt: z.string().datetime(), + isCurrent: z.boolean(), +}); + +export const DeviceListResponseSchema = z.object({ + devices: z.array(DeviceSchema), +}); + +export type TokenExchangeRequest = z.infer; +export type TokenResponse = z.infer; +export type Device = z.infer; +export type DeviceListResponse = z.infer; diff --git a/packages/api/src/discovery.ts b/packages/api/src/discovery.ts new file mode 100644 index 0000000..9680a9a --- /dev/null +++ b/packages/api/src/discovery.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const DiscoveryResponseSchema = z.object({ + apiVersion: z.string(), + instanceName: z.string(), + apiBaseUrl: z.string().url(), + tileUrl: z.string().url().optional(), +}); + +export type DiscoveryResponse = z.infer; diff --git a/packages/api/src/endpoints.ts b/packages/api/src/endpoints.ts new file mode 100644 index 0000000..a45d84e --- /dev/null +++ b/packages/api/src/endpoints.ts @@ -0,0 +1,35 @@ +/** Typed API endpoint path constants */ +export const ENDPOINTS = { + discovery: "/.well-known/trails-cool", + + auth: { + token: "/api/v1/auth/token", + devices: "/api/v1/auth/devices", + device: (id: string) => `/api/v1/auth/devices/${id}` as const, + }, + + routes: { + list: "/api/v1/routes", + create: "/api/v1/routes", + detail: (id: string) => `/api/v1/routes/${id}` as const, + update: (id: string) => `/api/v1/routes/${id}` as const, + delete: (id: string) => `/api/v1/routes/${id}` as const, + compute: "/api/v1/routes/compute", + }, + + activities: { + list: "/api/v1/activities", + create: "/api/v1/activities", + detail: (id: string) => `/api/v1/activities/${id}` as const, + delete: (id: string) => `/api/v1/activities/${id}` as const, + }, + + uploads: { + presign: "/api/v1/uploads", + }, + + push: { + subscribe: "/api/v1/push/subscribe", + unsubscribe: "/api/v1/push/unsubscribe", + }, +} as const; diff --git a/packages/api/src/errors.ts b/packages/api/src/errors.ts new file mode 100644 index 0000000..7d7fb88 --- /dev/null +++ b/packages/api/src/errors.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +export const FieldErrorSchema = z.object({ + field: z.string(), + message: z.string(), +}); + +export const ApiErrorResponseSchema = z.object({ + error: z.string(), + code: z.string(), + fields: z.array(FieldErrorSchema).optional(), +}); + +export type FieldError = z.infer; +export type ApiErrorResponse = z.infer; + +/** Standard error codes */ +export const ERROR_CODES = { + VALIDATION_ERROR: "VALIDATION_ERROR", + NOT_FOUND: "NOT_FOUND", + UNAUTHORIZED: "UNAUTHORIZED", + FORBIDDEN: "FORBIDDEN", + CONFLICT: "CONFLICT", + RATE_LIMITED: "RATE_LIMITED", + INTERNAL_ERROR: "INTERNAL_ERROR", +} as const; diff --git a/packages/api/src/index.test.ts b/packages/api/src/index.test.ts new file mode 100644 index 0000000..1283c01 --- /dev/null +++ b/packages/api/src/index.test.ts @@ -0,0 +1,318 @@ +import { describe, it, expect } from "vitest"; +import { + API_VERSION, + ENDPOINTS, + // Errors + ApiErrorResponseSchema, + ERROR_CODES, + // Pagination + PaginationQuerySchema, + // Discovery + DiscoveryResponseSchema, + // Auth + TokenExchangeRequestSchema, + TokenResponseSchema, + DeviceSchema, + DeviceListResponseSchema, + // Routes + RouteSummarySchema, + RouteDetailSchema, + RouteListResponseSchema, + CreateRouteRequestSchema, + UpdateRouteRequestSchema, + ComputeRouteRequestSchema, + // Activities + ActivitySummarySchema, + ActivityDetailSchema, + ActivityListResponseSchema, + CreateActivityRequestSchema, + // Uploads + PresignedUploadRequestSchema, + PresignedUploadResponseSchema, +} from "./index.ts"; + +describe("API_VERSION", () => { + it("is a valid semver string", () => { + expect(API_VERSION).toMatch(/^\d+\.\d+\.\d+$/); + }); +}); + +describe("ENDPOINTS", () => { + it("has correct static paths", () => { + expect(ENDPOINTS.discovery).toBe("/.well-known/trails-cool"); + expect(ENDPOINTS.routes.list).toBe("/api/v1/routes"); + expect(ENDPOINTS.activities.list).toBe("/api/v1/activities"); + expect(ENDPOINTS.auth.token).toBe("/api/v1/auth/token"); + expect(ENDPOINTS.uploads.presign).toBe("/api/v1/uploads"); + }); + + it("has correct dynamic paths", () => { + expect(ENDPOINTS.routes.detail("abc-123")).toBe("/api/v1/routes/abc-123"); + expect(ENDPOINTS.activities.delete("xyz")).toBe("/api/v1/activities/xyz"); + expect(ENDPOINTS.auth.device("dev-1")).toBe("/api/v1/auth/devices/dev-1"); + }); +}); + +describe("ApiErrorResponseSchema", () => { + it("accepts valid error", () => { + const result = ApiErrorResponseSchema.parse({ + error: "Not found", + code: "NOT_FOUND", + }); + expect(result.code).toBe("NOT_FOUND"); + }); + + it("accepts error with field errors", () => { + const result = ApiErrorResponseSchema.parse({ + error: "Validation failed", + code: ERROR_CODES.VALIDATION_ERROR, + fields: [{ field: "name", message: "Required" }], + }); + expect(result.fields).toHaveLength(1); + }); + + it("rejects missing error field", () => { + expect(() => ApiErrorResponseSchema.parse({ code: "X" })).toThrow(); + }); +}); + +describe("PaginationQuerySchema", () => { + it("defaults limit to 20", () => { + const result = PaginationQuerySchema.parse({}); + expect(result.limit).toBe(20); + expect(result.cursor).toBeUndefined(); + }); + + it("coerces string limit", () => { + const result = PaginationQuerySchema.parse({ limit: "50" }); + expect(result.limit).toBe(50); + }); + + it("rejects limit > 100", () => { + expect(() => PaginationQuerySchema.parse({ limit: 101 })).toThrow(); + }); +}); + +describe("DiscoveryResponseSchema", () => { + it("accepts valid discovery", () => { + const result = DiscoveryResponseSchema.parse({ + apiVersion: "1.0.0", + instanceName: "trails.cool", + apiBaseUrl: "https://trails.cool/api/v1", + }); + expect(result.apiVersion).toBe("1.0.0"); + }); + + it("accepts optional tileUrl", () => { + const result = DiscoveryResponseSchema.parse({ + apiVersion: "1.0.0", + instanceName: "my-instance", + apiBaseUrl: "https://example.com/api/v1", + tileUrl: "https://tiles.example.com/{z}/{x}/{y}.pbf", + }); + expect(result.tileUrl).toBeDefined(); + }); +}); + +describe("TokenExchangeRequestSchema", () => { + it("accepts authorization_code grant", () => { + const result = TokenExchangeRequestSchema.parse({ + grant_type: "authorization_code", + code: "abc123", + code_verifier: "verifier", + client_id: "trails-cool-mobile", + redirect_uri: "trailscool://auth/callback", + }); + expect(result.grant_type).toBe("authorization_code"); + }); + + it("accepts refresh_token grant", () => { + const result = TokenExchangeRequestSchema.parse({ + grant_type: "refresh_token", + refresh_token: "refresh-abc", + client_id: "trails-cool-mobile", + }); + expect(result.grant_type).toBe("refresh_token"); + }); + + it("rejects invalid grant_type", () => { + expect(() => TokenExchangeRequestSchema.parse({ + grant_type: "password", + client_id: "x", + })).toThrow(); + }); +}); + +describe("TokenResponseSchema", () => { + it("accepts valid token response", () => { + const result = TokenResponseSchema.parse({ + access_token: "at-123", + refresh_token: "rt-456", + token_type: "Bearer", + expires_in: 3600, + }); + expect(result.token_type).toBe("Bearer"); + }); +}); + +describe("CreateRouteRequestSchema", () => { + it("accepts minimal route", () => { + const result = CreateRouteRequestSchema.parse({ name: "My Route" }); + expect(result.name).toBe("My Route"); + expect(result.description).toBe(""); + }); + + it("accepts route with GPX", () => { + const result = CreateRouteRequestSchema.parse({ + name: "Berlin Tour", + description: "A nice ride", + gpx: "...", + routingProfile: "fastbike", + }); + expect(result.gpx).toBeDefined(); + }); + + it("rejects empty name", () => { + expect(() => CreateRouteRequestSchema.parse({ name: "" })).toThrow(); + }); + + it("rejects name over 200 chars", () => { + expect(() => CreateRouteRequestSchema.parse({ name: "x".repeat(201) })).toThrow(); + }); +}); + +describe("UpdateRouteRequestSchema", () => { + it("accepts partial update", () => { + const result = UpdateRouteRequestSchema.parse({ name: "New Name" }); + expect(result.name).toBe("New Name"); + expect(result.gpx).toBeUndefined(); + }); + + it("accepts GPX-only update", () => { + const result = UpdateRouteRequestSchema.parse({ gpx: "..." }); + expect(result.gpx).toBeDefined(); + }); +}); + +describe("ComputeRouteRequestSchema", () => { + it("accepts valid waypoints", () => { + const result = ComputeRouteRequestSchema.parse({ + waypoints: [ + { lat: 52.52, lon: 13.405 }, + { lat: 52.51, lon: 13.39 }, + ], + }); + expect(result.waypoints).toHaveLength(2); + expect(result.profile).toBe("fastbike"); + }); + + it("rejects fewer than 2 waypoints", () => { + expect(() => ComputeRouteRequestSchema.parse({ + waypoints: [{ lat: 52.52, lon: 13.405 }], + })).toThrow(); + }); +}); + +describe("RouteSummarySchema", () => { + const validRoute = { + id: "550e8400-e29b-41d4-a716-446655440000", + name: "Berlin Tour", + description: "", + distance: 42000, + elevationGain: 340, + elevationLoss: 320, + routingProfile: "fastbike", + dayBreaks: [2], + geojson: null, + createdAt: "2026-04-12T10:00:00.000Z", + updatedAt: "2026-04-12T12:00:00.000Z", + }; + + it("accepts valid route summary", () => { + const result = RouteSummarySchema.parse(validRoute); + expect(result.name).toBe("Berlin Tour"); + }); + + it("accepts nullable fields as null", () => { + const result = RouteSummarySchema.parse({ + ...validRoute, + distance: null, + elevationGain: null, + elevationLoss: null, + routingProfile: null, + }); + expect(result.distance).toBeNull(); + }); +}); + +describe("RouteListResponseSchema", () => { + it("accepts paginated response", () => { + const result = RouteListResponseSchema.parse({ + routes: [], + nextCursor: "abc123", + }); + expect(result.nextCursor).toBe("abc123"); + }); + + it("accepts null nextCursor (last page)", () => { + const result = RouteListResponseSchema.parse({ + routes: [], + nextCursor: null, + }); + expect(result.nextCursor).toBeNull(); + }); +}); + +describe("CreateActivityRequestSchema", () => { + it("accepts minimal activity", () => { + const result = CreateActivityRequestSchema.parse({ name: "Morning Ride" }); + expect(result.name).toBe("Morning Ride"); + }); + + it("accepts activity with all fields", () => { + const result = CreateActivityRequestSchema.parse({ + name: "Morning Ride", + description: "Nice weather", + gpx: "...", + routeId: "550e8400-e29b-41d4-a716-446655440000", + startedAt: "2026-04-12T07:00:00.000Z", + duration: 3600, + distance: 25000, + }); + expect(result.routeId).toBeDefined(); + }); +}); + +describe("PresignedUploadRequestSchema", () => { + it("accepts valid upload request", () => { + const result = PresignedUploadRequestSchema.parse({ + filename: "photo.jpg", + contentType: "image/jpeg", + resourceType: "route", + resourceId: "550e8400-e29b-41d4-a716-446655440000", + }); + expect(result.resourceType).toBe("route"); + }); + + it("rejects invalid resource type", () => { + expect(() => PresignedUploadRequestSchema.parse({ + filename: "x.jpg", + contentType: "image/jpeg", + resourceType: "user", + resourceId: "550e8400-e29b-41d4-a716-446655440000", + })).toThrow(); + }); +}); + +describe("DeviceSchema", () => { + it("accepts valid device", () => { + const result = DeviceSchema.parse({ + id: "dev-1", + deviceName: "iPhone 16e", + lastActiveAt: "2026-04-12T10:00:00.000Z", + createdAt: "2026-04-10T08:00:00.000Z", + isCurrent: true, + }); + expect(result.deviceName).toBe("iPhone 16e"); + }); +}); diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts new file mode 100644 index 0000000..8f7e100 --- /dev/null +++ b/packages/api/src/index.ts @@ -0,0 +1,56 @@ +export { API_VERSION } from "./version.ts"; +export { ENDPOINTS } from "./endpoints.ts"; + +// Error schemas +export { + ApiErrorResponseSchema, FieldErrorSchema, ERROR_CODES, + type ApiErrorResponse, type FieldError, +} from "./errors.ts"; + +// Pagination +export { + PaginationQuerySchema, PaginatedResponseSchema, + type PaginationQuery, type PaginatedResponse, +} from "./pagination.ts"; + +// Discovery +export { + DiscoveryResponseSchema, + type DiscoveryResponse, +} from "./discovery.ts"; + +// Auth +export { + TokenExchangeRequestSchema, TokenResponseSchema, + DeviceSchema, DeviceListResponseSchema, + type TokenExchangeRequest, type TokenResponse, + type Device, type DeviceListResponse, +} from "./auth.ts"; + +// Routes +export { + RouteSummarySchema, RouteDetailSchema, RouteVersionSchema, + RouteListResponseSchema, + CreateRouteRequestSchema, UpdateRouteRequestSchema, + ComputeRouteRequestSchema, + type RouteSummary, type RouteDetail, type RouteVersion, + type RouteListResponse, + type CreateRouteRequest, type UpdateRouteRequest, + type ComputeRouteRequest, +} from "./routes.ts"; + +// Activities +export { + ActivitySummarySchema, ActivityDetailSchema, + ActivityListResponseSchema, + CreateActivityRequestSchema, + type ActivitySummary, type ActivityDetail, + type ActivityListResponse, + type CreateActivityRequest, +} from "./activities.ts"; + +// Uploads +export { + PresignedUploadRequestSchema, PresignedUploadResponseSchema, + type PresignedUploadRequest, type PresignedUploadResponse, +} from "./uploads.ts"; diff --git a/packages/api/src/pagination.ts b/packages/api/src/pagination.ts new file mode 100644 index 0000000..649792f --- /dev/null +++ b/packages/api/src/pagination.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +/** Cursor-based pagination query params */ +export const PaginationQuerySchema = z.object({ + cursor: z.string().optional(), + limit: z.coerce.number().min(1).max(100).default(20), +}); + +export type PaginationQuery = z.infer; + +/** Paginated response wrapper — extend with your items */ +export const PaginatedResponseSchema = z.object({ + nextCursor: z.string().nullable(), +}); + +export type PaginatedResponse = z.infer; diff --git a/packages/api/src/routes.ts b/packages/api/src/routes.ts new file mode 100644 index 0000000..b9e64e5 --- /dev/null +++ b/packages/api/src/routes.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; + +/** Route summary for list views */ +export const RouteSummarySchema = z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string(), + distance: z.number().nullable(), + elevationGain: z.number().nullable(), + elevationLoss: z.number().nullable(), + routingProfile: z.string().nullable(), + dayBreaks: z.array(z.number()), + geojson: z.string().nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); + +/** Route version info */ +export const RouteVersionSchema = z.object({ + version: z.number(), + changeDescription: z.string().nullable(), + createdAt: z.string().datetime(), +}); + +/** Full route detail */ +export const RouteDetailSchema = RouteSummarySchema.extend({ + gpx: z.string().nullable(), + versions: z.array(RouteVersionSchema), +}); + +/** Paginated route list response */ +export const RouteListResponseSchema = z.object({ + routes: z.array(RouteSummarySchema), + nextCursor: z.string().nullable(), +}); + +/** Create route request */ +export const CreateRouteRequestSchema = z.object({ + name: z.string().min(1).max(200), + description: z.string().max(5000).default(""), + gpx: z.string().optional(), + routingProfile: z.string().optional(), +}); + +/** Update route request */ +export const UpdateRouteRequestSchema = z.object({ + name: z.string().min(1).max(200).optional(), + description: z.string().max(5000).optional(), + gpx: z.string().optional(), +}); + +/** Compute route request (BRouter proxy) */ +export const ComputeRouteRequestSchema = z.object({ + waypoints: z.array(z.object({ + lat: z.number(), + lon: z.number(), + })).min(2), + profile: z.string().default("fastbike"), + noGoAreas: z.array(z.object({ + points: z.array(z.object({ + lat: z.number(), + lon: z.number(), + })), + })).optional(), +}); + +export type RouteSummary = z.infer; +export type RouteVersion = z.infer; +export type RouteDetail = z.infer; +export type RouteListResponse = z.infer; +export type CreateRouteRequest = z.infer; +export type UpdateRouteRequest = z.infer; +export type ComputeRouteRequest = z.infer; diff --git a/packages/api/src/uploads.ts b/packages/api/src/uploads.ts new file mode 100644 index 0000000..98f8927 --- /dev/null +++ b/packages/api/src/uploads.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export const PresignedUploadRequestSchema = z.object({ + filename: z.string(), + contentType: z.string(), + resourceType: z.enum(["route", "activity"]), + resourceId: z.string().uuid(), +}); + +export const PresignedUploadResponseSchema = z.object({ + uploadUrl: z.string().url(), + storageKey: z.string(), + expiresAt: z.string().datetime(), +}); + +export type PresignedUploadRequest = z.infer; +export type PresignedUploadResponse = z.infer; diff --git a/packages/api/src/version.ts b/packages/api/src/version.ts new file mode 100644 index 0000000..5ef45ad --- /dev/null +++ b/packages/api/src/version.ts @@ -0,0 +1,7 @@ +/** + * API version following semver. + * - Patch: bug fixes + * - Minor: new endpoints, new optional fields (backwards compatible) + * - Major: breaking changes + */ +export const API_VERSION = "1.0.0"; diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json new file mode 100644 index 0000000..5285d28 --- /dev/null +++ b/packages/api/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f75fd4..42d9ac7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -410,6 +410,12 @@ importers: specifier: 'catalog:' version: 8.0.8(@types/node@25.5.2)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) + packages/api: + dependencies: + zod: + specifier: ^3.25.0 + version: 3.25.76 + packages/db: dependencies: drizzle-orm: @@ -4102,6 +4108,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + snapshots: '@adobe/css-tools@4.4.4': {} @@ -7620,3 +7629,5 @@ snapshots: lib0: 0.2.117 yocto-queue@0.1.0: {} + + zod@3.25.76: {}