Simple API gateway for webhooks
at main 7.9 kB view raw
1import { Hono } from "hono" 2import { validator } from "hono/validator" 3import { z } from "zod" 4import { fetchWithCache, kv } from "./util.ts" 5 6const SensorsSchema = z.object({ 7 sensorData: z.array( 8 z.discriminatedUnion("sensorType", [ 9 z.object({ 10 timestamp: z.string(), 11 sensorType: z.literal("location"), 12 data: z.object({ 13 altitude: z.number(), 14 speed: z.number(), 15 horizontalAccuracy: z.number(), 16 verticalAccuracy: z.number(), 17 longitude: z.number(), 18 course: z.number(), 19 floor: z.number().nullable(), 20 latitude: z.number(), 21 }), 22 }), 23 z.object({ 24 timestamp: z.string(), 25 sensorType: z.literal("gyroscope"), 26 data: z.object({ 27 x: z.number(), 28 y: z.number(), 29 z: z.number(), 30 }), 31 }), 32 z.object({ 33 timestamp: z.string(), 34 sensorType: z.literal("battery"), 35 data: z.object({ 36 batteryState: z.string(), 37 batteryLevel: z.number(), 38 }), 39 }), 40 z.object({ 41 timestamp: z.string(), 42 sensorType: z.literal("altitude"), 43 data: z.object({ 44 altitude: z.number(), 45 }), 46 }), 47 z.object({ 48 timestamp: z.string(), 49 sensorType: z.literal("heading"), 50 data: z.object({ 51 x: z.number(), 52 y: z.number(), 53 z: z.number(), 54 trueHeading: z.number(), 55 headingAccuracy: z.number(), 56 magneticHeading: z.number(), 57 }), 58 }), 59 ]), 60 ), 61 isSuccess: z.boolean(), 62 deviceId: z.string(), 63 timestamp: z.coerce.date(), 64 deviceName: z.string(), 65}) 66 67const ReverseGeocodingSchema = z.object({ 68 type: z.literal("FeatureCollection"), 69 features: z.array( 70 z.object({ 71 type: z.literal("Feature"), 72 properties: z.object({ 73 datasource: z.object({ 74 sourcename: z.string(), 75 attribution: z.string(), 76 license: z.string(), 77 url: z.string(), 78 }).optional(), 79 name: z.string().optional(), 80 country: z.string(), 81 country_code: z.string(), 82 state: z.string().optional(), 83 city: z.string().optional(), 84 postcode: z.string().optional(), 85 district: z.string().optional(), 86 suburb: z.string().optional(), 87 street: z.string().optional(), 88 housenumber: z.string().optional(), 89 iso3166_2: z.string().optional(), 90 lon: z.number(), 91 lat: z.number(), 92 state_code: z.string().optional(), 93 distance: z.number().optional(), 94 result_type: z.string().optional(), 95 formatted: z.string(), 96 address_line1: z.string().optional(), 97 address_line2: z.string().optional(), 98 category: z.string().optional(), 99 timezone: z.object({ 100 name: z.string(), 101 offset_STD: z.string(), 102 offset_STD_seconds: z.number(), 103 offset_DST: z.string(), 104 offset_DST_seconds: z.number(), 105 abbreviation_STD: z.string(), 106 abbreviation_DST: z.string(), 107 }).optional(), 108 plus_code: z.string().optional(), 109 rank: z.object({ 110 importance: z.number(), 111 popularity: z.number(), 112 }).optional(), 113 place_id: z.string().optional(), 114 }), 115 geometry: z.object({ 116 type: z.literal("Point"), 117 coordinates: z.tuple([z.number(), z.number()]), 118 }), 119 bbox: z.array(z.number()).optional(), 120 }), 121 ), 122 query: z.object({ 123 lat: z.number(), 124 lon: z.number(), 125 plus_code: z.string().optional(), 126 }).optional(), 127}) 128 129type Sensors = z.infer<typeof SensorsSchema> 130 131type Country = { 132 country: string 133 country_code: string 134} 135 136const sensors = new Hono() 137 .get("/country", async (c) => { 138 c.header("Access-Control-Allow-Origin", "*") 139 140 // Served cached data if available 141 const cached = await kv.get<Country>(["country"]) 142 if (cached.value) { 143 console.info("Serving cached country data") 144 return c.json(cached.value) 145 } 146 147 // Fetch sensors data from KV 148 const data = await kv.get<Sensors>(["sensors", "latest"]) 149 if (!data.value) { 150 return c.text("No data found", 404) 151 } 152 153 // Extract location from sensors data 154 const location = data.value.sensorData.find((sensor) => 155 sensor.sensorType === "location" 156 )?.data 157 158 if (!location) { 159 return c.text("No location data found", 404) 160 } 161 162 // Extract geocode from location data 163 const geocode = await fetchWithCache<Country>( 164 `https://api.geoapify.com/v1/geocode/reverse?lat=${location.latitude}&lon=${location.longitude}&apiKey=${ 165 Deno.env.get("GEOAPIFY_API_KEY") 166 }`, 167 ) 168 169 if (!geocode.success) { 170 console.error(geocode.error) 171 return c.text("Geoapify API error", 404) 172 } 173 174 const country = ReverseGeocodingSchema.safeParse(geocode.value) 175 176 if (!country.success) { 177 console.error(country.error) 178 return c.text("Invalid country data", 400) 179 } 180 181 const letterA = "a".codePointAt(0) as number 182 //biome-ignore format: breaks emoji 183 const regionalIndicatorA = "🇦".codePointAt(0) as number 184 185 const toRegionalIndicator = (char: string) => 186 String.fromCodePoint( 187 char.codePointAt(0) as number - letterA + regionalIndicatorA, 188 ) 189 190 const emoji = country.data.features[0].properties.country_code 191 .split("") 192 .map((char) => toRegionalIndicator(char)) 193 .join("") 194 195 const ret = { 196 country: country.data.features[0].properties.country, 197 country_code: country.data.features[0].properties.country_code, 198 country_flag: emoji, 199 } 200 201 // Cache country data to KV store (expiry set for 2 hours) 202 await kv.set(["country"], ret satisfies Country, { expireIn: 60 * 60 * 2 }) 203 204 return c.json(ret) 205 }) 206 .get("/get", async (c) => { 207 const data = await kv.get<Sensors>(["sensors", "latest"]) 208 if (!data.value) { 209 return c.text("No data found", 404) 210 } 211 return c.json(data.value) 212 }) 213 .post( 214 "/set", 215 validator("json", (value, c) => { 216 const parsed = SensorsSchema.safeParse(value) 217 if (!parsed.success) { 218 return c.text("Invalid!", 401) 219 } 220 return parsed.data 221 }), 222 async (c) => { 223 const body = c.req.valid("json") 224 console.log(`Receiving sensor data at ${body.timestamp.toISOString()}`) 225 226 // save data to kv store 227 await kv.set(["sensors", body.timestamp.toISOString()], body) 228 await kv.set(["sensors", "latest"], body) 229 230 return c.json(body) 231 }, 232 ) 233 234export { sensors }