Initial commit

master
Kenneth Barbour 2024-03-29 15:53:02 -04:00
commit f4cd8c120c
13 changed files with 3120 additions and 0 deletions

227
.gitignore vendored 100644
View File

@ -0,0 +1,227 @@
# Created by https://www.toptal.com/developers/gitignore/api/node,vim,linux,windows,visualstudiocode
# Edit at https://www.toptal.com/developers/gitignore?templates=node,vim,linux,windows,visualstudiocode
### Linux ###
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
### Vim ###
# Swap
[._]*.s[a-v][a-z]
!*.svg # comment out if you don't need vector files
[._]*.sw[a-p]
[._]s[a-rt-v][a-z]
[._]ss[a-gi-z]
[._]sw[a-p]
# Session
Session.vim
Sessionx.vim
# Temporary
.netrwhist
# Auto-generated tag files
tags
# Persistent undo
[._]*.un~
### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide
### Windows ###
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# End of https://www.toptal.com/developers/gitignore/api/node,vim,linux,windows,visualstudiocode
# SQLite databases
*.db

10
LICENSE.txt 100644
View File

@ -0,0 +1,10 @@
The MIT License (MIT)
Copyright © 2024 Kenneth Barbour
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

60
README.md 100644
View File

@ -0,0 +1,60 @@
# Weather Report API
This API allows users to submit and retrieve weather reports. Authenticated
users can submit weather reports for their own stations. Any user may retrieve
weather reports.
## API Spec
The OpenAPI specification can be found in [openapi.json](openapi.json).
## Environment Variables
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `PORT` | `3000` | Port to listen on for HTTP requests |
| `NODE_ENV` | `development` | Development environment; may be set to `production`|
| `DB_FILE` | `weather-reports.db` | Path to SQLite database file |
## Development
1. `npm install` to install dependencies
2. `npm run dev` to start the development server
## Usage
### Send a weather report
Send a JSON weather report by POST-ing to `/v1/reports/{station_id}`, where
`{station_id}` is any unique identifier for your station.
For example, with CURL:
```sh
curl -X POST http://localhost:3000/v1/reports/TEST \
-H "Content-Type: application/json" \
-d '{"latitude": 40.7128, "longitude": -74.0060, "temperature": 72.5, "pressure": 1013.5, "humidity": 50.0}'
```
### Get the most recent report
With CURL:
```sh
curl http://localhost:3000/v1/reports/TEST
```
### Get report history
With CURL:
```sh
curl http://localhost:3000/v1/reports/TEST/history?start=2020-01-01T00:00:00&end=2020-01-02T00:00:00
```
Your result may include a `next` field. Add that to the query param to get the
next set of results. For example:
```sh
curl http://localhost:3000/v1/reports/TEST/history?start=2020-01-01T00:00:00&end=2020-01-02T00:00:00&next=1234
```

177
openapi.json 100644
View File

@ -0,0 +1,177 @@
{
"openapi": "3.1.0",
"info": {
"title": "Weather API",
"version": "1.0.0",
"description": "API for submitting and retrieving weather reports"
},
"servers": [
{
"url": "http://localhost:3000/v1"
}
],
"components": {
"schemas": {
"WeatherReport": {
"type": "object",
"properties": {
"latitude": {
"type": "number",
"description": "Latitude coordinate of the weather station"
},
"longitude": {
"type": "number",
"description": "Longitude coordinate of the weather station"
},
"temperature": {
"type": "number",
"description": "Temperature in degrees Celsius"
},
"pressure": {
"type": "number",
"description": "Atmospheric pressure in hPa"
},
"humidity": {
"type": "number",
"description": "Relative humidity in percentage"
},
"date_time": {
"type": "string",
"format": "date-time",
"description": "Date and time of the weather report in ISO 8601 format. If not provided, the current date/time is assumed."
},
"wind_speed": {
"type": "number",
"description": "Wind speed in meters per second (optional)"
},
"wind_direction": {
"type": "string",
"description": "Wind direction (optional)"
},
"rainfall": {
"type": "number",
"description": "Rainfall amount in millimeters (optional)"
},
"conditions": {
"type": "string",
"description": "Text description of current weather conditions (optional)"
}
},
"required": ["latitude", "longitude", "temperature", "pressure", "humidity"]
}
}
},
"paths": {
"/v1/reports/{station_id}": {
"post": {
"summary": "Submit a weather report",
"description": "Endpoint for users to submit weather reports.",
"parameters": [
{
"name": "station_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"description": "The ID of the weather station"
},
"description": "ID of the weather station"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WeatherReport"
}
}
},
"responses": {
"201": {
"description": "Weather report submitted successfully"
},
"400": {
"description": "Bad request, check request parameters"
},
"500": {
"description": "Internal server error"
}
},
"security": []
}
}
},
"/v1/reports/{station_id}/history": {
"get": {
"summary": "Get weather reports in reverse chronological order",
"description": "Endpoint to retrieve weather reports for a station in reverse chronological order.",
"parameters": [
{
"name": "station_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"description": "The ID of the weather station"
},
"description": "ID of the weather station"
},
{
"name": "start",
"in": "query",
"required": false,
"schema": {
"type": "string",
"format": "date-time",
"description": "Starting date/time to retrieve reports from in ISO 8601 format"
},
"description": "Starting date/time to retrieve reports from (optional)"
},
{
"name": "end",
"in": "query",
"required": false,
"schema": {
"type": "string",
"description": "Latest date/time to retrieve reports from in ISO 8601 format"
},
"description": "Latest date/time to retrieve reports from (optional)"
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"reports": {
"type": "array",
"items": {
"$ref": "#/components/schemas/WeatherReport"
}
},
"next": {
"type": "string",
"description": "URL to request for more data (pagination)"
}
}
}
}
}
},
"400": {
"description": "Bad request, check request parameters"
},
"500": {
"description": "Internal server error"
}
},
"security": []
}
}
}
}

