diff --git a/README.md b/README.md index 4464326..dcf5c5a 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,6 @@ To run locally: In the web folder: `npm start` In the api folder: `npm start` and `npm run watch` in separate terminals +In the temp folder: `azurite --location .\azurite --debug azurite-debug.log` + +Use Azure Storage Explorer to inspect local blob storage \ No newline at end of file diff --git a/api/airport/function.json b/api/airport/function.json index 5b9cd6b..e2678c2 100644 --- a/api/airport/function.json +++ b/api/airport/function.json @@ -6,8 +6,7 @@ "direction": "in", "name": "req", "methods": [ - "get", - "post" + "get" ] }, { diff --git a/api/flightplan/flightplan.ts b/api/flightplan/flightplan.ts new file mode 100644 index 0000000..9dc953e --- /dev/null +++ b/api/flightplan/flightplan.ts @@ -0,0 +1,70 @@ +import Ajv from 'ajv'; +import addFormats from 'ajv-formats'; + +const flightPlanSchema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "departureTime": { + "type": "string", + "format": "date-time" + }, + "aircraft": { + "type": "string" + }, + "aircraftType": { + "type": "string" + }, + "segments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "route": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 2 + }, + "altitude": { + "type": "number", + "minimum": 1, + "maximum": 60000 + }, + "remarks": { + "type": "string" + } + }, + "required": ["route", "altitude"] + } + } + }, + "required": ["departureTime", "aircraft", "aircraftType", "segments"] +}; + +export interface ISegment { + route: string[]; + altitude: number; + remarks?: string; +} + +export interface IFlightPlan { + id?: string; + departureTime: string; + aircraft: string; + aircraftType: string; + segments: ISegment[]; +} + +export const validateFlightPlan = (flightPlan: IFlightPlan) => { + const ajv = new Ajv(); + addFormats(ajv); + const validateFlightPlanSchema = ajv.compile(flightPlanSchema); + if (!validateFlightPlanSchema(flightPlan)) { + throw new Error(`Schema validation failed: ${validateFlightPlanSchema.errors}`); + } +}; diff --git a/api/flightplan/function.json b/api/flightplan/function.json new file mode 100644 index 0000000..e2e0248 --- /dev/null +++ b/api/flightplan/function.json @@ -0,0 +1,22 @@ +{ + "bindings": [ + { + "authLevel": "function", + "type": "httpTrigger", + "direction": "in", + "name": "req", + "methods": [ + "get", + "post", + "put" + ], + "route": "flightplan/{id?}" + }, + { + "type": "http", + "direction": "out", + "name": "res" + } + ], + "scriptFile": "../dist/flightplan/index.js" +} \ No newline at end of file diff --git a/api/flightplan/index.ts b/api/flightplan/index.ts new file mode 100644 index 0000000..88983cf --- /dev/null +++ b/api/flightplan/index.ts @@ -0,0 +1,129 @@ +import { AzureFunction, Context, HttpRequest } from "@azure/functions" +import { v4 as uuidv4 } from 'uuid'; +import BlobStorage from "../shared/blob-storage"; +import { validateFlightPlan } from "./flightplan"; + +const makeBlobKey = (id: string) => `${id}.json`; + +const flightPlanHttpTrigger: AzureFunction = async function (context: Context, req: HttpRequest): Promise { + const blobStorage = new BlobStorage(process.env.BLOB_STORAGE_CONNECTION!, 'flightplans'); + + switch (req.method) { + case "POST": + { + let flightPlanString: string; + try { + const flightPlan = req.body; + validateFlightPlan(flightPlan); + flightPlan.id = uuidv4(); + flightPlanString = JSON.stringify(flightPlan, null, 2); + blobStorage.writeBlob(makeBlobKey(flightPlan.id), flightPlanString); + } catch(err) { + context.res = { + status: 400, + body: `Invalid flight plan: ${err}`, + }; + return; + } + context.res = { + status: 201, + body: flightPlanString, + }; + } + break; + case "PUT": + { + let flightPlanBlobKeys: string[]; + try { + flightPlanBlobKeys = await blobStorage.listBlobs(); + } catch(err) { + context.res = { + status: 500, + body: `Error listing flight plans`, + }; + return; + } + if (!flightPlanBlobKeys.includes(makeBlobKey(context.bindingData.id))) { + context.res = { + status: 404, + body: `Unknown flight plan ${context.bindingData.id}`, + }; + return; + } + let flightPlanString: string; + try { + const flightPlan = req.body; + validateFlightPlan(flightPlan); + flightPlan.id = context.bindingData.id; + flightPlanString = JSON.stringify(flightPlan, null, 2); + blobStorage.writeBlob(makeBlobKey(flightPlan.id), flightPlanString); + } catch(err) { + context.res = { + status: 400, + body: `Invalid flight plan: ${err}`, + }; + return; + } + context.res = { + status: 200, + body: flightPlanString, + }; + } + break; + case "GET": + { + if (context.bindingData.id) { + let flightPlanString: string; + try { + flightPlanString = await blobStorage.readBlob(makeBlobKey(context.bindingData.id)); + } catch(err) { + context.res = { + status: 404, + body: `Unknown flight plan ${context.bindingData.id}`, + }; + return; + } + context.res = { + status: 200, + body: flightPlanString + }; + } else { + let flightPlanBlobKeys: string[]; + try { + flightPlanBlobKeys = await blobStorage.listBlobs(); + } catch(err) { + context.res = { + status: 500, + body: `Error listing flight plans`, + }; + return; + } + let flightPlans: string[] = []; + for (let flightPlanBlobKey of flightPlanBlobKeys) { + try { + flightPlans.push(await blobStorage.readBlob(flightPlanBlobKey)); + } catch(err) { + context.res = { + status: 500, + body: `Error reading flight plan ${flightPlanBlobKey}`, + }; + return; + } + } + context.res = { + status: 200, + body: `[${flightPlans.join(',\n')}]` + }; + } + } + break; + default: + context.res = { + status: 400, + body: "Invalid HTTP method", + }; + break; + } +}; + +export default flightPlanHttpTrigger; diff --git a/api/package.json b/api/package.json index b7821fd..bfa6971 100644 --- a/api/package.json +++ b/api/package.json @@ -12,10 +12,14 @@ "test": "echo \"No tests yet...\"" }, "dependencies": { + "@azure/storage-blob": "^12.17.0", + "ajv": "^8.12.0", + "ajv-formats": "^2.1.1", "dotenv": "^16.3.1", "metar-taf-parser": "^8.0.4", "node-fetch": "^3.3.2", - "sunrise-sunset-js": "^2.2.1" + "sunrise-sunset-js": "^2.2.1", + "uuid": "^9.0.1" }, "devDependencies": { "@azure/functions": "^3.0.0", diff --git a/api/shared/blob-storage.ts b/api/shared/blob-storage.ts new file mode 100644 index 0000000..c9d8472 --- /dev/null +++ b/api/shared/blob-storage.ts @@ -0,0 +1,53 @@ +import { BlobServiceClient, ContainerClient, BlobClient } from '@azure/storage-blob'; + +class BlobStorage { + private containerClient: ContainerClient; + + constructor(connectionString: string, containerName: string) { + const serviceClient = BlobServiceClient.fromConnectionString(connectionString); + this.containerClient = serviceClient.getContainerClient(containerName); + } + + async listBlobs(): Promise { + let blobNames: string[] = []; + for await (const blob of this.containerClient.listBlobsFlat()) { + blobNames.push(blob.name); + } + return blobNames; + } + + async readBlob(key: string): Promise { + const blobClient = this.containerClient.getBlobClient(key); + const downloadBlockBlobResponse = await blobClient.download(); + if (!downloadBlockBlobResponse.readableStreamBody) { + throw new Error('No blob stream found'); + } + const content = await this.streamToString(downloadBlockBlobResponse.readableStreamBody); + return content; + } + + async writeBlob(key: string, value: string): Promise { + const blobClient = this.containerClient.getBlockBlobClient(key); + await blobClient.upload(value, value.length); + } + + async deleteBlob(key: string): Promise { + const blobClient = this.containerClient.getBlobClient(key); + await blobClient.delete(); + } + + private async streamToString(readableStream: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + const chunks: string[] = []; + readableStream.on('data', (data) => { + chunks.push(data.toString()); + }); + readableStream.on('end', () => { + resolve(chunks.join('')); + }); + readableStream.on('error', reject); + }); + } +} + +export default BlobStorage; diff --git a/api/warmup/index.ts b/api/warmup/index.ts index 6a05fc4..3e52ae5 100644 --- a/api/warmup/index.ts +++ b/api/warmup/index.ts @@ -5,7 +5,6 @@ const warmupHttpTrigger: AzureFunction = async function (context: Context, req: context.res = { body: "Warmup completed" }; - }; export default warmupHttpTrigger; diff --git a/api/weather/function.json b/api/weather/function.json index 1d0bd36..cdaf2bd 100644 --- a/api/weather/function.json +++ b/api/weather/function.json @@ -6,8 +6,7 @@ "direction": "in", "name": "req", "methods": [ - "get", - "post" + "get" ] }, {