From 15050ae51611280443cfa66c1c804998e8e8924f Mon Sep 17 00:00:00 2001 From: Alex Martin Date: Thu, 3 Jul 2025 17:21:52 -0700 Subject: [PATCH] New API endpoints for the Claim/transfer flow --- .../claims/[claimId]/complete/route.ts | 20 ++++++++ .../claims/[claimId]/route.ts | 45 ++++++++++++++++ .../claims/[claimId]/verify/route.ts | 20 ++++++++ .../[installationId]/claims/route.ts | 41 +++++++++++++++ .../[installationId]/claims/utils.ts | 51 +++++++++++++++++++ 5 files changed, 177 insertions(+) create mode 100644 app/v1/installations/[installationId]/claims/[claimId]/complete/route.ts create mode 100644 app/v1/installations/[installationId]/claims/[claimId]/route.ts create mode 100644 app/v1/installations/[installationId]/claims/[claimId]/verify/route.ts create mode 100644 app/v1/installations/[installationId]/claims/route.ts create mode 100644 app/v1/installations/[installationId]/claims/utils.ts diff --git a/app/v1/installations/[installationId]/claims/[claimId]/complete/route.ts b/app/v1/installations/[installationId]/claims/[claimId]/complete/route.ts new file mode 100644 index 0000000..20c3d02 --- /dev/null +++ b/app/v1/installations/[installationId]/claims/[claimId]/complete/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { inMemoryClaims, validateClaimId, validateInstallationId } from '../../utils'; + +export async function POST(_: NextRequest, { params }: { params: Promise<{ installationId: string, claimId: string }> }) { + const { installationId, claimId } = await params; + + if (!validateInstallationId(installationId) || !validateClaimId(claimId)) { + return NextResponse.json({ description: 'Operation failed because of a conflict with the current state of the resource' }, { status: 409 }); + } + + const matchingClaim = inMemoryClaims.find(c => (c.claimId == claimId && c.installationId == installationId)); + + if (!matchingClaim) { + return NextResponse.json({ description: 'Claim not found' }, { status: 404 }); + } + + matchingClaim.status = 'complete'; + + return NextResponse.json({ description: 'Claim completed successfully' }, { status: 204 }); +} diff --git a/app/v1/installations/[installationId]/claims/[claimId]/route.ts b/app/v1/installations/[installationId]/claims/[claimId]/route.ts new file mode 100644 index 0000000..4e337a8 --- /dev/null +++ b/app/v1/installations/[installationId]/claims/[claimId]/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { Claim, generateId, inMemoryClaims, validateInstallationId, validateNewClaimBodyId } from '../utils'; + +// NOTE - overload #2 to create a claim - this route gets the claimID from the path +export async function POST(request: NextRequest, { params }: { params: Promise<{ installationId: string, claimId: string }> }) { + const { installationId, claimId } = await params; + const data = await request.json(); + + const matchingClaim = inMemoryClaims.find(c => (c.claimId == claimId && c.installationId == installationId)); + + if (matchingClaim) { + return NextResponse.json({ description: 'Operation failed because of a conflict with the current state of the resource' }, { status: 409 }); + } + + if (!validateInstallationId(installationId) || !validateNewClaimBodyId(data)) { + return NextResponse.json({ description: 'Input has failed validation' }, { status: 400 }); + } + + var expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + 7); + const newClaim: Claim = { + claimId: generateId('claim', 20), + installationId, + status: 'unclaimed', + sourceInstallationId: data.sourceInstallationId, + expiration: data.expiration, + resourceIds: data.resourceIds, + } + inMemoryClaims.push(newClaim); + + return NextResponse.json({ description: 'Claim created successfully' }); +} + +// NOTE - this GET is not part of the spec but makes it much easier to test +export async function GET(_: NextRequest, { params }: { params: Promise<{ installationId: string, claimId: string }> }) { + const { installationId, claimId } = await params; + + const matchingClaim = inMemoryClaims.find(c => (c.claimId == claimId && c.installationId == installationId)); + + if (matchingClaim) { + return NextResponse.json(matchingClaim); + } + + return NextResponse.json({ description: 'Claim not found' }, { status: 404 }); +} diff --git a/app/v1/installations/[installationId]/claims/[claimId]/verify/route.ts b/app/v1/installations/[installationId]/claims/[claimId]/verify/route.ts new file mode 100644 index 0000000..a4ebc0a --- /dev/null +++ b/app/v1/installations/[installationId]/claims/[claimId]/verify/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { inMemoryClaims, validateClaimId, validateInstallationId } from '../../utils'; + +export async function POST(_: NextRequest, { params }: { params: Promise<{ installationId: string, claimId: string }> }) { + const { installationId, claimId } = await params; + + if (!validateInstallationId(installationId) || !validateClaimId(claimId)) { + return NextResponse.json({ description: 'Operation failed because of a conflict with the current state of the resource' }, { status: 409 }); + } + + const matchingClaim = inMemoryClaims.find(c => (c.claimId == claimId && c.installationId == installationId)); + + if (!matchingClaim) { + return NextResponse.json({ description: 'Claim not found' }, { status: 404 }); + } + + matchingClaim.status = 'verified'; + + return NextResponse.json({ description: 'Claim verified successfully' }); +} diff --git a/app/v1/installations/[installationId]/claims/route.ts b/app/v1/installations/[installationId]/claims/route.ts new file mode 100644 index 0000000..e423652 --- /dev/null +++ b/app/v1/installations/[installationId]/claims/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { Claim, generateId, inMemoryClaims, validateClaimId, validateInstallationId, validateNewClaimBodyId } from './utils'; + +// NOTE - overload #1 to create a claim - this route doesn't have a claimID in the path and gets it from the request body +export async function POST(request: NextRequest, { params }: { params: Promise<{ installationId: string }> }) { + const { installationId } = await params; + const data = await request.json(); + + if (!validateInstallationId(installationId) || !validateNewClaimBodyId(data) || !validateClaimIdInBody(data)) { + return NextResponse.json({ description: 'Operation failed because of a conflict with the current state of the resource' }, { status: 409 }); + } + + var expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + 7); + + const newClaim: Claim = { + claimId: data.claimId, + installationId, + status: 'unclaimed', + sourceInstallationId: generateId('ifcg', 20), + expiration: expirationDate.getTime(), + resourceIds: [ generateId('res', 20) ] + }; + inMemoryClaims.push(newClaim); + + return NextResponse.json({ description: 'Claim created successfully', claim: newClaim }); +} + +// NOTE - this GET is not part of the spec but makes it much easier to test +export async function GET(_: NextRequest, { params }: { params: Promise<{ installationId: string }> }) { + const { installationId } = await params; + + return NextResponse.json({ claims: inMemoryClaims }); +} + +function validateClaimIdInBody(data: any): boolean { + if (!Object.hasOwn(data, 'claimId')) return false; + if (!validateClaimId(data.claimId)) return false; + + return true; +} \ No newline at end of file diff --git a/app/v1/installations/[installationId]/claims/utils.ts b/app/v1/installations/[installationId]/claims/utils.ts new file mode 100644 index 0000000..a2a3ac8 --- /dev/null +++ b/app/v1/installations/[installationId]/claims/utils.ts @@ -0,0 +1,51 @@ +export const inMemoryClaims: Claim[] = []; + +export interface Claim { + claimId: string, + installationId: string, + status: 'unclaimed' | 'verified' | 'complete', + sourceInstallationId: string, + resourceIds: string[], + expiration: number, +} + +export function generateId(prefix: string, length: number): string { + var result = `${prefix}_`; + var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + var charactersLength = characters.length; + for ( var i = 0; i < length; i++ ) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + } + return result; +} + +export function validateInstallationId(installationId: string): boolean { + if (installationId.length < 10) return false; + return installationId.startsWith('ifcg_'); +} + +export function validateClaimId(installationId: string): boolean { + if (installationId.length < 10) return false; + return installationId.startsWith('claim_'); +} + +function validateResourceId(installationId: string): boolean { + if (installationId.length < 10) return false; + return installationId.startsWith('ir_'); +} + +export function validateNewClaimBodyId(data: any): boolean { + if (!Object.hasOwn(data, 'sourceInstallationId')) return false; + if (!validateInstallationId(data.sourceInstallationId)) return false; + + if (!Object.hasOwn(data, 'resourceIds')) return false; + if (!Array.isArray(data.resourceIds)) return false; + data.resourceIds.forEach((res: any) => { + if (!validateResourceId(`${res}`)) return false; + }); + + if (!Object.hasOwn(data, 'expiration')) return false; + if (data.expiration <= 0) return false; + + return true; +}