diff --git a/app/models/device.server.ts b/app/models/device.server.ts index 32c0c60b..eb8b4c9e 100644 --- a/app/models/device.server.ts +++ b/app/models/device.server.ts @@ -1,8 +1,8 @@ import { point } from '@turf/helpers' -import { eq, sql, desc, ilike, arrayContains, and } from 'drizzle-orm' +import { eq, sql, desc, ilike, arrayContains, and, between } from 'drizzle-orm' import { type Point } from 'geojson' import { drizzleClient } from '~/db.server' -import { device, location, sensor, type Device, type Sensor } from '~/schema' +import { device, deviceToLocation, location, sensor, type Device, type Sensor } from '~/schema' const BASE_DEVICE_COLUMNS = { id: true, @@ -77,6 +77,23 @@ export function getDevice({ id }: Pick) { }) } +export function getLocations({ id }: Pick, fromDate: Date, toDate: Date) { + return drizzleClient + .select({ + time: deviceToLocation.time, + x: sql`ST_X(${location.location})`.as('x'), + y: sql`ST_Y(${location.location})`.as('y'), + }) + .from(location) + .innerJoin(deviceToLocation, eq(deviceToLocation.locationId, location.id)) + .where( + and( + eq(deviceToLocation.deviceId, id), + between(deviceToLocation.time, fromDate, toDate) + ) + ) + .orderBy(desc(deviceToLocation.time)); +} export function getDeviceWithoutSensors({ id }: Pick) { return drizzleClient.query.device.findFirst({ where: (device, { eq }) => eq(device.id, id), diff --git a/app/routes/api.boxes.$deviceId.data.$sensorId.ts b/app/routes/api.boxes.$deviceId.data.$sensorId.ts index d5f67ae8..3fd1b316 100644 --- a/app/routes/api.boxes.$deviceId.data.$sensorId.ts +++ b/app/routes/api.boxes.$deviceId.data.$sensorId.ts @@ -4,13 +4,7 @@ import { getMeasurements } from "~/models/sensor.server"; import { type Measurement } from "~/schema"; import { convertToCsv } from "~/utils/csv"; import { parseDateParam, parseEnumParam } from "~/utils/param-utils"; - -const badRequestInit = { - status: 400, - headers: { - "Content-Type": "application/json; charset=utf-8", - }, -}; +import { badRequest, internalServerError, notFound } from "~/utils/response-utils"; /** * @openapi @@ -97,6 +91,51 @@ const badRequestInit = { * 200: * description: Success * content: + * application/json: + * schema: + * type: array + * example: '[{"sensor_id":"6649b23072c4c40007105953","time":"2025-11-06 23:59:57.189+00","value":4.78,"location_id":"5752066"},{"sensor_id":"6649b23072c4c40007105953","time":"2025-11-06 23:57:06.03+00","value":4.13,"location_id":"5752066"}]' + * text/csv: + * example: "createdAt,value + * 2023-09-29T08:06:13.254Z,6.38 + * 2023-09-29T08:06:12.312Z,6.38 + * 2023-09-29T08:06:11.513Z,6.38 + * 2023-09-29T08:06:10.380Z,6.38 + * 2023-09-29T08:06:09.569Z,6.38 + * 2023-09-29T08:06:05.967Z,6.38" + * 400: + * description: Bad Request + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * message: + * type: string + * 404: + * description: Not found + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * message: + * type: string + * 500: + * description: Internal Server Error + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * message: + * type: string */ export const loader: LoaderFunction = async ({ @@ -113,12 +152,7 @@ export const loader: LoaderFunction = async ({ let meas: Measurement[] | TransformedMeasurement[] = await getMeasurements(sensorId, fromDate.toISOString(), toDate.toISOString()); if (meas == null) - return new Response(JSON.stringify({ message: "Device not found." }), { - status: 404, - headers: { - "content-type": "application/json; charset=utf-8", - }, - }); + return notFound("Device not found."); if (outliers) meas = transformOutliers(meas, outlierWindow, outliers == "replace"); @@ -129,7 +163,7 @@ export const loader: LoaderFunction = async ({ if (download) headers["Content-Disposition"] = `attachment; filename=${sensorId}.${format}`; - let responseInit: ResponseInit = { + const responseInit: ResponseInit = { status: 200, headers: headers, }; @@ -143,19 +177,7 @@ export const loader: LoaderFunction = async ({ } catch (err) { console.warn(err); - return Response.json( - { - error: "Internal Server Error", - message: - "The server was unable to complete your request. Please try again later.", - }, - { - status: 500, - headers: { - "Content-Type": "application/json; charset=utf-8", - }, - }, - ); + return internalServerError(); } }; @@ -174,20 +196,10 @@ function collectParameters(request: Request, params: Params): // deviceId is there for legacy reasons const deviceId = params.deviceId; if (deviceId === undefined) - return Response.json( - { - code: "Bad Request", - message: "Invalid device id specified", - }, badRequestInit - ); + return badRequest("Invalid device id specified"); const sensorId = params.sensorId; if (sensorId === undefined) - return Response.json( - { - code: "Bad Request", - message: "Invalid sensor id specified", - }, badRequestInit - ); + return badRequest("Invalid sensor id specified"); const url = new URL(request.url); @@ -199,12 +211,7 @@ function collectParameters(request: Request, params: Params): let outlierWindow: number = 15; if (outlierWindowParam !== null) { if (Number.isNaN(outlierWindowParam) || Number(outlierWindowParam) < 1 || Number(outlierWindowParam) > 50) - return Response.json( - { - error: "Bad Request", - message: "Illegal value for parameter outlier-window. Allowed values: numbers between 1 and 50", - }, badRequestInit - ); + return badRequest("Illegal value for parameter outlier-window. Allowed values: numbers between 1 and 50"); outlierWindow = Number(outlierWindowParam); } diff --git a/app/routes/api.boxes.$deviceId.locations.ts b/app/routes/api.boxes.$deviceId.locations.ts new file mode 100644 index 00000000..4a6f6c90 --- /dev/null +++ b/app/routes/api.boxes.$deviceId.locations.ts @@ -0,0 +1,178 @@ +import { type Params, type LoaderFunction, type LoaderFunctionArgs } from "react-router"; +import { getLocations } from "~/models/device.server"; +import { parseDateParam, parseEnumParam } from "~/utils/param-utils"; +import { badRequest, internalServerError, notFound } from "~/utils/response-utils"; + +/** + * @openapi + * /boxes/{deviceId}/locations: + * get: + * tags: + * - Boxes + * summary: Get locations of a senseBox + * description: Get all locations of the specified senseBox ordered by date as an array of GeoJSON Points. + * If `format=geojson`, a GeoJSON linestring will be returned, with `properties.timestamps` + * being an array with the timestamp for each coordinate. + * parameters: + * - in: path + * name: deviceId + * required: true + * schema: + * type: string + * description: the ID of the senseBox you are referring to + * - in: query + * name: from-date + * required: false + * schema: + * type: string + * description: RFC3339Date + * format: date-time + * description: "Beginning date of measurement data (default: 48 hours ago from now)" + * - in: query + * name: to-date + * required: false + * schema: + * type: string + * descrption: TFC3339Date + * format: date-time + * description: "End date of measurement data (default: now)" + * - in: query + * name: format + * required: false + * schema: + * type: string + * enum: + * - json + * - geojson + * default: json + * description: "Can be 'json' (default) or 'geojson' (default: json)" + * responses: + * 200: + * description: Success + * content: + * application/json: + * schema: + * type: array + * example: '[{ "coordinates": [7.68123, 51.9123], "type": "Point", "timestamp": "2017-07-27T12:00.000Z"},{ "coordinates": [7.68223, 51.9433, 66.6], "type": "Point", "timestamp": "2017-07-27T12:01.000Z"},{ "coordinates": [7.68323, 51.9423], "type": "Point", "timestamp": "2017-07-27T12:02.000Z"}]' + * application/geojson: + * example: '' + * 400: + * description: Bad Request + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * message: + * type: string + * 404: + * description: Not found + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * message: + * type: string + * 500: + * description: Internal Server Error + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * message: + * type: string + */ + +export const loader: LoaderFunction = async ({ + request, + params, +}: LoaderFunctionArgs): Promise => { + try { + + const collected = collectParameters(request, params); + if (collected instanceof Response) + return collected; + const {deviceId, fromDate, toDate, format} = collected; + + const locations = await getLocations({ id: deviceId}, fromDate, toDate); + if (!locations) + return notFound("Device not found"); + + const jsonLocations = locations.map((location) => { + return { + coordinates: [location.x, location.y], + type: 'Point', + timestamp: location.time, + } + }); + + let headers: HeadersInit = { + "content-type": format == "json" ? "application/json; charset=utf-8" : "application/geo+json; charset=utf-8", + }; + + const responseInit: ResponseInit = { + status: 200, + headers: headers, + }; + + if (format == "json") + return Response.json(jsonLocations, responseInit); + else { + const geoJsonLocations = { + type: 'Feature', + geometry: { + type: 'LineString', coordinates: jsonLocations.map(location => location.coordinates) + }, + properties: { + timestamps: jsonLocations.map(location => location.timestamp) + } + }; + return Response.json(geoJsonLocations, responseInit) + } + + } catch (err) { + console.warn(err); + return internalServerError(); + } +}; + +function collectParameters(request: Request, params: Params): + Response | { + deviceId: string, + fromDate: Date, + toDate: Date, + format: string | null + } { + const deviceId = params.deviceId; + if (deviceId === undefined) + return badRequest("Invalid device id specified"); + + const url = new URL(request.url); + + const fromDate = parseDateParam(url, "from-date", new Date(new Date().setDate(new Date().getDate() - 2))) + if (fromDate instanceof Response) + return fromDate + + const toDate = parseDateParam(url, "to-date", new Date()) + if (toDate instanceof Response) + return toDate + + const format = parseEnumParam(url, "format", ["json", "geojson"], "json"); + if (format instanceof Response) + return format + + return { + deviceId, + fromDate, + toDate, + format + }; +} \ No newline at end of file diff --git a/app/routes/api.device.$deviceId.ts b/app/routes/api.device.$deviceId.ts index edcef08c..af9c896d 100644 --- a/app/routes/api.device.$deviceId.ts +++ b/app/routes/api.device.$deviceId.ts @@ -48,7 +48,7 @@ import { getDevice } from "~/models/device.server"; * error: * type: string * example: "Device not found" - * 400: + * 400: * description: Device ID is required * content: * application/json: diff --git a/app/utils/param-utils.ts b/app/utils/param-utils.ts index aba243db..43a6366e 100644 --- a/app/utils/param-utils.ts +++ b/app/utils/param-utils.ts @@ -1,9 +1,4 @@ -const badRequestInit = { - status: 400, - headers: { - "Content-Type": "application/json; charset=utf-8", - }, -}; +import { badRequest } from "./response-utils"; /** * Parses a parameter from the url search paramaters into a date. @@ -19,12 +14,7 @@ export function parseDateParam(url: URL, paramName: string, defaultDate: Date): if (param) { const date = new Date(param) if (Number.isNaN(date.valueOf())) - return Response.json( - { - error: "Bad Request", - message: `Illegal value for parameter ${paramName}. Allowed values: RFC3339Date`, - }, badRequestInit - ); + return badRequest(`Illegal value for parameter ${paramName}. Allowed values: RFC3339Date`); return date } return defaultDate; @@ -44,12 +34,7 @@ export function parseEnumParam(url: URL, paramName: string, allowed const param = url.searchParams.get(paramName); if (param) { if (!allowedValues.includes(param)) - return Response.json( - { - error: "Bad Request", - message: `Illegal value for parameter ${paramName}. Allowed values: ${allowedValues}`, - }, badRequestInit - ); + return badRequest(`Illegal value for parameter ${paramName}. Allowed values: ${allowedValues}`); return param; } return defaultValue diff --git a/app/utils/response-utils.ts b/app/utils/response-utils.ts new file mode 100644 index 00000000..8aeb121e --- /dev/null +++ b/app/utils/response-utils.ts @@ -0,0 +1,62 @@ +/** + * Creates a response object for a bad request + * @param message The message for the response + * @returns The response + */ +export function badRequest(message: string): Response { + return Response.json( + { + error: 'Bad Request', + message: message, + }, + { + status: 400, + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + }, + ) +} + +/** + * Creates a response object for an internal server error + * @param message The message for the response. Default: + * The server was unable to complete your request. Please try again later. + * @returns The response + */ +export function internalServerError(message = + "The server was unable to complete your request. Please try again later."): Response { + Response.error() + return Response.json( + { + error: "Internal Server Error", + message: message + }, + { + status: 500, + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + }, + ); +} + +/** + * Creates a response object for a 404 + * @param message The message for the response + * @returns The response + */ +export function notFound(message: string): Response { + return Response.json( + { + error: 'Not found', + message: message, + }, + { + status: 404, + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + }, + ) +} diff --git a/tests/routes/api.boxes.$deviceId.locations.spec.ts b/tests/routes/api.boxes.$deviceId.locations.spec.ts new file mode 100644 index 00000000..2685f6ee --- /dev/null +++ b/tests/routes/api.boxes.$deviceId.locations.spec.ts @@ -0,0 +1,183 @@ +import { type Params, type LoaderFunctionArgs } from "react-router"; +import { BASE_URL } from "vitest.setup"; +import { registerUser } from "~/lib/user-service.server"; +import { createDevice, deleteDevice } from "~/models/device.server"; +import { deleteMeasurementsForSensor, deleteMeasurementsForTime, saveMeasurements } from "~/models/measurement.server"; +import { getSensors } from "~/models/sensor.server"; +import { deleteUserByEmail } from "~/models/user.server"; +import { loader } from "~/routes/api.boxes.$deviceId.locations"; +import { type Sensor, type Device, type User } from "~/schema"; + +const DEVICE_SENSORS_ID_USER = { + name: "meTestSensorsIds", + email: "test@box.sensorids", + password: "highlySecurePasswordForTesting", +}; + +const DEVICE_SENSOR_ID_BOX = { + name: `${DEVICE_SENSORS_ID_USER}s Box`, + exposure: "outdoor", + expiresAt: null, + tags: [], + latitude: 0, + longitude: 0, + model: "luftdaten.info", + mqttEnabled: false, + ttnEnabled: false, + sensors: [ + { + title: "Temp", + unit: "°C", + sensorType: "dummy", + }, + { + title: "CO2", + unit: "mol/L", + sensorType: "dummy", + }, + { + title: "Air Pressure", + unit: "kPa", + sensorType: "dummy", + }, + ], +}; + +const MEASUREMENTS = [ + { + value: 1589625, + createdAt: new Date('1954-06-07 12:00:00+00'), + sensor_id: "", + location: { + lng: 1, + lat: 2, + height: 3 + } + }, + { + value: 3.14159, + createdAt: new Date('1988-03-14 1:59:26+00'), + sensor_id: "", + location: { + lng: 4, + lat: 5, + } + }, + { + value: 0, + createdAt: new Date('2000-05-25 11:11:11+00'), + sensor_id: "", + location: { + lng: 6, + lat: 7, + height: 8 + } + } +] + +describe("openSenseMap API Routes: /api/boxes/:deviceId/locations", () => { + let device: Device; + let deviceId: string = ""; + let sensors: Sensor[]; + + beforeAll(async () => { + const user = await registerUser( + DEVICE_SENSORS_ID_USER.name, + DEVICE_SENSORS_ID_USER.email, + DEVICE_SENSORS_ID_USER.password, + "en_US", + ); + + device = await createDevice(DEVICE_SENSOR_ID_BOX, (user as User).id); + deviceId = device.id; + sensors = await getSensors(deviceId); + + MEASUREMENTS.forEach(meas => meas.sensor_id = sensors[0].id) + await saveMeasurements(device, MEASUREMENTS); + }); + + describe("GET", () => { + it("should return locations of a box in json format", async () => { + // Arrange + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/locations?from-date=${new Date('1954-06-07 11:00:00+00')}&to-date=${new Date('1988-03-14 1:59:27+00')}`, + { method: "GET" }, + ); + + // Act + const dataFunctionValue = await loader({ + request, + params: { + deviceId: `${deviceId}` + } as Params, + } as LoaderFunctionArgs); // Assuming a separate loader for single sensor + const response = dataFunctionValue as Response; + const body = await response?.json(); + + // Assert + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json; charset=utf-8", + ); + expect(body).toHaveLength(2); + expect(body[0].coordinates).toHaveLength(2); + expect(body[1].coordinates).toHaveLength(2); + expect(body[0].coordinates[0]).toBe(4); + expect(body[0].coordinates[1]).toBe(5); + expect(body[1].coordinates[0]).toBe(1); + expect(body[1].coordinates[1]).toBe(2); + expect(body[0].type).toBe("Point"); + expect(body[1].type).toBe("Point"); + expect(body[0].timestamp).toBe('1988-03-14T01:59:26.000Z'); + expect(body[1].timestamp).toBe('1954-06-07T12:00:00.000Z'); + }); + + it("should return locations of a box in geojson format", async () => { + // Arrange + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/locations?from-date=${new Date('1954-06-07 11:00:00+00')}&to-date=${new Date('1988-03-14 1:59:27+00')}&format=geojson`, + { method: "GET" }, + ); + + // Act + const dataFunctionValue = await loader({ + request, + params: { + deviceId: `${deviceId}` + } as Params, + } as LoaderFunctionArgs); // Assuming a separate loader for single sensor + const response = dataFunctionValue as Response; + const body = await response?.json(); + + // Assert + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/geo+json; charset=utf-8", + ); + expect(body.type).toBe("Feature"); + expect(body.geometry.type).toBe("LineString"); + expect(body.geometry.coordinates).toHaveLength(2); + expect(body.geometry.coordinates[0]).toHaveLength(2); + expect(body.geometry.coordinates[1]).toHaveLength(2); + expect(body.geometry.coordinates[0][0]).toBe(4); + expect(body.geometry.coordinates[0][1]).toBe(5); + expect(body.geometry.coordinates[1][0]).toBe(1); + expect(body.geometry.coordinates[1][1]).toBe(2); + expect(body.properties.timestamps).toHaveLength(2); + expect(body.properties.timestamps[0]).toBe('1988-03-14T01:59:26.000Z'); + expect(body.properties.timestamps[1]).toBe('1954-06-07T12:00:00.000Z'); + }); + }); + + afterAll(async () => { + //delete measurements + if (sensors?.length > 0) { + await deleteMeasurementsForSensor(sensors[0].id); + MEASUREMENTS.forEach(async (measurement) => await deleteMeasurementsForTime(measurement.createdAt)); + } + // delete the valid test user + await deleteUserByEmail(DEVICE_SENSORS_ID_USER.email); + // delete the box + await deleteDevice({ id: deviceId }); + }); +});