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:
parent
572d240ed1
commit
49b304749e
13 changed files with 349 additions and 0 deletions
10
packages/api/package.json
Normal file
10
packages/api/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
45
packages/api/src/activities.ts
Normal file
45
packages/api/src/activities.ts
Normal 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
35
packages/api/src/auth.ts
Normal 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>;
|
||||
10
packages/api/src/discovery.ts
Normal file
10
packages/api/src/discovery.ts
Normal 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>;
|
||||
35
packages/api/src/endpoints.ts
Normal file
35
packages/api/src/endpoints.ts
Normal 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;
|
||||
26
packages/api/src/errors.ts
Normal file
26
packages/api/src/errors.ts
Normal 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
56
packages/api/src/index.ts
Normal 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";
|
||||
16
packages/api/src/pagination.ts
Normal file
16
packages/api/src/pagination.ts
Normal 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>;
|
||||
73
packages/api/src/routes.ts
Normal file
73
packages/api/src/routes.ts
Normal 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>;
|
||||
17
packages/api/src/uploads.ts
Normal file
17
packages/api/src/uploads.ts
Normal 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>;
|
||||
7
packages/api/src/version.ts
Normal file
7
packages/api/src/version.ts
Normal 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";
|
||||
8
packages/api/tsconfig.json
Normal file
8
packages/api/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue