Add BRouter proxy, rate limiting, and spec updates (tasks 5.1-5.2) (#11)
This commit is contained in:
parent
9deda5f125
commit
2d02ae3f9e
7 changed files with 173 additions and 5 deletions
37
apps/planner/app/lib/brouter.ts
Normal file
37
apps/planner/app/lib/brouter.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
const BROUTER_URL = process.env.BROUTER_URL ?? "http://localhost:17777";
|
||||
|
||||
export interface RouteRequest {
|
||||
waypoints: Array<{ lat: number; lon: number }>;
|
||||
profile?: string;
|
||||
alternativeIdx?: number;
|
||||
format?: string;
|
||||
}
|
||||
|
||||
export async function computeRoute(request: RouteRequest): Promise<unknown> {
|
||||
if (request.waypoints.length < 2) {
|
||||
throw new Error("At least 2 waypoints are required");
|
||||
}
|
||||
|
||||
const lonlats = request.waypoints.map((wp) => `${wp.lon},${wp.lat}`).join("|");
|
||||
|
||||
const params = new URLSearchParams({
|
||||
lonlats,
|
||||
profile: request.profile ?? "trekking",
|
||||
alternativeidx: String(request.alternativeIdx ?? 0),
|
||||
format: request.format ?? "geojson",
|
||||
});
|
||||
|
||||
const url = `${BROUTER_URL}/brouter?${params}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`BRouter error (${response.status}): ${body}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export function getBRouterUrl(): string {
|
||||
return BROUTER_URL;
|
||||
}
|
||||
34
apps/planner/app/lib/rate-limit.test.ts
Normal file
34
apps/planner/app/lib/rate-limit.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { checkRateLimit } from "./rate-limit";
|
||||
|
||||
describe("checkRateLimit", () => {
|
||||
it("allows requests within limit", () => {
|
||||
const result = checkRateLimit("test-allow", { maxRequests: 5, windowMs: 60000 });
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(4);
|
||||
});
|
||||
|
||||
it("blocks requests exceeding limit", () => {
|
||||
const key = "test-block";
|
||||
const opts = { maxRequests: 3, windowMs: 60000 };
|
||||
|
||||
checkRateLimit(key, opts); // 1
|
||||
checkRateLimit(key, opts); // 2
|
||||
checkRateLimit(key, opts); // 3
|
||||
|
||||
const result = checkRateLimit(key, opts); // 4 — over limit
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.remaining).toBe(0);
|
||||
expect(result.retryAfterSeconds).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("uses separate counters per key", () => {
|
||||
const opts = { maxRequests: 1, windowMs: 60000 };
|
||||
|
||||
const a = checkRateLimit("key-a", opts);
|
||||
const b = checkRateLimit("key-b", opts);
|
||||
|
||||
expect(a.allowed).toBe(true);
|
||||
expect(b.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
43
apps/planner/app/lib/rate-limit.ts
Normal file
43
apps/planner/app/lib/rate-limit.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
interface RateLimitEntry {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
const store = new Map<string, RateLimitEntry>();
|
||||
|
||||
const DEFAULT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||
const DEFAULT_MAX_REQUESTS = 60;
|
||||
|
||||
// Clean up expired entries periodically
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of store) {
|
||||
if (now > entry.resetAt) {
|
||||
store.delete(key);
|
||||
}
|
||||
}
|
||||
}, 60 * 1000);
|
||||
|
||||
export function checkRateLimit(
|
||||
key: string,
|
||||
options?: { windowMs?: number; maxRequests?: number },
|
||||
): { allowed: boolean; remaining: number; retryAfterSeconds?: number } {
|
||||
const windowMs = options?.windowMs ?? DEFAULT_WINDOW_MS;
|
||||
const maxRequests = options?.maxRequests ?? DEFAULT_MAX_REQUESTS;
|
||||
const now = Date.now();
|
||||
|
||||
let entry = store.get(key);
|
||||
if (!entry || now > entry.resetAt) {
|
||||
entry = { count: 0, resetAt: now + windowMs };
|
||||
store.set(key, entry);
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
|
||||
if (entry.count > maxRequests) {
|
||||
const retryAfterSeconds = Math.ceil((entry.resetAt - now) / 1000);
|
||||
return { allowed: false, remaining: 0, retryAfterSeconds };
|
||||
}
|
||||
|
||||
return { allowed: true, remaining: maxRequests - entry.count };
|
||||
}
|
||||
|
|
@ -3,5 +3,6 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes";
|
|||
export default [
|
||||
index("routes/home.tsx"),
|
||||
route("api/sessions", "routes/api.sessions.ts"),
|
||||
route("api/route", "routes/api.route.ts"),
|
||||
route("session/:id", "routes/session.$id.tsx"),
|
||||
] satisfies RouteConfig;
|
||||
|
|
|
|||
45
apps/planner/app/routes/api.route.ts
Normal file
45
apps/planner/app/routes/api.route.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { data } from "react-router";
|
||||
import type { Route } from "./+types/api.route";
|
||||
import { computeRoute } from "~/lib/brouter";
|
||||
import { checkRateLimit } from "~/lib/rate-limit";
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
if (request.method !== "POST") {
|
||||
return data({ error: "Method not allowed" }, { status: 405 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { waypoints, profile, sessionId } = body as {
|
||||
waypoints: Array<{ lat: number; lon: number }>;
|
||||
profile?: string;
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
if (!waypoints || waypoints.length < 2) {
|
||||
return data({ error: "At least 2 waypoints are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Rate limit by session ID or IP
|
||||
const rateLimitKey = sessionId ?? request.headers.get("x-forwarded-for") ?? "unknown";
|
||||
const limit = checkRateLimit(`route:${rateLimitKey}`);
|
||||
|
||||
if (!limit.allowed) {
|
||||
return data(
|
||||
{ error: "Rate limit exceeded" },
|
||||
{
|
||||
status: 429,
|
||||
headers: { "Retry-After": String(limit.retryAfterSeconds) },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const route = await computeRoute({ waypoints, profile });
|
||||
return data(route, {
|
||||
headers: { "X-RateLimit-Remaining": String(limit.remaining) },
|
||||
});
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Route computation failed";
|
||||
return data({ error: message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue