Skip to content

Commit

Permalink
feat(federation): add basic federation for trips (#11)
Browse files Browse the repository at this point in the history
  • Loading branch information
finxol authored Jan 25, 2025
1 parent ea34419 commit bae0409
Show file tree
Hide file tree
Showing 18 changed files with 195 additions and 74 deletions.
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"drizzle-orm": "catalog:",
"drizzle-zod": "catalog:",
"hono": "catalog:",
"ofetch": "catalog:",
"postgres": "catalog:",
"zod": "catalog:"
},
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ export async function logout(token: string) {
db.update(accountsTable).set({ token: "" }).where(eq(accountsTable.token, token))
}

/**
* Get the account ID for a given token
* @param token The user's token
* @returns the account ID
*/
export async function getAccount(token: string): Promise<string | null> {
const account = await db
.select({
Expand Down
34 changes: 0 additions & 34 deletions apps/api/src/lib/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,9 @@
import type { Context } from "hono"
import { HTTPException } from "hono/http-exception"
import type { CustomHeader, RequestHeader } from "hono/utils/headers"
import { ContentfulStatusCode } from "hono/utils/http-status"

import { ADMIN_EMAIL } from "@karr/config"
import { isUUIDv4 } from "@karr/util"
import logger from "@karr/util/logger"

/**
* Check if a request is authenticated
* @param c The Hono context object
* @returns True if the request is authenticated, false otherwise
*/
export function checkAuth(value: Record<RequestHeader | CustomHeader, string>): {
id: string
} {
const authorization = value["authorization"]

if (authorization === undefined || authorization === "") {
throw new HTTPException(400, {
message: "Authencation token is required in Authorization header"
})
}

// TODO(@finxol): verify the JWT
const id: string = authorization

// check the id is a valid UUID
if (!isUUIDv4(id)) {
throw new HTTPException(400, {
message: "Invalid user ID"
})
}

logger.debug(`User ID: ${id}`)

return { id }
}

/**
* Template for a function that returns a response object
* @param c The Hono context object
Expand Down
31 changes: 31 additions & 0 deletions apps/api/src/routes/federation/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { FetchError, ofetch } from "ofetch"

import { FEDERATION_TARGETS } from "@karr/config"
import { Trip } from "@karr/db/schemas/trips.js"
import logger from "@karr/util/logger"

import { DataResponse } from "@/lib/types"

export async function getFederatedTrips(): Promise<Trip[]> {
const trips: Trip[] = []
try {
for await (const target of FEDERATION_TARGETS) {
const t = await ofetch<DataResponse<Trip[]>>(
"/api/v1/federation/trips/search",
{
baseURL: target.url,
headers: {
Cookie: `auth-token=federation`
}
}
)
for (const trip of t.data) {
trip.origin = target.name
trips.push(trip)
}
}
} catch (error) {
logger.error("Error fetching trips from federation:", (error as FetchError)?.data)
}
return trips
}
12 changes: 12 additions & 0 deletions apps/api/src/routes/federation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Hono } from "hono"

import trips from "./trips"

/**
* Federation endpoint
*/
const hono = new Hono()

hono.route("/trips", trips)

export default hono
18 changes: 18 additions & 0 deletions apps/api/src/routes/federation/trips.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Hono } from "hono"

import logger from "@karr/util/logger"

import { getTrips } from "@/db/trips"
import { handleRequest } from "@/lib/helpers"

/**
* Federation trips endpoint
*/
const hono = new Hono()

hono.get("/search", async (c) => {
logger.debug("GET /trips")
return handleRequest(c, getTrips)
})

export default hono
7 changes: 6 additions & 1 deletion apps/api/src/routes/trips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import logger from "@karr/util/logger"
import { addTrip, deleteTrip, getTrips } from "@/db/trips"
import { handleRequest, responseErrorObject } from "@/lib/helpers"
import type { DataResponse } from "@/lib/types.d.ts"
import { getFederatedTrips } from "./federation/helpers"

const hono = new Hono()

Expand Down Expand Up @@ -44,7 +45,10 @@ hono.get("/search", (c) => {
const immediatePromise: Promise<void> = getTrips().then(sendData)

// Get the trips from the federated servers
const slowerPromises: Promise<void>[] = [getSlowerData().then(sendData)]
const slowerPromises: Promise<void>[] = [
getFederatedTrips().then(sendData)
// getSlowerData().then(sendData)
]

await Promise.all([...tripsToSend, immediatePromise, ...slowerPromises])
logger.debug("All data sent")
Expand Down Expand Up @@ -123,6 +127,7 @@ export default hono
// ================ For SSE tests ================
// ===============================================

//eslint-disable-next-line @typescript-eslint/no-unused-vars
function getSlowerData(): Promise<Trip[]> {
return new Promise((resolve) => {
setTimeout(() => {
Expand Down
13 changes: 10 additions & 3 deletions apps/api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import { getCookie } from "hono/cookie"
import { cors } from "hono/cors"
import { validator } from "hono/validator"

import { API_VERSION, PRODUCTION } from "@karr/config"
import { API_VERSION, FEDERATION, PRODUCTION } from "@karr/config"

import account from "@/routes/account"
import auth from "@/routes/auth"
import federation from "@/routes/federation"
import system from "@/routes/system"
import trips from "@/routes/trips"
import user from "@/routes/user"
Expand Down Expand Up @@ -59,14 +60,16 @@ export const build = (): Hono => {
c,
{
message: "Unauthorized",
cause: "Auth token is required in Authorization header"
cause: "Auth token is required in cookie"
},
401
)
}

// TODO(@finxol): verify the JWT
const id: string | null = await getAccount(authtoken)
const id: string | null =
// very unsafe, but it's just for the PoC
authtoken === "federation" ? "federation" : await getAccount(authtoken)

if (id === null) {
return responseErrorObject(
Expand Down Expand Up @@ -102,5 +105,9 @@ export const build = (): Hono => {
hono.route(`/${API_VERSION}/account`, account)
hono.route(`/${API_VERSION}/trips`, trips)

if (FEDERATION) {
hono.route(`/${API_VERSION}/federation`, federation)
}

return hono
}
29 changes: 26 additions & 3 deletions apps/web/src/app/trips/search/trips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

import { useEffect, useState } from "react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { Trash as IconTrash } from "lucide-react"
import { Earth as IconEarth, House as IconHouse, Trash as IconTrash } from "lucide-react"

import { TripSchema, type Trip } from "@karr/db/schemas/trips.js"
import { Badge } from "@karr/ui/components/badge"
import { Button } from "@karr/ui/components/button"
import {
Card,
Expand Down Expand Up @@ -167,7 +168,13 @@ function FetchTrips({ userid }: { userid: string }) {
<section className="mt-10 flex flex-col items-center justify-start gap-4">
{trips.map((trip: Trip) => {
const t = TripSchema.parse(trip)
return <TripCard key={t.id} trip={t} onDelete={deleteTrip} />
return (
<TripCard
key={`${trip.origin || ""}@${t.id}`}
trip={t}
onDelete={deleteTrip}
/>
)
})}
{loading && <Loading />}
</section>
Expand Down Expand Up @@ -201,7 +208,23 @@ function TripCard({
<CardContent>
<p>{trip.price}</p>
</CardContent>
<CardFooter>
<CardFooter className="flow-inline">
{trip.origin ? (
<Badge
variant="outline"
className="text-sm flex flex-row items-center gap-1"
>
<IconEarth />
{trip.origin}
</Badge>
) : (
<Badge
variant="outline"
className="text-sm flex flex-row items-center gap-1"
>
<IconHouse className="my-0.5" />
</Badge>
)}
<p>{trip.account.split("-")[0]}</p>
</CardFooter>
</Card>
Expand Down
2 changes: 2 additions & 0 deletions packages/config/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export const {
LOG_TIMESTAMP,
LOG_LEVEL,
ADMIN_EMAIL,
FEDERATION,
FEDERATION_TARGETS,
API_VERSION,
APPLICATION_NAME,
PRODUCTION
Expand Down
4 changes: 3 additions & 1 deletion packages/config/src/default_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,7 @@
"API_PORT": 1993,
"API_BASE": "/api",
"LOG_LEVEL": "info",
"LOG_TIMESTAMP": true
"LOG_TIMESTAMP": true,
"FEDERATION": true,
"FEDERATION_TARGETS": []
}
13 changes: 13 additions & 0 deletions packages/config/src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,19 @@ export function loadFullConfig(): FullConfig {
config.ADMIN_EMAIL = process.env.ADMIN_EMAIL
}

if (process.env.FEDERATION) {
config.FEDERATION = !(process.env.LOG_TIMESTAMP === "false")
}

if (process.env.FEDERATION_TARGETS) {
config.FEDERATION_TARGETS = process.env.FEDERATION_TARGETS.split(",").map(
(target) => ({
name: target,
url: target
})
)
}

return FullConfigSchema.parse(config)
}

Expand Down
21 changes: 21 additions & 0 deletions packages/config/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ export const ConfigFileSchema = z
LOG_TIMESTAMP: z.boolean().optional(),
LOG_LEVEL: LogLevelSchema.optional(),
ADMIN_EMAIL: z.string().email().optional(),
FEDERATION: z.boolean().optional(),
FEDERATION_TARGETS: z
.array(
z.object({
name: z.string(),
url: z.string()
}),
{
message:
"Invalid federation target. Needs am array of objects with name and url"
}
)
.optional(),
DB_CONFIG: z
.object({
host: z.string().optional(),
Expand Down Expand Up @@ -57,6 +70,14 @@ export const FullConfigSchema = z
: "info"
),
ADMIN_EMAIL: z.string().email().optional(),
FEDERATION: z.boolean(),
// TODO: move this to settings to be editable via the UI
FEDERATION_TARGETS: z.array(
z.object({
name: z.string(),
url: z.union([z.string().url(), z.string().ip()])
})
),
API_VERSION: z.enum(["v1"]).default(staticConfig.API_VERSION),
APPLICATION_NAME: z.string().default(staticConfig.APPLICATION_NAME),
PRODUCTION: z.boolean().default(process.env.NODE_ENV === "production")
Expand Down
11 changes: 10 additions & 1 deletion packages/config/src/test/default_config.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"

import defaultConfig from "@/default_config.json" with { type: "json" }
import { ConfigFileSchema } from "@/schema.js"
import { ConfigFileSchema, FullConfigSchema } from "@/schema.js"

describe("default config", () => {
it("should validate against ConfigFileSchema", () => {
Expand All @@ -12,4 +12,13 @@ describe("default config", () => {
}
expect(result.success).toBe(true)
})

it("should validate against FullConfigSchema", () => {
const result = FullConfigSchema.safeParse(defaultConfig)
// If you want to be more specific about errors when validation fails
if (!result.success) {
console.error(result.error.issues)
}
expect(result.success).toBe(true)
})
})
1 change: 1 addition & 0 deletions packages/db/src/schemas/trips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const tripsTable = pgTable("Trips", {

export const TripSchema = z.object({
id: z.string().uuid(),
origin: z.string().optional().nullable(),
from: z.string(),
to: z.string(),
departure: z.string(),
Expand Down
Loading

0 comments on commit bae0409

Please sign in to comment.