2332
package-lock.json generated 100644

File diff suppressed because it is too large Load Diff

21
package.json 100644
View File

@ -0,0 +1,21 @@
{
"name": "@kenbarbour/weather-report-api",
"version": "0.0.0",
"description": "An API for weather reports",
"main": "server.mjs",
"scripts": {
"dev": "nodemon server.mjs"
},
"author": "Kenneth Barbour <kenbarbour@gmail.com>",
"license": "MIT",
"dependencies": {
"@koa/router": "^12.0.1",
"koa": "^2.15.2",
"koa-bodyparser": "^4.4.1",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7"
},
"devDependencies": {
"nodemon": "^3.1.0"
}
}

13
send-sample-report 100755
View File

@ -0,0 +1,13 @@
#!/bin/bash
## This script will send a sample weather report to the TEST station
curl "http://localhost:${PORT:-3000}/v1/reports/TEST" \
-X POST -H 'Content-Type: application/json' \
-d '{
"temperature": 21,
"pressure": 1000,
"humidity": 45
}'
curl "http://localhost:${PORT:-3000}/v1/reports/TEST/history"
# curl "http://localhost:${PORT:-3000}/v1/reports/TEST"

12
server.mjs 100644
View File

@ -0,0 +1,12 @@
import App from './src/app.mjs';
import Env from './src/env.mjs';
const env = Env();
const app = App(env);
app.listen(env.PORT, () => {
/* Log environment variables for convenience */
if (env.NODE_ENV === 'development') {
console.log(env);
}
});

24
src/app.mjs 100644
View File

@ -0,0 +1,24 @@
import Koa from 'koa';
import logger from './middleware/logger.mjs';
import makeRouter from './routes.mjs';
import makeStore from './store/sqlite.mjs';
/**
* Generates an application that can listen to HTTP requests
* and repond to them.
* @returns {Koa}
*/
export function makeApp({ DB_FILE }) {
const app = new Koa();
app.use(logger());
const store = makeStore({ DB_FILE });
const router = makeRouter({ store });
app.use(router.routes(), router.allowedMethods());
return app;
}
export default makeApp;

18
src/env.mjs 100644
View File

@ -0,0 +1,18 @@
export function getEnv(env) {
/* Each value below should have a default value, or will throw an error
* if not provided in the environment.
*/
const {
PORT = 3000,
DB_FILE = 'weather-reports.db',
NODE_ENV = 'development',
} = env;
return {
PORT,
DB_FILE,
NODE_ENV,
};
}
export default () => getEnv(process.env);

View File

@ -0,0 +1,66 @@
/**
* Log requests to the console.
* Handles any caught errors and logs to the console.
* Provides log, error, warn, and nonce properties to the request context.
* @returns {Promise}
*/
export async function logMiddleware(ctx, next) {
// Generate a Nonce that can be used to identify this request in the logs
ctx.nonce = generateNonce();
/* Add log, error, and warn functions to request context that include
* the request nonce. */
ctx.log = (...args) => console.log(`[${ctx.nonce}]`, ...args);
ctx.error = (...args) => console.error(`[${ctx.nonce}]`, 'ERROR', ...args);
ctx.warn = (...args) => console.warn(`[${ctx.nonce}]`, 'WARNING', ...args);
/* Start timing the request. Any middleware added before this one will
* not be timed. */
const start = Date.now();
try {
await next(); // process the rest of the middlewares
} catch (err) {
/* Catch any errors that occur during the request
* We will log them to the console and send a standard response that
* includes the request nonce. When debugging, we only need to search for
* log lines that include the nonce.
*/
ctx.body = {
message: 'A server error has occurred and has been logged.',
nonce: ctx.nonce,
};
ctx.status = 500;
ctx.error(err, ctx);
/* If not in production, add additional debugging information. This
* information may be sensitive and should not be present in production.
*/
if (process.env.NODE_ENV !== 'production') {
ctx.body.error = err.message;
ctx.body.stack = err.stack.split('\n').slice(1);
}
return;
} finally {
// Log the request, regardless of error condition
const ms = Date.now() - start;
ctx.log(`${ctx.method} ${ctx.url} - ${ctx.status} - ${ms}ms`);
}
}
/**
* Generate a random string.
* @param {Number} length
* @returns {String}
*/
export function generateNonce(length=6) {
return Math.random().toString(36).substring(2, length + 2);
}
/* By convention,
* middleware default exports should be functions that return a middleware. */
export default () => (logMiddleware);

