├── dune-project ├── dune ├── db ├── psql.sh ├── load_schema.sh └── schema.sql ├── server ├── lib │ ├── dune │ ├── time.mli │ └── time.ml ├── bin │ ├── dune │ └── server.ml └── models │ ├── dune │ ├── api_key.mli │ ├── user.mli │ ├── api_key.ml │ ├── reading.mli │ ├── sensor.mli │ ├── user.ml │ ├── sensor.ml │ └── reading.ml ├── docker-compose.yml ├── .gitignore ├── sensors.opam.locked ├── sensors.opam ├── LICENSE ├── README.md ├── api_doc.md └── insomnia.yaml /dune-project: -------------------------------------------------------------------------------- 1 | (lang dune 2.0) 2 | -------------------------------------------------------------------------------- /dune: -------------------------------------------------------------------------------- 1 | (data_only_dirs _esy esy.lock lib node_modules) 2 | -------------------------------------------------------------------------------- /db/psql.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker-compose exec postgres psql -U sensors 4 | -------------------------------------------------------------------------------- /db/load_schema.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker-compose exec postgres psql -U sensors -c "$(cat db/schema.sql)" 4 | -------------------------------------------------------------------------------- /server/lib/dune: -------------------------------------------------------------------------------- 1 | (library 2 | (name lib) 3 | (libraries ptime.clock.os ptime) 4 | (preprocess (pps ppx_yojson_conv)) 5 | ) -------------------------------------------------------------------------------- /server/bin/dune: -------------------------------------------------------------------------------- 1 | (executable 2 | (name server) 3 | (libraries dream caqti-driver-postgresql models lib) 4 | (preprocess (pps lwt_ppx ppx_yojson_conv)) 5 | ) 6 | -------------------------------------------------------------------------------- /server/models/dune: -------------------------------------------------------------------------------- 1 | (library 2 | (name models) 3 | (libraries caqti-driver-postgresql caqti-lwt re lib) 4 | (preprocess (pps lwt_ppx ppx_yojson_conv)) 5 | ) -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | postgres: 5 | image: postgres 6 | expose: 7 | - 5432 8 | ports: 9 | - "5432:5432" 10 | environment: 11 | - POSTGRES_USER=sensors 12 | - POSTGRES_DB=sensors 13 | - POSTGRES_HOST_AUTH_METHOD=trust 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.annot 2 | *.cmo 3 | *.cma 4 | *.cmi 5 | *.a 6 | *.o 7 | *.cmx 8 | *.cmxs 9 | *.cmxa 10 | 11 | # ocamlbuild working directory 12 | _build/ 13 | 14 | # ocamlbuild targets 15 | *.byte 16 | *.native 17 | 18 | # oasis generated files 19 | setup.data 20 | setup.log 21 | 22 | # Merlin configuring file for Vim and Emacs 23 | .merlin 24 | 25 | # Dune generated files 26 | *.install 27 | 28 | # Local OPAM switch 29 | _opam/ 30 | -------------------------------------------------------------------------------- /server/models/api_key.mli: -------------------------------------------------------------------------------- 1 | module type DB = Caqti_lwt.CONNECTION 2 | 3 | type t = { 4 | id : int; 5 | uuid : string; 6 | } 7 | (* Note: Yojson isn't used here because an API key is shared with the 8 | frontend as part of the sensor JSON record.*) 9 | 10 | val create: unit -> (module DB) -> t Lwt.t 11 | (* Generate a new API key. *) 12 | 13 | val touch: string -> (module DB) -> unit Lwt.t 14 | (* Update the last_used timestamp for this key. *) 15 | -------------------------------------------------------------------------------- /server/models/user.mli: -------------------------------------------------------------------------------- 1 | module type DB = Caqti_lwt.CONNECTION 2 | 3 | type t = { 4 | username: string; 5 | name: string; 6 | sensors: int list; 7 | } [@@deriving yojson] 8 | 9 | 10 | val get : string -> string -> (module DB) -> int option Lwt.t 11 | (* Recover the ID associated with the input username/password pair, or 12 | None if the username/password combo is invalid.*) 13 | 14 | val sensors : int -> (module DB) -> int list Lwt.t 15 | (* Recover a list of the sensor ids associated with this user. *) 16 | 17 | val get_user_meta : int -> (module DB) -> t option Lwt.t 18 | (* Recover the metadata associated with a specific user. *) 19 | -------------------------------------------------------------------------------- /server/models/api_key.ml: -------------------------------------------------------------------------------- 1 | module type DB = Caqti_lwt.CONNECTION 2 | module R = Caqti_request 3 | module T = Caqti_type 4 | 5 | 6 | type t = { 7 | id : int; 8 | uuid : string 9 | } 10 | 11 | 12 | let (>>=) = Lwt.bind 13 | 14 | 15 | let create = 16 | let query = 17 | R.find T.unit T.(tup2 T.int T.string) 18 | "INSERT INTO api_key DEFAULT VALUES RETURNING id, uuid" 19 | in 20 | fun () (module Db : DB) -> 21 | Db.find query () 22 | >>= Caqti_lwt.or_fail 23 | >>= fun (id, uuid) -> Lwt.return {id; uuid} 24 | 25 | 26 | let touch = 27 | let query = 28 | R.exec T.string 29 | "UPDATE api_key SET last_used = now() WHERE uuid = $1" 30 | in 31 | fun uuid (module Db : DB) -> 32 | Db.exec query uuid 33 | >>= Caqti_lwt.or_fail 34 | -------------------------------------------------------------------------------- /server/lib/time.mli: -------------------------------------------------------------------------------- 1 | type date = Ptime.date 2 | type time = Ptime.time 3 | 4 | val readings_per_day : int -> int 5 | (* Report the number of readings per day, given an input reading step size, in seconds. *) 6 | 7 | val date_of_string_opt : string -> Ptime.date option 8 | val string_of_date : date -> string 9 | 10 | val today : unit -> date 11 | 12 | val yojson_of_date : date -> [> `String of string] 13 | val date_of_yojson : [> `String of string] -> date 14 | 15 | val datetime_of_epoch_sec : int -> (date * time) option 16 | 17 | val bin: time -> int -> int 18 | (* Given a time record and step size, report the index of the bin containing the time. 19 | Examples: 20 | time = (1, 20, 15) i.e. "01:20:15 AM UTC" 21 | step = 3600 i.e. "60 minute timestep" 22 | bin = 1 i.e. the second time bin for the day. 23 | *) 24 | -------------------------------------------------------------------------------- /sensors.opam.locked: -------------------------------------------------------------------------------- 1 | opam-version: "2.0" 2 | synopsis: "Minimalist framework to build extensible HTTP servers and clients" 3 | description: 4 | "Rock is a Unix indpendent API to build extensible HTTP servers and clients. It provides building blocks such as middlewares and handlers (a.k.a controllers)." 5 | depends: [ 6 | "caqti" {= "1.6.0"} 7 | "caqti-driver-postgresql" {= "1.6.0"} 8 | "caqti-lwt" {= "1.6.0"} 9 | "dream" {= "1.0.0~alpha2"} 10 | "dune" {= "2.8.5"} 11 | "ocaml" {= "4.11.1"} 12 | "ppx_yojson_conv" {= "v0.14.0"} 13 | "ptime" {= "0.8.5"} 14 | "re" {= "1.9.0"} 15 | ] 16 | build: [ 17 | ["dune" "subst"] {pinned} 18 | [ 19 | "dune" 20 | "build" 21 | "-p" 22 | name 23 | "-j" 24 | jobs 25 | "@install" 26 | "@runtest" {with-test} 27 | "@doc" {with-doc} 28 | ] 29 | ] 30 | name: "sensors" 31 | version: "dev" 32 | -------------------------------------------------------------------------------- /sensors.opam: -------------------------------------------------------------------------------- 1 | # This file is generated by dune, edit dune-project instead 2 | opam-version: "2.0" 3 | synopsis: "Minimalist framework to build extensible HTTP servers and clients" 4 | description: 5 | "Rock is a Unix indpendent API to build extensible HTTP servers and clients. It provides building blocks such as middlewares and handlers (a.k.a controllers)." 6 | depends: [ 7 | "dune" {>= "2.0"} 8 | "ocaml" {>= "4.11.0"} 9 | "dream" {>= "1.0.0~alpha2"} 10 | "caqti" {>= "1.6.0"} 11 | "caqti-lwt" {>= "1.6.0"} 12 | "caqti-driver-postgresql" {>= "1.6.0"} 13 | "ppx_yojson_conv" {>= "v0.14.0"} 14 | "ptime" 15 | "re" 16 | "ocaml-lsp-server" {dev} 17 | ] 18 | build: [ 19 | ["dune" "subst"] {pinned} 20 | [ 21 | "dune" 22 | "build" 23 | "-p" 24 | name 25 | "-j" 26 | jobs 27 | "@install" 28 | "@runtest" {with-test} 29 | "@doc" {with-doc} 30 | ] 31 | ] 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Joe Thomas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /server/models/reading.mli: -------------------------------------------------------------------------------- 1 | module type DB = Caqti_lwt.CONNECTION 2 | 3 | type readings = float option array [@@deriving yojson] 4 | 5 | type t = { 6 | sensor_id: int; 7 | occurred: Ptime.date; 8 | readings: readings 9 | } [@@deriving yojson] 10 | 11 | val empty : Sensor.t -> Ptime.date -> t 12 | (* Create an empty set of readings for the input sensor / date combination. *) 13 | 14 | val insert : t -> (module DB) -> unit Lwt.t 15 | (* Record a full set of daily measurments for the input sensor / date combination. 16 | If a record is already present with the same date, it will be overwritten. *) 17 | 18 | val read_range : int -> int -> Ptime.date -> Ptime.date -> (module DB) -> t list Lwt.t 19 | (* Retrieve all of the readings for a given sensor between two dates. *) 20 | 21 | val read_day : int -> Ptime.date -> (module DB) -> t option Lwt.t 22 | (* Retrieve the readings associated with a given sensor / date 23 | combination (without validating user ownership). *) 24 | 25 | val overwrite : t -> readings -> t 26 | (* Form a new record by replacing existing measurements with ones in 27 | the input array, provided they are not null. *) 28 | -------------------------------------------------------------------------------- /server/lib/time.ml: -------------------------------------------------------------------------------- 1 | type date = Ptime.date 2 | type time = Ptime.time 3 | 4 | let (|>?) a b = Option.map b a 5 | 6 | let seconds_per_day = 24 * 60 * 60 7 | 8 | let readings_per_day step = seconds_per_day / step 9 | 10 | let date_of_string_opt text = 11 | match text ^ "T00:00:00Z" |> Ptime.of_rfc3339 with 12 | | Ok (t, _, _) -> Some (Ptime.to_date t) 13 | | _ -> None 14 | 15 | let string_of_date (year, month, day) = 16 | Printf.sprintf "%04d-%02d-%02d" year month day 17 | 18 | let today () = Ptime_clock.now () |> Ptime.to_date 19 | 20 | let yojson_of_date d = `String (string_of_date d) 21 | let date_of_yojson y = match y with 22 | | `String x -> ( 23 | match date_of_string_opt x with 24 | | Some d -> d 25 | | None -> raise (Failure "Invalid date JSON") 26 | ) 27 | | _ -> raise (Failure "Invalid date JSON") 28 | 29 | 30 | let datetime_of_epoch_sec x = 31 | x |> Float.of_int |> Ptime.of_float_s |>? Ptime.to_date_time 32 | 33 | 34 | let bin ((h, m, s), _offset) step = 35 | (* Offset can be ignored because all times in this system are UTC. *) 36 | let total_seconds = h * 3600 + m * 60 + s in 37 | total_seconds / step 38 | -------------------------------------------------------------------------------- /server/models/sensor.mli: -------------------------------------------------------------------------------- 1 | module type DB = Caqti_lwt.CONNECTION 2 | 3 | type t = { 4 | id: int; 5 | name: string; 6 | description: string; 7 | api_key: string; 8 | step: int; (* Number of seconds between readings. *) 9 | } [@@deriving yojson] 10 | 11 | 12 | val create: int -> string -> string -> int -> (module DB) -> t Lwt.t 13 | (* Generate a new sensor record for a given user ID, sensor name, 14 | description, and step size. *) 15 | 16 | val get : int -> int -> (module DB) -> t option Lwt.t 17 | (* `get user_id sensor_id` retrieves the metadata of a sensor 18 | with primary key sensor_id, provided that sensor is owned by the user 19 | identified by user_id. *) 20 | 21 | val delete : int -> int -> (module DB) -> unit Lwt.t 22 | (* `delete user_id sensor_id` deletes the sensor record with primary key 23 | sensor_id, provided that sensor is owned by the user identified by 24 | user_id. Because of foreign key relationships, this also deletes 25 | the time series data associated with the sensor.*) 26 | 27 | val from_key : string -> (module DB) -> t list Lwt.t 28 | (* Recover the primary keys of all of the sensors accessible from 29 | the input API key. *) 30 | -------------------------------------------------------------------------------- /server/models/user.ml: -------------------------------------------------------------------------------- 1 | module type DB = Caqti_lwt.CONNECTION 2 | module R = Caqti_request 3 | module T = Caqti_type 4 | 5 | type t = { 6 | username: string [@key "username"]; 7 | name: string [@key "name"]; 8 | sensors: int list [@key "sensors"]; 9 | } [@@deriving yojson] 10 | 11 | 12 | (* Note: Storing passwords in cleartext is a bad idea in a production app.*) 13 | let get = 14 | let query = 15 | R.find_opt T.(tup2 T.string T.string) T.int 16 | "SELECT id FROM app_user WHERE username = ? and password = ?" in 17 | fun username password (module Db : DB) -> 18 | let%lwt user_or_error = Db.find_opt query (username, password) in 19 | Caqti_lwt.or_fail user_or_error 20 | 21 | 22 | let sensors = 23 | let query = 24 | R.collect T.int T.int 25 | "SELECT sensor FROM user_sensor WHERE app_user = ?" in 26 | fun user_id (module Db : DB) -> 27 | let%lwt user_or_error = Db.collect_list query user_id in 28 | Caqti_lwt.or_fail user_or_error 29 | 30 | 31 | let details = 32 | let query = 33 | R.find_opt T.int T.(tup2 T.string T.string) 34 | "SELECT username, name FROM app_user WHERE id = ?" in 35 | fun user_id (module Db : DB) -> 36 | let%lwt details_or_error = Db.find_opt query user_id in 37 | Caqti_lwt.or_fail details_or_error 38 | 39 | 40 | let get_user_meta id m = 41 | let%lwt details = details id m in 42 | let%lwt sensors = sensors id m in 43 | Lwt.return (Option.map (fun (username, name) -> {username; name; sensors}) details) 44 | -------------------------------------------------------------------------------- /db/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION if not exists "uuid-ossp"; 2 | 3 | CREATE TABLE dream_session ( 4 | id TEXT PRIMARY KEY, 5 | label TEXT NOT NULL, 6 | expires_at REAL NOT NULL, 7 | payload TEXT NOT NULL 8 | ); 9 | 10 | CREATE TABLE app_user ( 11 | id SERIAL PRIMARY KEY, 12 | username TEXT NOT NULL, 13 | name TEXT NOT NULL, 14 | password TEXT NOT NULL 15 | ); 16 | CREATE UNIQUE INDEX username ON app_user(username); 17 | 18 | CREATE TABLE api_key ( 19 | id SERIAL PRIMARY KEY, 20 | uuid TEXT NOT NULL DEFAULT uuid_generate_v4(), 21 | last_used TIMESTAMP NOT NULL DEFAULT now() 22 | ); 23 | CREATE UNIQUE INDEX api_key_uuid ON api_key(uuid); 24 | 25 | CREATE TABLE sensor ( 26 | id SERIAL PRIMARY KEY, 27 | name TEXT NOT NULL, 28 | description TEXT, 29 | api_key INTEGER, 30 | step INTEGER CHECK (step > 0) NOT NULL, 31 | FOREIGN KEY (api_key) REFERENCES api_key(id) 32 | ); 33 | 34 | CREATE TABLE sensor_reading ( 35 | id SERIAL PRIMARY KEY, 36 | sensor INTEGER, 37 | occurred DATE, 38 | readings JSON, 39 | CONSTRAINT fk_sensor_reading_sensor FOREIGN KEY (sensor) references sensor(id) ON DELETE CASCADE 40 | ); 41 | CREATE UNIQUE INDEX sensor_reading_sensor_occurred ON sensor_reading(sensor, occurred); 42 | 43 | CREATE TABLE user_sensor ( 44 | id SERIAL PRIMARY KEY, 45 | app_user INTEGER, 46 | sensor INTEGER, 47 | CONSTRAINT fk_user_sensor_user 48 | FOREIGN KEY (app_user) 49 | REFERENCES app_user(id) 50 | ON DELETE CASCADE, 51 | CONSTRAINT fk_user_sensor_sensor 52 | FOREIGN KEY (sensor) 53 | REFERENCES sensor(id) 54 | ON DELETE CASCADE 55 | ); 56 | 57 | insert into app_user (username, name, password) values ('test_user', 'Test User', 'test_password'); 58 | insert into api_key (uuid) values ('73f186a9-a367-4218-a2e8-f89da8e64715'); 59 | insert into sensor (name, description, api_key, step) values 60 | ('Test Sensor', 'This is the first example sensor.', 1, 3600); 61 | insert into user_sensor (app_user, sensor) values (1,1); 62 | -------------------------------------------------------------------------------- /server/models/sensor.ml: -------------------------------------------------------------------------------- 1 | module type DB = Caqti_lwt.CONNECTION 2 | module R = Caqti_request 3 | module T = Caqti_type 4 | 5 | let (>>=) = Lwt.bind 6 | 7 | type t = { 8 | id: int; 9 | name: string; 10 | description: string; 11 | api_key: string; 12 | step: int; 13 | } [@@deriving yojson] 14 | 15 | 16 | let create_sensor = 17 | let query = 18 | R.find T.(tup4 T.string T.string T.int T.int) T.int 19 | {| 20 | INSERT INTO sensor (name, description, api_key, step) 21 | VALUES ($1, $2, $3, $4) RETURNING id 22 | |} in 23 | fun name description key_id step (module Db : DB) -> 24 | let%lwt result = Db.find query (name, description, key_id, step) in 25 | Caqti_lwt.or_fail result 26 | 27 | 28 | let create_user_relationship = 29 | let query = 30 | R.exec T.(tup2 T.int T.int) 31 | "INSERT INTO user_sensor (app_user, sensor) VALUES ($1, $2)" in 32 | fun user_id sensor_id (module Db: DB) -> 33 | let%lwt result = Db.exec query (user_id, sensor_id) in 34 | Caqti_lwt.or_fail result 35 | 36 | 37 | let create user_id name description step db = 38 | let%lwt k = Api_key.create () db in 39 | let%lwt id = create_sensor name description k.id step db in 40 | let%lwt _ = create_user_relationship user_id id db in 41 | Lwt.return {id; name; description; api_key=k.uuid; step} 42 | 43 | 44 | let get = 45 | let query = 46 | R.find_opt T.(tup2 T.int T.int) T.(tup4 T.string T.string T.string T.int) 47 | {| 48 | SELECT s.name, s.description, k.uuid, s.step 49 | FROM sensor s 50 | INNER JOIN user_sensor us 51 | ON us.sensor = s.id AND us.app_user = $1 AND us.sensor = $2 52 | INNER JOIN api_key k 53 | ON s.api_key = k.id 54 | |} 55 | in 56 | fun user_id sensor_id (module Db: DB) -> 57 | Db.find_opt query (user_id, sensor_id) 58 | >>= Caqti_lwt.or_fail 59 | >>= (fun details_or_error -> 60 | Lwt.return ( 61 | Option.map (fun (name, description, api_key, step) -> 62 | {id=sensor_id; name; description; api_key; step}) 63 | details_or_error)) 64 | 65 | 66 | let delete = 67 | let query = 68 | R.exec T.(tup2 T.int T.int) 69 | {| 70 | DELETE FROM sensor WHERE id IN 71 | (SELECT s.id FROM 72 | Sensor s 73 | INNER JOIN user_sensor us 74 | ON us.sensor = s.id AND us.app_user = $1 AND us.sensor = $2) 75 | |} 76 | in 77 | fun user_id sensor_id (module Db: DB) -> 78 | let%lwt result = Db.exec query (user_id, sensor_id) in 79 | Caqti_lwt.or_fail result 80 | 81 | 82 | let from_key = 83 | let query = 84 | R.collect T.string (T.tup4 T.int T.string T.string T.int) 85 | {| 86 | SELECT s.id, s.name, s.description, s.step FROM sensor s 87 | INNER JOIN api_key k 88 | ON s.api_key = k.id AND k.uuid = $1 89 | |} 90 | in 91 | fun api_key (module Db: DB) -> 92 | Db.collect_list query api_key 93 | >>= Caqti_lwt.or_fail 94 | >>= (fun details_or_error -> 95 | Lwt.return ( 96 | List.map (fun (id, name, description, step) -> {id; name; description; api_key; step}) 97 | details_or_error)) 98 | -------------------------------------------------------------------------------- /server/models/reading.ml: -------------------------------------------------------------------------------- 1 | module type DB = Caqti_lwt.CONNECTION 2 | module R = Caqti_request 3 | module T = Caqti_type 4 | module Time = Lib.Time 5 | 6 | type readings = float option array [@@deriving yojson] 7 | 8 | type t = { 9 | sensor_id: int; 10 | occurred: Time.date; 11 | readings: readings 12 | } [@@deriving yojson] 13 | 14 | 15 | let empty (s: Sensor.t) occurred = 16 | let n = Time.readings_per_day 900 in (* FIXME: Sensor step sizes. *) 17 | let readings = Array.init n (fun _ -> None) in 18 | { sensor_id=s.id; occurred; readings } 19 | 20 | 21 | let sql_readings = 22 | let encode (a: readings) = 23 | Ok (a |> yojson_of_readings |> Yojson.Safe.to_string) 24 | in 25 | let decode text = 26 | Ok (text |> Yojson.Safe.from_string |> readings_of_yojson) 27 | in 28 | T.(custom ~encode ~decode string) 29 | 30 | 31 | let sql_date = 32 | let encode (d: Time.date) = Ok (Time.string_of_date d) in 33 | let decode text = 34 | match Time.date_of_string_opt text with 35 | | Some d -> Ok d 36 | | _ -> Error (Printf.sprintf "Failed to parse date string '%s'." text ) 37 | in 38 | T.(custom ~encode ~decode string) 39 | 40 | 41 | let insert = 42 | let query = 43 | R.exec T.(tup3 T.int sql_date sql_readings) 44 | {| 45 | INSERT INTO sensor_reading (sensor, occurred, readings) 46 | VALUES ($1, $2, $3) 47 | ON CONFLICT (sensor, occurred) DO UPDATE SET readings = $3 48 | |} 49 | in 50 | fun r (module Db: DB) -> 51 | let%lwt result = Db.exec query (r.sensor_id, r.occurred, r.readings) in 52 | Caqti_lwt.or_fail result 53 | 54 | 55 | let read_range = 56 | let query = 57 | R.collect T.(tup4 T.int T.int sql_date sql_date) T.(tup2 sql_date sql_readings) 58 | {| 59 | SELECT sr.occurred, sr.readings FROM sensor_reading sr 60 | INNER JOIN user_sensor us ON 61 | sr.sensor = $2 AND us.sensor = sr.sensor AND us.app_user = $1 62 | WHERE 63 | $3 <= sr.occurred and sr.occurred <= $4 64 | |} 65 | in 66 | fun user_id sensor_id start_d end_d (module Db: DB) -> 67 | let%lwt result = Db.collect_list query (user_id, sensor_id, start_d, end_d) in 68 | let%lwt tuples = Caqti_lwt.or_fail result in 69 | let convert = (fun (d, readings) -> {sensor_id; occurred=d; readings}) in 70 | Lwt.return (List.map convert tuples) 71 | 72 | 73 | let read_day = 74 | let query = 75 | R.find_opt T.(tup2 T.int sql_date) sql_readings 76 | {| 77 | SELECT sr.readings FROM sensor_reading sr 78 | WHERE 79 | sr.sensor = $1 AND sr.occurred = $2 80 | |} 81 | in 82 | fun sensor_id d (module Db: DB) -> 83 | let%lwt result = Db.find_opt query (sensor_id, d) in 84 | let%lwt tuples = Caqti_lwt.or_fail result in 85 | let convert = (fun readings -> {sensor_id; occurred=d; readings}) in 86 | Lwt.return (Option.map convert tuples) 87 | 88 | 89 | let overwrite {sensor_id; occurred; readings} (a: float option array) = 90 | let rec optzip x y = 91 | match x, y with 92 | | _::xs, Some y::ys -> Some y :: (optzip xs ys) 93 | | Some x::xs, None::ys -> Some x :: (optzip xs ys) 94 | | None::xs, None::ys -> None :: (optzip xs ys) 95 | | _ -> [] 96 | in 97 | let pairs = optzip (Array.to_list readings) (Array.to_list a) in 98 | let readings = Array.of_list pairs in 99 | {sensor_id; occurred; readings=readings} 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sensors 2 | 3 | This repo implements a simple REST API server using the 4 | [Dream](https://aantron.github.io/dream/) web framework. I wrote it in 5 | July of 2021, while I was at the [Recurse 6 | Center](https://www.recurse.com/), in order to learn more about web 7 | programming in OCaml. 8 | 9 | I wrote an accompanying [blog 10 | post](https://jsthomas.github.io/ocaml-dream-api.html) that explains 11 | some of my build process and compares the experience to building web 12 | applications in Python. 13 | 14 | ## What does the server do? 15 | 16 | The `sensors` server provides a simple API for managing time series 17 | data. A "sensor" is an abstract IoT device that measures a floating 18 | point quantity at a specific cadence (for example a weather station 19 | producing an hourly average wind-speed reading). 20 | 21 | The API sends and receives data formatted as JSON, and stores 22 | information in a PostgreSQL database. It allows creating, reading, and 23 | deleting of "sensors". Each sensor has an API key, which grants the 24 | ability to upload (POST) data to an endpoint. API users can use a GET 25 | endpoint to retrieve sensor readings between two dates. A more 26 | detailed description of the API endpoints is available 27 | [here](api_doc.md). 28 | 29 | There are three main entities in the system: Users, Sensors, and 30 | Readings. A user can "own" several sensors, and each sensor can have 31 | many readings. A single reading record consists of a date and a 32 | (chronological) array of readings taken on that date (e.g. an hourly 33 | sensor has an array of 24 elements). 34 | 35 | *Important:* This project shouldn't be used as a reference for 36 | implementing security features (e.g. passwords, API keys). I'm not a 37 | security expert and I spent little time on these types of features. 38 | 39 | ## How do I get set up? 40 | 41 | This project uses a PostgreSQL database in a docker container. Before 42 | running the server, you'll need to install `docker` and 43 | `docker-compose`. Then run `docker-compose up -d`, which should 44 | download the relevant image and start it. 45 | 46 | The folder `db` contains scripts for managing the database. From the 47 | root of the project, run `./db/loadschema.sh` to set up the necessary 48 | tables (defined in `db/schema.sql`). You can also run `./db/psql.sh` 49 | to start a client and examine the database. 50 | 51 | Assuming you have `dune` [installed](https://dune.build/install), you 52 | should be able to run the server via 53 | 54 | ``` 55 | opam install caqti-driver-postgresql caqti-lwt dream lwt_ppx ppx_yojson_conv ptime re 56 | dune exec server/bin/server 57 | ``` 58 | 59 | I built this project with version 4.11.1 of the compiler. 60 | 61 | I used [insomnia](https://insomnia.rest/) to make ad-hoc requests 62 | against the API; the project contains an `insomnia.yaml` file, if 63 | you'd like to use my existing API setup. 64 | 65 | ## How do I shut the database down (or restart it)? 66 | 67 | After stopping the server (CRTL+C), run `docker-compose down`. This 68 | should shut down the PostgreSQL container (and clear any state in the 69 | DB). 70 | 71 | ## Project Structure 72 | 73 | - `server/lib` contains utility functions for dealing with dates and 74 | times. 75 | - `server/models` contains `Caqti` queries for the four main tables in 76 | the database (Keys, Users, Sensors, and Readings). 77 | - `server/bin` defines the server itself. 78 | 79 | (Files are stored under `server` because I'm considering adding a 80 | frontend portion to this project in the future.) 81 | 82 | ## Feedback 83 | 84 | If you ended up looking through the source code for my API, let me 85 | know how what you thought! I'm interested in adding more tutorial 86 | resources to the OCaml ecosystem, so feel free to post a PR or issue 87 | if you have ideas about how to make these resources better. 88 | -------------------------------------------------------------------------------- /api_doc.md: -------------------------------------------------------------------------------- 1 | # REST API Endpoints 2 | 3 | This document describes all of the endpoints `sensors` supports, with brief examples. 4 | 5 | ### GET `/version` 6 | 7 | Retrieve text indicating the version of the server (e.g. `Sensors 8 | v0.0.0`). 9 | 10 | ### POST `/api/login` 11 | 12 | Start a new session, provided the credentials in the JSON body are valid. 13 | 14 | Example body: 15 | ```json 16 | { 17 | "username": "test_user", 18 | "password": "test_password" 19 | } 20 | ``` 21 | 22 | ### POST `/api/logout` 23 | 24 | End whatever session that was in progress. No body is required. 25 | 26 | ### POST `/api/sensor` 27 | 28 | Create a new sensor with metadata supplied in the JSON body. The 29 | response body contains the full JSON description of the new sensor, 30 | including a fresh API key for data upload. The `step` field records, 31 | in seconds, how often the sensor takes a measurement. 32 | 33 | Example request body: 34 | 35 | ```json 36 | { 37 | "name": "API Test Sensor #2", 38 | "description": "Some new sensor.", 39 | "step": 3600 40 | } 41 | ``` 42 | 43 | This sensor records data hourly (3600 seconds), producing 24 44 | measurements per day. 45 | 46 | Example response body: 47 | ```json 48 | { 49 | "id": 4, 50 | "name": "API Test Sensor #2", 51 | "description": "Some new sensor.", 52 | "api_key": "3b10ddc6-d51e-43e4-b43c-2470b624d5da", 53 | "step": 3600 54 | } 55 | ``` 56 | 57 | 58 | ### GET `/api/sensor/` 59 | 60 | Return the metadata of the sensor specified in the path. The specified 61 | sensor must belong to the user, otherwise the API responds with a `404 62 | Not Found`. 63 | 64 | Example response body: 65 | ```json 66 | { 67 | "id": 1, 68 | "name": "Test Sensor", 69 | "description": "This is the first example sensor.", 70 | "api_key": "d08fe36b-06f7-477e-adce-cd96f3d7046a" 71 | } 72 | ``` 73 | 74 | ### DELETE `/api/sensor/` 75 | 76 | Delete the specified sensor, assuming it belongs to the user, and any 77 | related time series data. 78 | 79 | 80 | ### POST `/sensor/upload` 81 | 82 | This endpoint is intended for IoT devices to upload their data. The 83 | request should contain an `Authorization` header with the value 84 | `Bearer `, where `` is the UUID-v4 formatted string 85 | from the sensor's metadata `3b10ddc6-d51e-43e4-b43c-2470b624d5da` 86 | above. If the data was successfully captured, the server responds with 87 | `200 OK`. 88 | 89 | Example request body: 90 | 91 | ```json 92 | [{"time": 1604883600, "value": 1.0}, 93 | {"time": 1604887200, "value": 2.0}, 94 | {"time": 1604890800, "value": 3.0}, 95 | {"time": 1604966400, "value": 8.0}, 96 | {"time": 1604970000, "value": 9.0}, 97 | {"time": 1604973600, "value": 10.0}, 98 | {"time": 1604977200, "value": 11.0}, 99 | {"time": 1604980800, "value": 12.0}] 100 | ``` 101 | 102 | The `time` fields should be epoch timestamps (seconds since midnight, 103 | Jan 1, 1970). The `value` fields should carry whatever floating point 104 | quantity the sensor is measuring. Uploading `null` values is not 105 | allowed. 106 | 107 | 108 | ### POST `/api/sensor//readings` 109 | 110 | This endpoint allows a frontend to edit sensor data, and assumes a 111 | session is already active (see `/api/login`). If the data in the 112 | request body is correctly formatted, it completely overwrites the 113 | existing data of data. If the edit was successful, the endpoint 114 | responds with a `200 OK`. If the number of readings does not match the 115 | step size of the sensor, the edit will be rejected. 116 | 117 | Example request body: 118 | ```json 119 | { 120 | "occurred": "2020-11-09", 121 | "readings": [ 122 | 4.0, 10.0, 12.0, 9.2, 13.1, 7.6, 123 | 4.0, 10.0, 12.0, 9.2, 13.1, 7.6, 124 | 4.0, 10.0, 12.0, 9.2, 13.1, 7.6, 125 | 4.0, 10.0, 12.0, 9.2, 13.1, 7.6 126 | ] 127 | } 128 | ``` 129 | 130 | ### GET `/api/sensor//readings?start=&end=` 131 | 132 | Recover the specified sensor's readings between the two input dates 133 | (inclusive). If `start` and `end` query parameters aren't supplied, 134 | default to today's data. Missing data will be indicated with `null` 135 | values. 136 | 137 | Example response body: 138 | 139 | ```json 140 | [ 141 | { 142 | "sensor_id": 1, 143 | "occurred": "2020-11-09", 144 | "readings": [ 145 | 4.0, 146 | 1.0, 147 | 2.0, 148 | ... 149 | ] 150 | }, 151 | { 152 | "sensor_id": 1, 153 | "occurred": "2020-11-10", 154 | "readings": [ 155 | 9.199999999999999, 156 | 13.1, 157 | 7.6, 158 | ... 159 | ] 160 | } 161 | ] 162 | ``` 163 | 164 | ### GET `/api/user` 165 | 166 | Report the metadata associated with the user that owns the current 167 | session. The `sensors` array in the response contains the IDs of all 168 | of the sensors belonging to the user. 169 | 170 | Example response body: 171 | 172 | ```json 173 | { 174 | "username": "test_user", 175 | "name": "Test User", 176 | "sensors": [ 177 | 1 178 | ] 179 | } 180 | ``` 181 | -------------------------------------------------------------------------------- /server/bin/server.ml: -------------------------------------------------------------------------------- 1 | module type DB = Caqti_lwt.CONNECTION 2 | module R = Caqti_request 3 | module T = Caqti_type 4 | 5 | module Time = Lib.Time 6 | module Reading = Models.Reading 7 | module Sensor = Models.Sensor 8 | 9 | let (>>=) = Lwt.bind 10 | let (>>=?) = Option.bind 11 | let (|>?) a b = Option.map b a 12 | 13 | let int_of_string x = 14 | x |> Int32.of_string |> Int32.to_int 15 | 16 | let int_of_string_opt x = 17 | x |> Int32.of_string_opt |>? Int32.to_int 18 | 19 | let json_response ?status x = 20 | x |> Yojson.Safe.to_string |> Dream.json ?status 21 | 22 | 23 | type error_doc = { 24 | error : string; 25 | } [@@deriving yojson] 26 | 27 | 28 | type login_doc = { 29 | username : string; 30 | password : string; 31 | } [@@deriving yojson] 32 | 33 | 34 | let json_receiver json_parser handler request = 35 | let%lwt body = Dream.body request in 36 | let parse = 37 | try 38 | Some (body 39 | |> Yojson.Safe.from_string 40 | |> json_parser) 41 | with _ -> 42 | None 43 | in 44 | match parse with 45 | | Some doc -> handler doc request 46 | | None -> 47 | { error="Received invalid JSON input." } 48 | |> yojson_of_error_doc 49 | |> json_response ~status: `Bad_Request 50 | 51 | 52 | let login = 53 | let login_base login_doc request = 54 | let%lwt user_id = Dream.sql request 55 | (Models.User.get login_doc.username login_doc.password) in 56 | match user_id with 57 | | Some id -> 58 | let%lwt () = Dream.invalidate_session request in 59 | let%lwt () = Dream.put_session "user" (Int.to_string id) request in 60 | Dream.empty `OK 61 | | None -> Dream.empty `Forbidden 62 | in 63 | json_receiver login_doc_of_yojson login_base 64 | 65 | 66 | let logout request = 67 | let%lwt () = Dream.invalidate_session request in 68 | Dream.empty `OK 69 | 70 | 71 | let login_required h request = 72 | match Dream.session "user" request with 73 | | None -> Dream.empty `Unauthorized 74 | | _ -> h request 75 | 76 | 77 | let get_user_id request = 78 | (* Retrieve the user's ID from the session. This is only safe to use 79 | for handlers within the API/login_required scope.*) 80 | Dream.session "user" request 81 | |> Option.get 82 | |> int_of_string 83 | 84 | 85 | let user request = 86 | let%lwt u = get_user_id request 87 | |> Models.User.get_user_meta 88 | |> Dream.sql request in 89 | match u with 90 | | None -> 91 | Dream.empty `Not_Found 92 | | Some details -> 93 | Models.User.yojson_of_t details 94 | |> json_response 95 | 96 | 97 | type sensor_create_doc = { 98 | name : string; 99 | description : string; 100 | step: int 101 | }[@@deriving yojson] 102 | 103 | 104 | let id_validator key handler request = 105 | let sensor_id = Dream.param key request |> int_of_string_opt in 106 | match sensor_id with 107 | | None -> Dream.empty `Bad_Request 108 | | Some m_id -> 109 | handler m_id request 110 | 111 | 112 | let create_sensor = 113 | let create spec request = 114 | let user_id = get_user_id request in 115 | let%lwt sensor = 116 | Dream.sql request (Sensor.create user_id spec.name spec.description spec.step) in 117 | Sensor.yojson_of_t sensor 118 | |> json_response 119 | in 120 | json_receiver sensor_create_doc_of_yojson create 121 | 122 | 123 | let read_sensor sensor_id request = 124 | let user_id = get_user_id request in 125 | match%lwt Dream.sql request (Sensor.get user_id sensor_id) with 126 | | None -> Dream.empty `Not_Found 127 | | Some m -> 128 | Sensor.yojson_of_t m 129 | |> json_response 130 | 131 | 132 | let delete_sensor sensor_id request = 133 | let user_id = get_user_id request in 134 | let%lwt _ = Dream.sql request (Sensor.delete user_id sensor_id) in 135 | Dream.empty `OK 136 | 137 | 138 | let date_query_param key ~default request = 139 | Dream.query key request 140 | >>=? Time.date_of_string_opt 141 | |> Option.value ~default 142 | 143 | 144 | let get_readings sensor_id request = 145 | let today = Time.today () in 146 | let user_id = get_user_id request in 147 | let start_d = date_query_param "start" ~default:today request in 148 | let end_d = date_query_param "end" ~default:today request in 149 | let%lwt readings = Dream.sql request (Reading.read_range user_id sensor_id start_d end_d) in 150 | `List (List.map Reading.yojson_of_t readings) 151 | |> json_response 152 | 153 | 154 | type readings_create_doc = { 155 | occurred : Time.date; 156 | readings : Reading.readings; 157 | }[@@deriving yojson] 158 | 159 | 160 | let write_readings sensor_id = 161 | let write create_obj request = 162 | let user_id = get_user_id request in 163 | match%lwt Dream.sql request (Sensor.get user_id sensor_id) with 164 | | None -> Dream.empty `Not_Found 165 | | Some sensor -> 166 | let actual = Array.length create_obj.readings in 167 | let expected = Time.readings_per_day sensor.step in 168 | if actual = expected then 169 | let r: Reading.t = { 170 | sensor_id=sensor.id; 171 | occurred=create_obj.occurred; 172 | readings=create_obj.readings 173 | } in 174 | let%lwt _ = Dream.sql request (Reading.insert r) in 175 | Dream.empty `OK 176 | else 177 | let message = Printf.sprintf 178 | "Incorrect number of readings. Actual: %d, Expected: %d" 179 | actual expected in 180 | let e = { error = message } in 181 | yojson_of_error_doc e 182 | |> json_response ~status:`Bad_Request 183 | in 184 | json_receiver readings_create_doc_of_yojson write 185 | 186 | 187 | let api_key_required handler request = 188 | match Dream.header "Authorization" request with 189 | | None -> Dream.empty `Bad_Request 190 | | Some key -> 191 | let key = Stringext.replace_all ~pattern:"Bearer " ~with_:"" key in 192 | let%lwt sensors = key |> Sensor.from_key |> Dream.sql request in 193 | match sensors with 194 | | [] -> Dream.empty `Forbidden 195 | | _ -> 196 | let%lwt _ = Models.Api_key.touch key |> Dream.sql request in 197 | handler sensors request 198 | 199 | 200 | type sensor_upload_rec = { 201 | time : int; (* UTC epoch timestamp. *) 202 | value: float; 203 | }[@@deriving yojson] 204 | 205 | 206 | type sensor_upload_doc = sensor_upload_rec list [@@deriving yojson] 207 | 208 | let sensor_upload sensor_ids request = 209 | (* Write the data in the JSON body of the request to each sensor's 210 | database records. 211 | 212 | Sensor data is posted as list of UTC time / float pairs, which must 213 | be converted into the date/array format used in the Reading 214 | model.*) 215 | let store_one doc request (sensor: Sensor.t) = 216 | let n = Time.readings_per_day sensor.step in 217 | let table = Hashtbl.create 7 in 218 | let insert d t v = 219 | let bin = Time.bin t sensor.step in 220 | let a = match Hashtbl.find_opt table d with 221 | | Some array -> array 222 | | None -> 223 | let u: float option array = Array.make n None in 224 | Hashtbl.add table d u; 225 | u 226 | in 227 | Array.set a bin v 228 | in 229 | let add record = 230 | match Time.datetime_of_epoch_sec record.time with 231 | | Some (d,t) -> insert d t (Some record.value) 232 | | None -> () 233 | in 234 | let persist (d, readings) = 235 | let%lwt existing = Dream.sql request (Reading.read_day sensor.id d) in 236 | let record: Reading.t = match existing with 237 | | None -> {sensor_id=sensor.id; occurred=d; readings} 238 | | Some row -> Reading.overwrite row readings 239 | in 240 | Dream.sql request (Reading.insert record) 241 | in 242 | List.iter add doc; 243 | Hashtbl.to_seq table |> List.of_seq |> Lwt_list.iter_s persist 244 | in 245 | 246 | let upload (sensors: Sensor.t list) doc request = 247 | let%lwt _ = Lwt_list.iter_s (store_one doc request) sensors in 248 | Dream.empty `OK 249 | in 250 | json_receiver sensor_upload_doc_of_yojson (upload sensor_ids) request 251 | 252 | 253 | let version _request = 254 | Dream.html "Sensors v0.0.0" 255 | 256 | 257 | let () = 258 | Dream.run ~interface:"0.0.0.0" 259 | @@ Dream.logger 260 | @@ Dream.sql_pool "postgresql://sensors@127.0.0.1/sensors" 261 | @@ Dream.sql_sessions 262 | @@ Dream.router [ 263 | Dream.get "/" version; 264 | Dream.get "/version" version; 265 | 266 | Dream.post "/api/login" login; 267 | Dream.post "/api/logout" logout; 268 | 269 | Dream.scope "/api" [login_required] [ 270 | Dream.get "/user" user; 271 | Dream.scope "/sensor" [] [ 272 | Dream.post "/" create_sensor; 273 | Dream.get "/:sensor_id" @@ id_validator "sensor_id" read_sensor; 274 | Dream.delete "/:sensor_id" @@ id_validator "sensor_id" delete_sensor; 275 | 276 | Dream.post "/:sensor_id/readings" @@ id_validator "sensor_id" write_readings; 277 | Dream.get "/:sensor_id/readings" @@ id_validator "sensor_id" get_readings 278 | ] 279 | ]; 280 | 281 | Dream.scope "/sensor" [] [ 282 | Dream.post "/upload" @@ api_key_required sensor_upload; 283 | ] 284 | ] 285 | @@ Dream.not_found 286 | -------------------------------------------------------------------------------- /insomnia.yaml: -------------------------------------------------------------------------------- 1 | _type: export 2 | __export_format: 4 3 | __export_date: 2021-07-15T19:29:19.586Z 4 | __export_source: insomnia.desktop.app:v2021.4.0 5 | resources: 6 | - _id: req_7aa11bdbca304876a625802b444f081b 7 | parentId: wrk_56e01358d8014e89a8547f8db1fe143d 8 | modified: 1626019575968 9 | created: 1626019564415 10 | url: http://localhost:8080/version 11 | name: Version 12 | description: "" 13 | method: GET 14 | body: {} 15 | parameters: [] 16 | headers: [] 17 | authentication: {} 18 | metaSortKey: -1625850869555 19 | isPrivate: false 20 | settingStoreCookies: true 21 | settingSendCookies: true 22 | settingDisableRenderRequestBody: false 23 | settingEncodeUrl: true 24 | settingRebuildPath: true 25 | settingFollowRedirects: global 26 | _type: request 27 | - _id: wrk_56e01358d8014e89a8547f8db1fe143d 28 | parentId: null 29 | modified: 1626374752190 30 | created: 1625249160253 31 | name: Sensors 32 | description: "" 33 | scope: collection 34 | _type: workspace 35 | - _id: req_91502f2a67f041fa917c566cfab545b0 36 | parentId: wrk_56e01358d8014e89a8547f8db1fe143d 37 | modified: 1626376589584 38 | created: 1625249205261 39 | url: http://localhost:8080/api/login 40 | name: Login 41 | description: "" 42 | method: POST 43 | body: 44 | mimeType: application/json 45 | text: |- 46 | { 47 | "username": "test_user", 48 | "password": "test_password" 49 | } 50 | parameters: [] 51 | headers: 52 | - name: Content-Type 53 | value: application/json 54 | id: pair_575730232ce7406d8a3c2d2c910869dd 55 | authentication: {} 56 | metaSortKey: -1625850869505 57 | isPrivate: false 58 | settingStoreCookies: true 59 | settingSendCookies: true 60 | settingDisableRenderRequestBody: false 61 | settingEncodeUrl: true 62 | settingRebuildPath: true 63 | settingFollowRedirects: global 64 | _type: request 65 | - _id: req_3676ee2b2db046eab011c372848c86ca 66 | parentId: wrk_56e01358d8014e89a8547f8db1fe143d 67 | modified: 1626287832165 68 | created: 1625249211133 69 | url: http://localhost:8080/api/logout 70 | name: Logout 71 | description: "" 72 | method: POST 73 | body: {} 74 | parameters: [] 75 | headers: [] 76 | authentication: {} 77 | metaSortKey: -1625850869480 78 | isPrivate: false 79 | settingStoreCookies: true 80 | settingSendCookies: true 81 | settingDisableRenderRequestBody: false 82 | settingEncodeUrl: true 83 | settingRebuildPath: true 84 | settingFollowRedirects: global 85 | _type: request 86 | - _id: req_33bc665aa3884be6b57d27db4d735a70 87 | parentId: wrk_56e01358d8014e89a8547f8db1fe143d 88 | modified: 1626374725031 89 | created: 1625754345878 90 | url: http://localhost:8080/api/sensor/ 91 | name: Create Sensor 92 | description: "" 93 | method: POST 94 | body: 95 | mimeType: application/json 96 | text: |- 97 | { 98 | "name": "API Test Sensor #2", 99 | "description": "Some new sensor.", 100 | "step": 900 101 | } 102 | parameters: [] 103 | headers: 104 | - name: Content-Type 105 | value: application/json 106 | id: pair_575730232ce7406d8a3c2d2c910869dd 107 | authentication: {} 108 | metaSortKey: -1625850869467.5 109 | isPrivate: false 110 | settingStoreCookies: true 111 | settingSendCookies: true 112 | settingDisableRenderRequestBody: false 113 | settingEncodeUrl: true 114 | settingRebuildPath: true 115 | settingFollowRedirects: global 116 | _type: request 117 | - _id: req_a93475ee022141428a0ab103b3a06f28 118 | parentId: wrk_56e01358d8014e89a8547f8db1fe143d 119 | modified: 1626368407996 120 | created: 1625886972305 121 | url: http://localhost:8080/sensor/upload 122 | name: Sensor Readings Upload 123 | description: "" 124 | method: POST 125 | body: 126 | mimeType: application/json 127 | text: |- 128 | [{"time": 1604883600, "value": 1.0}, 129 | {"time": 1604887200, "value": 2.0}, 130 | {"time": 1604890800, "value": 3.0}, 131 | {"time": 1604966400, "value": 8.0}, 132 | {"time": 1604970000, "value": 9.0}, 133 | {"time": 1604973600, "value": 10.0}, 134 | {"time": 1604977200, "value": 11.0}, 135 | {"time": 1604980800, "value": 12.0} 136 | ] 137 | parameters: [] 138 | headers: 139 | - id: pair_091333a853ef45d2ac43b50945a7fba2 140 | name: Authorization 141 | value: Bearer d08fe36b-06f7-477e-adce-cd96f3d7046a 142 | description: "" 143 | disabled: true 144 | - name: Content-Type 145 | value: application/json 146 | id: pair_096b17a2d6cb42cc8988bcd50401fc3c 147 | - id: pair_b7657077f2814354afe429dd34f0ce76 148 | name: Authorization 149 | value: Bearer 73f186a9-a367-4218-a2e8-f89da8e64715 150 | description: "" 151 | authentication: {} 152 | metaSortKey: -1625700454887 153 | isPrivate: false 154 | settingStoreCookies: true 155 | settingSendCookies: true 156 | settingDisableRenderRequestBody: false 157 | settingEncodeUrl: true 158 | settingRebuildPath: true 159 | settingFollowRedirects: global 160 | _type: request 161 | - _id: req_8a707e74176f491cb46e504acf14f90c 162 | parentId: wrk_56e01358d8014e89a8547f8db1fe143d 163 | modified: 1626375492899 164 | created: 1626216712129 165 | url: http://localhost:8080/api/sensor/1/readings 166 | name: API Readings Upload 167 | description: "" 168 | method: POST 169 | body: 170 | mimeType: application/json 171 | text: |- 172 | { 173 | "occurred": "2020-11-10", 174 | "readings": [ 175 | 4.0, 10.0, 12.0, 9.2, 13.1, 7.6, 176 | 4.0, 10.0, 12.0, 9.2, 13.1, 7.6, 177 | 4.0, 10.0, 12.0, 9.2, 13.1, 7.6, 178 | 4.0, 10.0, 12.0, 9.2, 13.1, 7.6 179 | ] 180 | } 181 | parameters: [] 182 | headers: 183 | - id: pair_091333a853ef45d2ac43b50945a7fba2 184 | name: Authorization 185 | value: Bearer d08fe36b-06f7-477e-adce-cd96f3d7046a 186 | description: "" 187 | disabled: true 188 | - name: Content-Type 189 | value: application/json 190 | id: pair_096b17a2d6cb42cc8988bcd50401fc3c 191 | - id: pair_b7657077f2814354afe429dd34f0ce76 192 | name: Authorization 193 | value: Bearer 73f186a9-a367-4218-a2e8-f89da8e64715 194 | description: "" 195 | authentication: {} 196 | metaSortKey: -1625625247603 197 | isPrivate: false 198 | settingStoreCookies: true 199 | settingSendCookies: true 200 | settingDisableRenderRequestBody: false 201 | settingEncodeUrl: true 202 | settingRebuildPath: true 203 | settingFollowRedirects: global 204 | _type: request 205 | - _id: req_43f05f4eb13b4665940ff8524172aca3 206 | parentId: wrk_56e01358d8014e89a8547f8db1fe143d 207 | modified: 1626375579056 208 | created: 1625866779922 209 | url: http://localhost:8080/api/sensor/1/readings?start=2020-11-01&end=2020-11-10 210 | name: Get Readings 211 | description: "" 212 | method: GET 213 | body: {} 214 | parameters: [] 215 | headers: [] 216 | authentication: {} 217 | metaSortKey: -1625550040319 218 | isPrivate: false 219 | settingStoreCookies: true 220 | settingSendCookies: true 221 | settingDisableRenderRequestBody: false 222 | settingEncodeUrl: true 223 | settingRebuildPath: true 224 | settingFollowRedirects: global 225 | _type: request 226 | - _id: req_6c5756dd41c043f7871e4bf215310f76 227 | parentId: wrk_56e01358d8014e89a8547f8db1fe143d 228 | modified: 1626362050314 229 | created: 1625701100940 230 | url: http://localhost:8080/api/user 231 | name: User 232 | description: "" 233 | method: GET 234 | body: {} 235 | parameters: [] 236 | headers: [] 237 | authentication: {} 238 | metaSortKey: -1625249211148.625 239 | isPrivate: false 240 | settingStoreCookies: true 241 | settingSendCookies: true 242 | settingDisableRenderRequestBody: false 243 | settingEncodeUrl: true 244 | settingRebuildPath: true 245 | settingFollowRedirects: global 246 | _type: request 247 | - _id: req_a8bef94034fb4a0ab4d71c89496478a9 248 | parentId: wrk_56e01358d8014e89a8547f8db1fe143d 249 | modified: 1626019846509 250 | created: 1625754381187 251 | url: http://localhost:8080/api/sensor/1 252 | name: Get Sensor 253 | description: "" 254 | method: GET 255 | body: {} 256 | parameters: [] 257 | headers: [] 258 | authentication: {} 259 | metaSortKey: -1625249211139.25 260 | isPrivate: false 261 | settingStoreCookies: true 262 | settingSendCookies: true 263 | settingDisableRenderRequestBody: false 264 | settingEncodeUrl: true 265 | settingRebuildPath: true 266 | settingFollowRedirects: global 267 | _type: request 268 | - _id: req_b4e677a62c004bc5a071eec441682573 269 | parentId: wrk_56e01358d8014e89a8547f8db1fe143d 270 | modified: 1626377315000 271 | created: 1625754394529 272 | url: http://localhost:8080/api/sensor/2 273 | name: Delete Sensor 274 | description: "" 275 | method: DELETE 276 | body: {} 277 | parameters: [] 278 | headers: [] 279 | authentication: {} 280 | metaSortKey: -1625249211136.125 281 | isPrivate: false 282 | settingStoreCookies: true 283 | settingSendCookies: true 284 | settingDisableRenderRequestBody: false 285 | settingEncodeUrl: true 286 | settingRebuildPath: true 287 | settingFollowRedirects: global 288 | _type: request 289 | - _id: env_bc705932b825c8fa7b07d12c9a6782981331948d 290 | parentId: wrk_56e01358d8014e89a8547f8db1fe143d 291 | modified: 1625249160362 292 | created: 1625249160362 293 | name: Base Environment 294 | data: {} 295 | dataPropertyOrder: null 296 | color: null 297 | isPrivate: false 298 | metaSortKey: 1625249160362 299 | _type: environment 300 | - _id: jar_bc705932b825c8fa7b07d12c9a6782981331948d 301 | parentId: wrk_56e01358d8014e89a8547f8db1fe143d 302 | modified: 1626377325751 303 | created: 1625249160366 304 | name: Default Jar 305 | cookies: [] 306 | _type: cookie_jar 307 | - _id: spc_446fbbe887d843228dd77fe17254a270 308 | parentId: wrk_56e01358d8014e89a8547f8db1fe143d 309 | modified: 1625249160264 310 | created: 1625249160264 311 | fileName: Smart Meter Project 312 | contents: "" 313 | contentType: yaml 314 | _type: api_spec 315 | --------------------------------------------------------------------------------