Skip to content

Latest commit

 

History

History
418 lines (353 loc) · 12.8 KB

File metadata and controls

418 lines (353 loc) · 12.8 KB

MotoTrail — API Contracts & Integration Specs

Version: 0.1.0-draft
Date: 2026-03-23


1. Routing Module — Valhalla Integration

Architecture (API Architect Pattern)

RoutingResilienceLayer   ← circuit breaker, retry, timeout
  └── RoutingManagerLayer    ← caching, request normalisation, config
        └── RoutingServiceLayer    ← raw Valhalla HTTP calls

1.1 RoutingServiceLayer

Responsibility: Raw HTTP communication with Valhalla. No business logic, no caching, no resilience.

Request DTO (Zod schemas — defined in packages/shared):

// packages/shared/src/dto/routing.dto.ts
import { z } from 'zod';

export const WaypointSchema = z.object({
  lon: z.number().min(-180).max(180),
  lat: z.number().min(-90).max(90),
  label: z.string().max(100).optional(),
});

export const RoutingProfile = z.enum(['motorcycle', 'auto']);

export const RoutingOptionsSchema = z.object({
  useHighways: z.number().min(0).max(1).default(0.5),
  avoidBadSurfaces: z.number().min(0).max(1).default(0.5),
  useFerry: z.boolean().default(true),
});

// POST /routing/calculate
export const CalculateRouteSchema = z.object({
  waypoints: z.array(WaypointSchema).min(2).max(25),
  profile: RoutingProfile.default('motorcycle'),
  options: RoutingOptionsSchema.optional(),
});

export type CalculateRouteDto = z.infer<typeof CalculateRouteSchema>;
export type WaypointDto = z.infer<typeof WaypointSchema>;
export type RoutingOptionsDto = z.infer<typeof RoutingOptionsSchema>;

Response DTO (Zod schemas):

// Response types (inferred from Zod or plain interfaces — not validated at boundary)
export interface CalculatedRouteDto {
  geometry: GeoJsonLineString;     // decoded from Valhalla polyline6
  distanceM: number;               // metres
  durationS: number;               // seconds
  summary: string;                 // human-readable route summary
  legs: RouteLegDto[];
}

export interface RouteLegDto {
  distanceM: number;
  durationS: number;
  maneuvers: ManeuverDto[];
}

export interface ManeuverDto {
  type: number;                    // Valhalla maneuver type
  instruction: string;
  lon: number;
  lat: number;
}

// POST /routing/elevation
export const ElevationRequestSchema = z.object({
  points: z.array(z.tuple([z.number(), z.number()])).min(2),  // [lon, lat] pairs
});
export type ElevationRequestDto = z.infer<typeof ElevationRequestSchema>;

export interface ElevationResponseDto {
  points: ElevationPointDto[];
  ascentM: number;
  descentM: number;
}

export interface ElevationPointDto {
  lon: number;
  lat: number;
  elevationM: number;
  distanceFromStartM: number;
}

Valhalla raw request format:

// Route request to Valhalla
interface ValhallaRouteRequest {
  locations: Array<{ lon: number; lat: number; type: 'break' }>;
  costing: 'motorcycle' | 'auto';
  costing_options: {
    motorcycle: {
      use_highways: number;
      use_trails: number;
      use_ferry: number;
      avoid_bad_surfaces: number;
    };
  };
  directions_options: {
    units: 'kilometers';
    language: 'en-US';
  };
}

// Height request to Valhalla
interface ValhallaHeightRequest {
  encoded_polyline: string;        // polyline6 encoded
  range: true;                     // include cumulative distance
}

2. Route Schema — Full Field Spec (Drizzle ORM + drizzle-postgis)

// apps/api/src/database/schema/routes.schema.ts
import { pgTable, uuid, varchar, text, integer, jsonb, timestamp, index } from 'drizzle-orm/pg-core';
import { pgEnum } from 'drizzle-orm/pg-core';
import { lineString, polygon } from 'drizzle-postgis';
import { users } from './users.schema';

export const routeDiscipline = pgEnum('route_discipline', ['touring', 'gravel', 'adventure']);
export const routeStatus = pgEnum('route_status', ['draft', 'public', 'unlisted']);

