|
| 1 | +/** |
| 2 | + * Diagnostic endpoint for the Scottish Water API integration. |
| 3 | + * |
| 4 | + * Accessible at /api/debug-scottish-water — returns a structured JSON report of |
| 5 | + * every step in the fetch → parse → validate pipeline so failures can be identified |
| 6 | + * without needing access to a browser console. |
| 7 | + */ |
| 8 | + |
| 9 | +import { z } from 'zod'; |
| 10 | + |
| 11 | +export const config = { |
| 12 | + runtime: 'edge', |
| 13 | +}; |
| 14 | + |
| 15 | +const SCOTTISH_WATER_API_URL = |
| 16 | + 'https://api.scottishwater.co.uk/overflow-event-monitoring/v1/near-real-time'; |
| 17 | + |
| 18 | +// Mirror of the schema in src/utils/discharge/schemas.ts |
| 19 | +const emptyStringToNull = (val: unknown) => (val === '' ? null : val); |
| 20 | + |
| 21 | +const scottishWaterApiResultSchema = z.object({ |
| 22 | + ASSET_ID: z.string().max(256).nullable(), |
| 23 | + ASSET_NAME: z.string().max(256).nullable(), |
| 24 | + OVERFLOW_STATUS_ID: z.preprocess(emptyStringToNull, z.coerce.number().nullable()), |
| 25 | + RECEIVING_WATER: z.string().max(256).nullable(), |
| 26 | + OVERFLOW_START_DATETIME: z.string().nullable(), |
| 27 | + OVERFLOW_END_DATETIME: z.string().nullable(), |
| 28 | + DISCHARGE_OVERFLOW_LOCATION_LATITUDE: z.preprocess( |
| 29 | + emptyStringToNull, |
| 30 | + z.coerce.number().nullable(), |
| 31 | + ), |
| 32 | + DISCHARGE_OVERFLOW_LOCATION_LONGITUDE: z.preprocess( |
| 33 | + emptyStringToNull, |
| 34 | + z.coerce.number().nullable(), |
| 35 | + ), |
| 36 | +}); |
| 37 | + |
| 38 | +const scottishWaterApiResponseSchema = z.object({ |
| 39 | + results: z.array(scottishWaterApiResultSchema), |
| 40 | +}); |
| 41 | + |
| 42 | +export default async function handler(): Promise<Response> { |
| 43 | + const report: Record<string, unknown> = {}; |
| 44 | + |
| 45 | + // ── Step 1: fetch ──────────────────────────────────────────────────────── |
| 46 | + let rawText: string; |
| 47 | + try { |
| 48 | + const response = await fetch(SCOTTISH_WATER_API_URL); |
| 49 | + report['step1_fetch'] = { |
| 50 | + ok: response.ok, |
| 51 | + status: response.status, |
| 52 | + statusText: response.statusText, |
| 53 | + contentType: response.headers.get('content-type'), |
| 54 | + }; |
| 55 | + |
| 56 | + if (!response.ok) { |
| 57 | + return json({ ...report, conclusion: `FAILED at step 1: HTTP ${response.status}` }); |
| 58 | + } |
| 59 | + |
| 60 | + rawText = await response.text(); |
| 61 | + report['step2_raw_text'] = { |
| 62 | + byteLength: rawText.length, |
| 63 | + preview: rawText.slice(0, 500), |
| 64 | + }; |
| 65 | + } catch (err) { |
| 66 | + report['step1_fetch'] = { |
| 67 | + ok: false, |
| 68 | + error: String(err), |
| 69 | + hint: 'fetch() threw — likely a network or CORS error from the edge function', |
| 70 | + }; |
| 71 | + return json({ ...report, conclusion: 'FAILED at step 1: fetch threw an exception' }); |
| 72 | + } |
| 73 | + |
| 74 | + // ── Step 2: JSON parse ─────────────────────────────────────────────────── |
| 75 | + let parsed: unknown; |
| 76 | + try { |
| 77 | + parsed = JSON.parse(rawText); |
| 78 | + report['step3_json_parse'] = { ok: true }; |
| 79 | + } catch (err) { |
| 80 | + report['step3_json_parse'] = { ok: false, error: String(err) }; |
| 81 | + return json({ ...report, conclusion: 'FAILED at step 3: response is not valid JSON' }); |
| 82 | + } |
| 83 | + |
| 84 | + // ── Step 3: top-level shape ────────────────────────────────────────────── |
| 85 | + const topLevelKeys = |
| 86 | + parsed !== null && typeof parsed === 'object' ? Object.keys(parsed as object) : []; |
| 87 | + report['step4_top_level_keys'] = topLevelKeys; |
| 88 | + |
| 89 | + // ── Step 4: schema validation ──────────────────────────────────────────── |
| 90 | + const schemaResult = scottishWaterApiResponseSchema.safeParse(parsed); |
| 91 | + |
| 92 | + if (!schemaResult.success) { |
| 93 | + report['step5_schema_validation'] = { |
| 94 | + ok: false, |
| 95 | + errorCount: schemaResult.error.issues.length, |
| 96 | + // Show up to 10 issues to keep the response readable |
| 97 | + issues: schemaResult.error.issues.slice(0, 10), |
| 98 | + }; |
| 99 | + return json({ ...report, conclusion: 'FAILED at step 5: Zod schema validation failed' }); |
| 100 | + } |
| 101 | + |
| 102 | + const { results } = schemaResult.data; |
| 103 | + report['step5_schema_validation'] = { ok: true, totalRecords: results.length }; |
| 104 | + |
| 105 | + // ── Step 5: coordinate filter ──────────────────────────────────────────── |
| 106 | + const withCoords = results.filter( |
| 107 | + (r) => |
| 108 | + r.DISCHARGE_OVERFLOW_LOCATION_LATITUDE != null && |
| 109 | + r.DISCHARGE_OVERFLOW_LOCATION_LONGITUDE != null, |
| 110 | + ); |
| 111 | + const withoutCoords = results.length - withCoords.length; |
| 112 | + |
| 113 | + report['step6_coordinate_filter'] = { |
| 114 | + recordsWithCoords: withCoords.length, |
| 115 | + recordsDropped: withoutCoords, |
| 116 | + }; |
| 117 | + |
| 118 | + if (withCoords.length === 0) { |
| 119 | + return json({ ...report, conclusion: 'FAILED at step 6: no records have valid coordinates' }); |
| 120 | + } |
| 121 | + |
| 122 | + // ── Step 6: sample parsed records ─────────────────────────────────────── |
| 123 | + report['step7_sample_records'] = withCoords.slice(0, 3); |
| 124 | + |
| 125 | + return json({ ...report, conclusion: 'SUCCESS: all steps passed' }); |
| 126 | +} |
| 127 | + |
| 128 | +function json(data: unknown): Response { |
| 129 | + return new Response(JSON.stringify(data, null, 2), { |
| 130 | + headers: { 'Content-Type': 'application/json' }, |
| 131 | + }); |
| 132 | +} |
0 commit comments