40
src/routes.mjs 100644
View File

@ -0,0 +1,40 @@
import Router from '@koa/router';
import koaBodyparser from 'koa-bodyparser';
function makeRouter({ prefix, store }) {
const router = new Router({ prefix });
const body = koaBodyparser();
router.get('/v1/reports/:station_id', async (ctx) => {
const latestReport = await store.getLatestReport(ctx.params.station_id);
ctx.body = latestReport;
});
router.get('/v1/reports/:station_id/history', async (ctx) => {
const {
start = new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(),
end = new Date(Date.now() + 1000).toISOString(),
next = null,
} = ctx.query;
const { station_id } = ctx.params;
const reports = await store.getReports({
station_id,
start,
end,
});
ctx.body = reports;
});
router.post('/v1/reports/:station_id', body, async (ctx) => {
const { station_id } = ctx.params;
const report = { station_id, ...ctx.request.body };
const result = await store.writeReport(report);
ctx.body = result;
ctx.status = 201;
});
return router;
}
export default makeRouter;

View File

@ -0,0 +1,120 @@
import sqlite3 from 'sqlite3';
import { open } from 'sqlite';
export function makeStore({ DB_FILE }) {
/* Open the database. Keep this function non-async.
* Consumers of dbPromise will need to await the promise
* before using the database.
*/
const dbPromise = open({
filename: DB_FILE,
driver: sqlite3.Database,
}).then((db) => {
makeSchema(db);
return db;
});
return {
writeReport: (...args) => dbPromise.then(db => writeReport(db, ...args)),
getReports: (...args) => dbPromise.then(db => getReports(db, ...args)),
getLatestReport: (...args) => dbPromise.then(db => getLatestReport(db, ...args)),
};
}
export async function writeReport(db, report) {
const record = {
report_datetime: sqlDate(new Date()),
station_id: report.station_id,
temperature: report.temperature || null,
humidity: report.humidity || null,
pressure: report.pressure || null,
wind_speed: report.wind_speed || null,
wind_direction: report.wind_direction || null,
rainfall: report.rainfall || null,
latitude: report.latitude || null,
longitude: report.longitude || null,
altitude: report.altitude || null,
};
const sql = 'INSERT INTO weather_reports ('
+ Object.keys(record).join(', ')
+ ') VALUES ('
+ Object.keys(record).map(k => `:${k}`).join(', ')
+ ')';
const params = Object.fromEntries(Object.entries(record)
.map(([k, v]) => [`:${k}`, v]));
const result = await db.run(sql, params);
return { record_id: result.lastID, ...record };
}
export async function getReports(db, { station_id, start, end, next=null }) {
const maxRows = 3;
const startDate = new Date(start);
const endDate = new Date(end);
const direction = startDate > endDate ? 'DESC' : 'ASC';
const minDate = startDate > endDate ? endDate : startDate;
const maxDate = startDate > endDate ? startDate : endDate;
let sql = `SELECT * FROM weather_reports WHERE station_id = :station_id
AND report_datetime >= DATETIME(:minDate)
AND report_datetime <= DATETIME(:maxDate)`;
const params = {
':station_id': station_id,
':minDate' : sqlDate(minDate),
':maxDate' : sqlDate(maxDate),
};
if (next) {
sql += ` AND report_id >= :nextId`;
params[':nextId'] = next;
}
sql += ` ORDER BY report_datetime ${direction} LIMIT ${maxRows + 1}`;
const result = await db.all(sql, params);
// If more than maxRows is returned, truncate and set the new next marker
let newNext = null;
if (result.length > maxRows) {
const nextResult = result.pop();
newNext = nextResult.report_id;
}
return {
count: result.length,
reports: result,
next: newNext,
};
}
export async function getLatestReport(db, station_id) {
console.log({ station_id });
const sql = `SELECT * FROM weather_reports WHERE station_id = ?
ORDER BY report_datetime DESC LIMIT 1`;
const result = await db.get(sql, station_id);
return result;
}
export async function makeSchema(db) {
const schema = `
CREATE TABLE IF NOT EXISTS weather_reports (
report_id INTEGER PRIMARY KEY AUTOINCREMENT,
report_datetime DATETIME NOT NULL,
station_id TEXT NOT NULL,
temperature REAL,
pressure REAL,
humidity REAL,
wind_speed REAL,
wind_direction TEXT,
rainfall REAL,
latitude REAL,
longitude REAL,
altitude REAL
);
`;
await db.run(schema);
}
export function sqlDate(date) {
return date.toISOString().slice(0, 19).replace('T', ' ');
}
export default makeStore;