export const routes = pgTable('routes', {
  id: uuid('id').primaryKey().defaultRandom(),
  userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
  title: varchar('title', { length: 200 }).notNull(),
  description: text('description'),
  discipline: routeDiscipline('discipline').notNull(),
  status: routeStatus('status').notNull().default('draft'),

  // PostGIS geometry (native via drizzle-postgis)
  geometry: lineString('geometry', { srid: 4326 }).notNull(),
  boundingBox: polygon('bounding_box', { srid: 4326 }),

  // Computed stats
  distanceM: integer('distance_m').notNull(),
  durationS: integer('duration_s'),
  ascentM: integer('ascent_m'),
  descentM: integer('descent_m'),
  countryCodes: text('country_codes').array().notNull().default([]),  // from Valhalla admin info

  // Waypoints
  waypoints: jsonb('waypoints').notNull().default([]),

  // Metadata
  viewCount: integer('view_count').notNull().default(0),  // buffered in Redis
  saveCount: integer('save_count').notNull().default(0),
  coverPhotoUrl: text('cover_photo_url'),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
  deletedAt: timestamp('deleted_at', { withTimezone: true }),  // soft delete
}, (table) => [
  index('routes_user_id_idx').on(table.userId),
  index('routes_discipline_idx').on(table.discipline),
  index('routes_status_idx').on(table.status),
  // GIST indexes on geometry columns created via raw SQL migration
  // GIN index on country_codes created via raw SQL migration
  // Partial index on deleted_at created via raw SQL migration
]);

// Elevation data in separate table (lazy-loaded, keeps list queries fast)
export const routeElevation = pgTable('route_elevation', {
  id: uuid('id').primaryKey().defaultRandom(),
  routeId: uuid('route_id').notNull().unique().references(() => routes.id, { onDelete: 'cascade' }),
  elevationData: jsonb('elevation_data').notNull(),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
  index('route_elevation_route_id_idx').on(table.routeId),
]);

// Type exports
export type Route = typeof routes.$inferSelect;
export type NewRoute = typeof routes.$inferInsert;
export type RouteElevation = typeof routeElevation.$inferSelect;

