Skip to content

Commit 46cf329

Browse files
committed
implement runtime validation for parking citations response
1 parent 2f859bf commit 46cf329

6 files changed

Lines changed: 81 additions & 44 deletions

File tree

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"react-map-gl": "^8.1.0",
3434
"use-sync-external-store": "^1.6.0",
3535
"wellknown": "^0.5.0",
36+
"zod": "^3.24.2",
3637
"zustand": "^5.0.9"
3738
},
3839
"devDependencies": {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { z } from "zod";
2+
3+
/* ——————————————— Schemas ——————————————— */
4+
5+
export const PointGeometrySchema = z.object({
6+
type: z.literal("Point"),
7+
coordinates: z.tuple([z.number(), z.number()]),
8+
});
9+
10+
export const FeatureSchema = <T extends z.ZodTypeAny>(properties: T) =>
11+
z.object({
12+
type: z.literal("Feature"),
13+
geometry: PointGeometrySchema,
14+
properties,
15+
});
16+
17+
export const FeatureCollectionSchema = <T extends z.ZodTypeAny>(featureSchema: T) =>
18+
z.object({
19+
type: z.literal("FeatureCollection"),
20+
features: z.array(featureSchema),
21+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Feature, FeatureCollection, Point } from "geojson";
2+
import { z } from "zod";
3+
import { FeatureSchema, FeatureCollectionSchema } from "../geojson/geojson.schema";
4+
5+
/* ——————————————— Schemas ——————————————— */
6+
7+
export const ParkingCitationPropertiesSchema = z.object({
8+
ticket_number: z.string(),
9+
issue_date: z.string(), // ISO-like datetime string
10+
issue_time: z.string(), // HHMM as string (e.g. "846")
11+
meter_id: z.string().nullable(),
12+
marked_time: z.string(),
13+
rp_state_plate: z.string(),
14+
plate_expiry_date: z.string(), // YYYYMM
15+
vin: z.string().nullable(),
16+
make: z.string(),
17+
body_style: z.string(),
18+
color: z.string(),
19+
location: z.string(),
20+
route: z.string().nullable(),
21+
agency: z.string(),
22+
violation_code: z.string(),
23+
violation_description: z.string(),
24+
fine_amount: z.string(),
25+
agency_desc: z.string(),
26+
color_desc: z.string(),
27+
body_style_desc: z.string(),
28+
loc_lat: z.string(),
29+
loc_long: z.string(),
30+
});
31+
32+
export const ParkingCitationFeatureSchema = FeatureSchema(ParkingCitationPropertiesSchema);
33+
34+
export const ParkingCitationFeatureCollectionSchema = FeatureCollectionSchema(ParkingCitationFeatureSchema);
35+
36+
/* ——————————————— Types ——————————————— */
37+
38+
export type ParkingCitationProperties = z.infer<typeof ParkingCitationPropertiesSchema>;
39+
40+
export type ParkingCitationFeature = Feature<Point, ParkingCitationProperties>;
41+
42+
export type ParkingCitationFeatureCollection = FeatureCollection<Point, ParkingCitationProperties>;

apps/web/src/lib/socrata/parking-citations.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { multiPolygon } from "@turf/turf";
22
import _ from "lodash";
33
import { type GeoJSONGeometry, stringify } from "wellknown";
4-
import { ParkingCitationFeatureCollection, ParkingCitationProperties } from "@/types";
4+
import { ParkingCitationFeatureCollection, ParkingCitationFeatureCollectionSchema } from "./parking-citations.schema";
55

66
type FetchParkingCitationsInput = {
77
token?: string;
@@ -24,7 +24,7 @@ const ISSUE_DATE_COLUMN = "issue_date";
2424

2525
const EMPTY_PARKING_CITATION_FEATURE_COLLECTION = {
2626
type: "FeatureCollection",
27-
features: [] satisfies ParkingCitationProperties[],
27+
features: [],
2828
} satisfies ParkingCitationFeatureCollection;
2929

3030
const toSocrataDate = (date: Date) => date.toISOString().split("T")[0];
@@ -82,5 +82,13 @@ export const fetchParkingCitations = async ({
8282
return EMPTY_PARKING_CITATION_FEATURE_COLLECTION;
8383
}
8484

85-
return response.json();
85+
const json: unknown = await response.json();
86+
const parsed = ParkingCitationFeatureCollectionSchema.safeParse(json);
87+
88+
if (!parsed.success) {
89+
console.error("Invalid parking citations response", parsed.error);
90+
return EMPTY_PARKING_CITATION_FEATURE_COLLECTION;
91+
}
92+
93+
return parsed.data;
8694
};

apps/web/src/types.ts

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -13,44 +13,6 @@ export type AnyRef<T> = RefObject<T | null>;
1313
*/
1414
export type NonNullableProperties<T> = { [P in keyof T]: NonNullable<T[P]> };
1515

16-
/**
17-
* Properties for a parking citation feature
18-
*/
19-
export type ParkingCitationProperties = {
20-
ticket_number: string;
21-
issue_date: string; // ISO-like datetime string
22-
issue_time: string; // HHMM as string (e.g. "846")
23-
meter_id: string | null;
24-
marked_time: string;
25-
rp_state_plate: string;
26-
plate_expiry_date: string; // YYYYMM
27-
vin: string | null;
28-
make: string;
29-
body_style: string;
30-
color: string;
31-
location: string;
32-
route: string | null;
33-
agency: string;
34-
violation_code: string;
35-
violation_description: string;
36-
fine_amount: string; // stored as string in source data
37-
agency_desc: string;
38-
color_desc: string;
39-
body_style_desc: string;
40-
loc_lat: string;
41-
loc_long: string;
42-
};
43-
44-
/**
45-
* A single parking citation feature
46-
*/
47-
export type ParkingCitationFeature = Feature<Point, ParkingCitationProperties>;
48-
49-
/**
50-
* Collection of parking citation features
51-
*/
52-
export type ParkingCitationFeatureCollection = FeatureCollection<Point, ParkingCitationProperties>;
53-
5416
export type NeighborhoodCouncilProperties = (typeof neighborhoodCouncilCollection)["features"][0]["properties"];
5517

5618
export type NeighborhoodCouncilFeature = Feature<Polygon, NeighborhoodCouncilProperties>;

pnpm-lock.yaml

Lines changed: 6 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)