Add @trails-cool/api package with Zod schemas for REST API contract

Single source of truth for the Journal REST API:
- API_VERSION semver constant (1.0.0)
- Endpoint path constants (routes, activities, auth, uploads, push)
- Zod schemas for all request/response shapes:
  - Routes: list, detail, create, update, compute
  - Activities: list, detail, create
  - Auth: token exchange, refresh, device management
  - Discovery: instance info + API version
  - Uploads: presigned URL flow
  - Errors: structured error response with field-level details
- Cursor-based pagination schemas
- TypeScript types inferred from Zod (z.infer)

Used by Journal server (validation) and mobile client (type safety).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-12 22:49:30 +02:00
parent 572d240ed1
commit 49b304749e
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
13 changed files with 349 additions and 0 deletions

10
packages/api/package.json Normal file
View file

@ -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"
}
}

View file

@ -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<typeof ActivitySummarySchema>;
export type ActivityDetail = z.infer<typeof ActivityDetailSchema>;
export type ActivityListResponse = z.infer<typeof ActivityListResponseSchema>;
export type CreateActivityRequest = z.infer<typeof CreateActivityRequestSchema>;

35
packages/api/src/auth.ts Normal file
View file

@ -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<typeof TokenExchangeRequestSchema>;
export type TokenResponse = z.infer<typeof TokenResponseSchema>;
export type Device = z.infer<typeof DeviceSchema>;
export type DeviceListResponse = z.infer<typeof DeviceListResponseSchema>;

View file

@ -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<typeof DiscoveryResponseSchema>;

View file

@ -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;

View file

@ -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<typeof FieldErrorSchema>;
export type ApiErrorResponse = z.infer<typeof ApiErrorResponseSchema>;
/** 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;

56
packages/api/src/index.ts Normal file
View file

@ -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";

View file

@ -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<typeof PaginationQuerySchema>;
/** Paginated response wrapper — extend with your items */
export const PaginatedResponseSchema = z.object({
nextCursor: z.string().nullable(),
});
export type PaginatedResponse = z.infer<typeof PaginatedResponseSchema>;

View file

@ -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<typeof RouteSummarySchema>;
export type RouteVersion = z.infer<typeof RouteVersionSchema>;
export type RouteDetail = z.infer<typeof RouteDetailSchema>;
export type RouteListResponse = z.infer<typeof RouteListResponseSchema>;
export type CreateRouteRequest = z.infer<typeof CreateRouteRequestSchema>;
export type UpdateRouteRequest = z.infer<typeof UpdateRouteRequestSchema>;
export type ComputeRouteRequest = z.infer<typeof ComputeRouteRequestSchema>;

View file

@ -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<typeof PresignedUploadRequestSchema>;
export type PresignedUploadResponse = z.infer<typeof PresignedUploadResponseSchema>;

View file

@ -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";

View file

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}