Notes:

  • PostGIS geometry columns use drizzle-postgis types (lineString, polygon) — no manual WKT/GeoJSON serialization needed.
  • GIST/GIN indexes require raw SQL in migrations (Drizzle doesn't support them declaratively yet).
  • All queries must filter by deletedAt IS NULL at the repository level.
  • List/browse queries must explicitly select columns — never SELECT *. Exclude geometry column and never join route_elevation in list queries.

---

## 3. GPX Export Format

```xml
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1"
  creator="MotoTrail"
  xmlns="http://www.topografix.com/GPX/1/1"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">
  <metadata>
    <name>{route.title}</name>
    <desc>{route.description}</desc>
    <author><name>MotoTrail</name></author>
    <link href="https://mototrail.app/routes/{route.id}">
      <text>View on MotoTrail</text>
    </link>
    <time>{route.createdAt.toISOString()}</time>
    <keywords>motorcycle, {route.discipline}</keywords>
  </metadata>
  <trk>
    <name>{route.title}</name>
    <type>{route.discipline}</type>
    <trkseg>
      <!-- One trkpt per coordinate in the LineString geometry -->
      <trkpt lat="{lat}" lon="{lon}">
        <ele>{elevationM}</ele>
      </trkpt>
      ...
    </trkseg>
  </trk>
  <!-- Waypoints as wpt elements -->
  {route.waypoints.map(wp =>
    `<wpt lat="${wp.lat}" lon="${wp.lon}">
      <name>${wp.label ?? 'Waypoint'}</name>
    </wpt>`
  )}
</gpx>

4. Frontend API Client Contract

All API calls go through a typed client. No raw fetch or axios calls in components.

// lib/api/client.ts
import axios from 'axios';

export const apiClient = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL,
  timeout: 15000,
  headers: { 'Content-Type': 'application/json' },
  withCredentials: true,  // send HttpOnly refresh token cookie with every request
});

// Attach in-memory access token to requests
apiClient.interceptors.request.use((config) => {
  const token = useAuthStore.getState().accessToken;
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

// Handle 401 → silent refresh via HttpOnly cookie
// The refresh token is in an HttpOnly cookie (set by the API), so JS never touches it.
// POST /auth/refresh reads the cookie server-side and returns a new access token in the body.
apiClient.interceptors.response.use(
  (res) => res,
  async (error) => {
    if (error.response?.status === 401 && !error.config._retry) {
      error.config._retry = true;
      try {
        const { data } = await axios.post(
          `${process.env.NEXT_PUBLIC_API_URL}/auth/refresh`,
          {},
          { withCredentials: true },  // send the HttpOnly cookie
        );
        useAuthStore.getState().setAccessToken(data.data.accessToken);
        error.config.headers.Authorization = `Bearer ${data.data.accessToken}`;
        return apiClient(error.config);
      } catch {
        useAuthStore.getState().logout();
        return Promise.reject(error);
      }
    }
    return Promise.reject(error);
  }
);

Auth token storage strategy:

  • Access token: stored in Zustand store (JavaScript memory only). Lost on page refresh — silently re-acquired via refresh endpoint.
  • Refresh token: HttpOnly, Secure, SameSite=Strict cookie set by the API. Inaccessible to JavaScript — immune to XSS token theft.
  • No localStorage or sessionStorage is used for tokens.
// lib/api/routes.api.ts
export const routesApi = {
  browse: (params: RouteQueryParams) =>
    apiClient.get<PaginatedResponse<RouteListItem>>('/routes', { params }),

  getById: (id: string) =>
    apiClient.get<Route>(`/routes/${id}`),

  create: (dto: CreateRouteDto) =>
    apiClient.post<Route>('/routes', dto),

  update: (id: string, dto: UpdateRouteDto) =>
    apiClient.patch<Route>(`/routes/${id}`, dto),

  delete: (id: string) =>
    apiClient.delete(`/routes/${id}`),

  getGpxUrl: (id: string) =>
    `${process.env.NEXT_PUBLIC_API_URL}/routes/${id}/gpx`,

  uploadPhoto: (id: string, file: File) => {
    const form = new FormData();
    form.append('file', file);
    return apiClient.post<RoutePhoto>(`/routes/${id}/photos`, form, {
      headers: { 'Content-Type': 'multipart/form-data' },
    });
  },
};

// lib/api/routing.api.ts
export const routingApi = {
  calculate: (dto: CalculateRouteDto) =>
    apiClient.post<CalculatedRouteDto>('/routing/calculate', dto),

  elevation: (dto: ElevationRequestDto) =>
    apiClient.post<ElevationResponseDto>('/routing/elevation', dto),
};

5. Planner State Machine

The route planner follows this state machine:

IDLE
  → (user clicks map or drops waypoint)
WAYPOINTS_SET (< 2 waypoints)
  → (second waypoint added)
CALCULATING
  → (Valhalla responds successfully)
ROUTE_READY
  → (user modifies waypoints)
CALCULATING (recalculate)
  → (Valhalla error)
CALCULATION_ERROR
  → (user clears all waypoints)
IDLE

Corresponding Zustand store actions:

addWaypoint(lon: number, lat: number): void
removeWaypoint(index: number): void
moveWaypoint(index: number, lon: number, lat: number): void
reorderWaypoints(from: number, to: number): void
clearRoute(): void
calculateRoute(): Promise<void>    // calls routingApi.calculate, handles state transitions
saveRoute(meta: RouteMetaDto): Promise<Route>   // requires auth

6. Valhalla Resilience Configuration Reference

// infra/valhalla/valhalla.json (Docker config)
{
  "mjolnir": {
    "tile_dir": "/custom_files/valhalla_tiles",
    "timezone": "/custom_files/timezones.sqlite",
    "admin": "/custom_files/admins.sqlite"
  },
  "thor": {
    "logging": { "type": "std_out", "color": false, "level": "WARN" }
  },
  "httpd": {
    "service": {
      "listen": "tcp://0.0.0.0:8002",
      "loopback": "ipc:///tmp/loopback",
      "interrupt": "ipc:///tmp/interrupt"
    }
  },
  "service_limits": {
    "auto": { "max_distance": 5000000.0, "max_locations": 20 },
    "motorcycle": { "max_distance": 5000000.0, "max_locations": 25 },
    "max_matrix_distance": 400000.0,
    "max_matrix_location_pairs": 25
  }
}
// NestJS resilience layer config
export const VALHALLA_RESILIENCE_CONFIG = {
  circuitBreaker: {
    timeout: 10_000,          // ms — individual request timeout
    errorThresholdPercentage: 50,
    resetTimeout: 30_000,     // ms — how long before trying again
    volumeThreshold: 5,       // min requests before circuit can open
  },
  retry: {
    maxAttempts: 3,
    delays: [100, 200, 400],  // ms — exponential backoff
    retryOn: [500, 502, 503, 504],   // HTTP status codes to retry
  },
} as const;