From 48bcccaeb65233bfe813869af6058c6daaf18023 Mon Sep 17 00:00:00 2001 From: Vince Forgione Date: Tue, 23 Oct 2018 16:08:38 -0500 Subject: [PATCH] Initial Commit Ok, so I've never written a line of R before. This kinda sorta works for really simple use cases. For more advanced stuff (like aggregates) I need to fix some things. There also appears to be a randomly occurring bug parsing timestamps when the response data is empty. --- .Rbuildignore | 2 + .gitignore | 4 + AotClient.Rproj | 20 +++ DESCRIPTION | 13 ++ LICENSE | 13 ++ NAMESPACE | 10 ++ R/AotClient.R | 276 +++++++++++++++++++++++++++++++++++ README.md | 43 ++++++ man/ls.nodes.Rd | 19 +++ man/ls.observations.Rd | 19 +++ man/ls.projects.Rd | 19 +++ man/ls.raw_observations.Rd | 21 +++ man/ls.sensors.Rd | 21 +++ man/stat.node.Rd | 21 +++ man/stat.project.Rd | 21 +++ man/stat.sensor.Rd | 23 +++ tests/testthat.R | 4 + tests/testthat/test_client.R | 104 +++++++++++++ 18 files changed, 653 insertions(+) create mode 100644 .Rbuildignore create mode 100644 .gitignore create mode 100644 AotClient.Rproj create mode 100644 DESCRIPTION create mode 100644 LICENSE create mode 100644 NAMESPACE create mode 100644 R/AotClient.R create mode 100644 README.md create mode 100644 man/ls.nodes.Rd create mode 100644 man/ls.observations.Rd create mode 100644 man/ls.projects.Rd create mode 100644 man/ls.raw_observations.Rd create mode 100644 man/ls.sensors.Rd create mode 100644 man/stat.node.Rd create mode 100644 man/stat.project.Rd create mode 100644 man/stat.sensor.Rd create mode 100644 tests/testthat.R create mode 100644 tests/testthat/test_client.R diff --git a/.Rbuildignore b/.Rbuildignore new file mode 100644 index 0000000..91114bf --- /dev/null +++ b/.Rbuildignore @@ -0,0 +1,2 @@ +^.*\.Rproj$ +^\.Rproj\.user$ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b6a065 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.Rproj.user +.Rhistory +.RData +.Ruserdata diff --git a/AotClient.Rproj b/AotClient.Rproj new file mode 100644 index 0000000..497f8bf --- /dev/null +++ b/AotClient.Rproj @@ -0,0 +1,20 @@ +Version: 1.0 + +RestoreWorkspace: Default +SaveWorkspace: Default +AlwaysSaveHistory: Default + +EnableCodeIndexing: Yes +UseSpacesForTab: Yes +NumSpacesForTab: 2 +Encoding: UTF-8 + +RnwWeave: Sweave +LaTeX: pdfLaTeX + +AutoAppendNewline: Yes +StripTrailingWhitespace: Yes + +BuildType: Package +PackageUseDevtools: Yes +PackageInstallArgs: --no-multiarch --with-keep.source diff --git a/DESCRIPTION b/DESCRIPTION new file mode 100644 index 0000000..35e4a88 --- /dev/null +++ b/DESCRIPTION @@ -0,0 +1,13 @@ +Package: AotClient +Type: Package +Title: Office Array of Things API Client +Version: 0.1.0 +Author: Vince Forgione +Maintainer: "Vince Forgione" +Description: HTTP API Client +License: Apache 2 +Encoding: UTF-8 +LazyData: true +Suggests: + testthat +RoxygenNote: 6.1.0 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e467085 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2018 University of Chicago + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/NAMESPACE b/NAMESPACE new file mode 100644 index 0000000..8c9514c --- /dev/null +++ b/NAMESPACE @@ -0,0 +1,10 @@ +# Generated by roxygen2: do not edit by hand + +export(ls.nodes) +export(ls.observations) +export(ls.projects) +export(ls.raw_observations) +export(ls.sensors) +export(stat.node) +export(stat.project) +export(stat.sensor) diff --git a/R/AotClient.R b/R/AotClient.R new file mode 100644 index 0000000..7c77e63 --- /dev/null +++ b/R/AotClient.R @@ -0,0 +1,276 @@ + +#' Timestamped message -- primarily used to push error output to user +#' +#' @param msg - The message to be logged +#' @return None (invisible NULL) as per cat +#' @noRd +log_msg <- function (msg) { + cat(format(Sys.time(), "%Y-%m-%d %H:%M:%OS3 "), ": ", msg, "\n", sep="") +} + + +#' Sends a request to the API, ensures 200 response and returns the response +#' +#' Given a URL and optional filters/query params, this sends an HTTP GET request +#' to the URL. The response"s status is checked -- if it isn"t 200 then an +#' error message is logged and the process halts; it it"s 200 then the entire +#' response object is returned. +#' +#' @param url - The URL to send the request to +#' @param filters - A list of tuples to build filters/query params +#' @return The entire response +#' @noRd +send_request <- function (url, filters = NULL) { + # send request; get response + if (!is.null(filters)) { + resp <- httr::GET(url, query=filters) + } else { + resp <- httr::GET(url) + } + + # if not 200, log error + if (resp$status_code != 200) { + msg <- paste("Error in httr GET:", rep$status_code, rep$headers$statusmessage, url) + if(!is.null(rep$headers$`content-length`) && (rep$headers$`content-length` > 0)) { + details <- httr::content(rep) + msg <- paste(msg, details) + } + log_msg(msg) + } + + # stop or return + httr::stop_for_status(resp) + return(resp) +} + + +#' Parses a response object as JSON and returns the `data` object +#' +#' @param resp - The response object +#' @return The parsed JSON body +#' @noRd +parse_content <- function (resp) { + content <- httr::content(resp, as="text") + json <- jsonlite::fromJSON(content) + data <- json$data + return(data) +} + + +#' Sends a request and parses the result as a single map object +#' +#' Given a URL and optional filters, a request is sent and the response +#' is processed as a single map object -- the response content has a +#' `data` key that maps an object representing details for the metadata +#' record requested. +#' +#' @param url - The URL to send the request to +#' @param filters - A list of tuples to build query params +#' @return The metadata details +#' @noRd +stat <- function (url, filters) { + resp <- send_request(url, filters) + details <- parse_content(resp) + return(details) +} + + +#' Gets a data frame of `project` metadata +#' +#' Projects are the highest entity in the hierarchy of the Array of +#' Things system. They are generally ambiguous geographic regions used +#' to roughly group nodes. +#' +#' @param filters - A list of tuples to create filters/query params +#' @return A data frame of project metadata +#' @export +ls.projects <- function (filters = NULL) { + # build url, send request, get response + url <- "https://api.arrayofthings.org/api/projects" + resp <- send_request(url, filters) + + # build data frame + data <- parse_content(resp) + df <- as.data.frame.list(data) + attr(df, "name") <- data$name + attr(df, "slug") <- data$slug + attr(df, "first_observation") <- as.POSIXlt(data$first_observation) + attr(df, "latest_observation") <- as.POSIXlt(data$latest_observation) + attr(df, "hull") <- data$hull + + # return data frame + return(df) +} + + +#' Gets the details for a single `project` metadata record +#' +#' Projects are the highest entity in the hierarchy of the Array of +#' Things system. They are generally ambiguous geographic regions used +#' to roughly group nodes. +#' +#' @param slug - The project"s unique identifier +#' @param filters - A list of tuples to create filters/query params +#' @return A list representing the project +#' @export +stat.project <- function (slug, filters = NULL) { + # build url, send request, get response + url <- paste("https://api.arrayofthings.org/api/projects/", slug, sep="") + details <- stat(url, filters) + return(details) +} + + +#' Gets a data frame of `node` metadata +#' +#' Nodes are the physical devices deployed to collect observations. +#' The are comprised of multiple sensors and are grouped by +#' projects. +#' +#' @param filters - A list of tuples to create filters/query params +#' @return A data frame of node metadata +#' @export +ls.nodes <- function (filters = NULL) { + # build url, send request, get response + url <- "https://api.arrayofthings.org/api/nodes" + resp <- send_request(url, filters) + + # build data frame + data <- parse_content(resp) + df <- as.data.frame.list(data) + attr(df, "vsn") <- data$vsn + attr(df, "location") <- data$location + attr(df, "human_address") <- data$human_address + attr(df, "description") <- data$description + attr(df, "commissioned_on") <- as.POSIXlt(data$commissioned_on) + attr(df, "decommissioned_on") <- as.POSIXlt(data$decommissioned_on) + + # return data frame + return(df) +} + + +#' Gets the details for a single `node` metadata record +#' +#' Nodes are the physical devices deployed to collect observations. +#' The are comprised of multiple sensors and are grouped by +#' projects. +#' +#' @param vsn - The node"s unique identifier +#' @param filters - A list of tuples to create filters/query params +#' @return A list representing the node +#' @export +stat.node <- function (vsn, filters = NULL) { + url <- paste("https://api.arrayofthings.org/api/nodes/", vsn, sep="") + details <- stat(url, filters) + return(details) +} + + +#' Gets a data frame of `sensor` metadata +#' +#' Sensors are the physical boards inside the nodes that record +#' the observations. Sensors are in various states of being tuned +#' and therefor some of their observations are considered to be +#' experimental. Trustworthy data is listed under the _observations_ +#' endpoint and experimental data is under _raw-observations_. +#' +#' @param filters - A list of tuples to create filters/query params +#' @return A data frame of sensor metadata +#' @export +ls.sensors <- function (filters = NULL) { + # build url, send request, get response + url <- "https://api.arrayofthings.org/api/sensors" + resp <- send_request(url, filters) + + # build data frame + data <- parse_content(resp) + df <- as.data.frame.list(data) + attr(df, "path") <- data$path + attr(df, "subsystem") <- data$subsystem + attr(df, "sensor") <- data$sensor + attr(df, "parameter") <- data$parameter + attr(df, "uom") <- data$uom + attr(df, "min") <- data$min + attr(df, "max") <- data$max + attr(df, "data_sheet") <- data$data_sheet + + # return data frame + return(df) +} + + +#' Gets the details for a single `sensor` metadata record +#' +#' Sensors are the physical boards inside the nodes that record +#' the observations. Sensors are in various states of being tuned +#' and therefor some of their observations are considered to be +#' experimental. Trustworthy data is listed under the _observations_ +#' endpoint and experimental data is under _raw-observations_. +#' +#' @param path - The sensor"s unique identifier +#' @param filters - A list of tuples to create filters/query params +#' @return A list representing the sensor +#' @export +stat.sensor <- function (path, filters = NULL) { + url <- paste("https://api.arrayofthings.org/api/sensors/", path, sep="") + details <- stat(url, filters) + return(details) +} + + +#' Gets a data frame of `obserations` data. +#' +#' Observation data are the environmental measurements made +#' by the sensors. Data listed here is more or less tuned +#' and trustworthy. +#' +#' @param filters - A list of tuples to create filters/query params +#' @return A data frame of observation data +#' @export +ls.observations <- function (filters = NULL) { + # build url, send request, get response + url <- "https://api.arrayofthings.org/api/observations" + resp <- send_request(url, filters) + + # build data frame + data <- parse_content(resp) + df <- as.data.frame.list(data) + attr(df, "node_vsn") <- data$node_vsn + attr(df, "sensor_path") <- data$sensor_path + attr(df, "timestamp") <- as.POSIXlt(data$timestamp) + attr(df, "value") <- data$value + + # return data frame + return(df) +} + + +#' Gets a data frame of `raw observations` data. +#' +#' Raw observation data are the environmental measurements made +#' by the sensors. Data listed here can be both tuned and not-yet +#' tuned data. The values of `raw` are the analog readings made by +#' the sensor; `hrf` (human readable format) are the clean/trusted +#' values -- these are typically null. +#' +#' @param filters - A list of tuples to create filters/query params +#' @return A data frame of project metadata +#' @export +ls.raw_observations <- function (filters = NULL) { + # build url, send request, get response + url <- "https://api.arrayofthings.org/api/raw-observations" + resp <- send_request(url, filters) + + # build data frame + data <- parse_content(resp) + df <- as.data.frame.list(data) + attr(df, "node_vsn") <- data$node_vsn + attr(df, "sensor_path") <- data$sensor_path + attr(df, "timestamp") <- as.POSIXlt(data$timestamp) + attr(df, "hrf") <- data$hrf + attr(df, "raw") <- data$raw + + # return data frame + return(df) +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..83e6aec --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# Array of Things Client + +This library serves as the official R client to the [Array of Things API](https://api.arrayofthings.org/). + +## Using the Library + +This isn't listed with CRAN yet (because it's awful -- I'm not an R developer). You _can_ install it +from GitHub though: + +```R +devtools::install_github("UbranCCD-UChicago/aot-client-r") +``` + +There are two general types of functions presented: _ls_ and _stat_. You should use the ls functions +to work with the list endpoints, and stat is for details: + +- `ls.projects` to get a list of projects +- `ls.nodes` to get a list of nodes +- `ls.sensors` to get a list of sensors +- `ls.observations` to get the observation data +- `ls.raw_observations` to get the raw observation data +- `stat.project` to get details for a single project +- `stat.node` to get details for a single node +- `stat.sensor` to get details for a single sensor + +The _stat_ functions require a unique id for the type of metadata you're looking for -- `slug` for +projects, `vsn` for nodes, and `path` for sensors. + +All of the functions allow you to add arbitrary filters/parameters as well: + +```R +# sensors onboard node 004 +df <- ls.sensors(filters=list(onboard_node="004")) + +# note: this doesn't work quite right but it will soon +# average temperature observations made in august per node +df <- ls.observations(filters=list( + by_sensor="metsense.bmp180.temperature", + timestamp="ge:2018-08-01T00:00:00", + timestamp="lt:2018-09-01T00:00:00", + value="avg:node_vsn" +)) +``` diff --git a/man/ls.nodes.Rd b/man/ls.nodes.Rd new file mode 100644 index 0000000..1eee68b --- /dev/null +++ b/man/ls.nodes.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/AotClient.R +\name{ls.nodes} +\alias{ls.nodes} +\title{Gets a data frame of `node` metadata} +\usage{ +ls.nodes(filters = NULL) +} +\arguments{ +\item{filters}{- A list of tuples to create filters/query params} +} +\value{ +A data frame of node metadata +} +\description{ +Nodes are the physical devices deployed to collect observations. +The are comprised of multiple sensors and are grouped by +projects. +} diff --git a/man/ls.observations.Rd b/man/ls.observations.Rd new file mode 100644 index 0000000..47d5c10 --- /dev/null +++ b/man/ls.observations.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/AotClient.R +\name{ls.observations} +\alias{ls.observations} +\title{Gets a data frame of `obserations` data.} +\usage{ +ls.observations(filters = NULL) +} +\arguments{ +\item{filters}{- A list of tuples to create filters/query params} +} +\value{ +A data frame of observation data +} +\description{ +Observation data are the environmental measurements made +by the sensors. Data listed here is more or less tuned +and trustworthy. +} diff --git a/man/ls.projects.Rd b/man/ls.projects.Rd new file mode 100644 index 0000000..5dfe9d6 --- /dev/null +++ b/man/ls.projects.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/AotClient.R +\name{ls.projects} +\alias{ls.projects} +\title{Gets a data frame of `project` metadata} +\usage{ +ls.projects(filters = NULL) +} +\arguments{ +\item{filters}{- A list of tuples to create filters/query params} +} +\value{ +A data frame of project metadata +} +\description{ +Projects are the highest entity in the hierarchy of the Array of +Things system. They are generally ambiguous geographic regions used +to roughly group nodes. +} diff --git a/man/ls.raw_observations.Rd b/man/ls.raw_observations.Rd new file mode 100644 index 0000000..f16c22f --- /dev/null +++ b/man/ls.raw_observations.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/AotClient.R +\name{ls.raw_observations} +\alias{ls.raw_observations} +\title{Gets a data frame of `raw observations` data.} +\usage{ +ls.raw_observations(filters = NULL) +} +\arguments{ +\item{filters}{- A list of tuples to create filters/query params} +} +\value{ +A data frame of project metadata +} +\description{ +Raw observation data are the environmental measurements made +by the sensors. Data listed here can be both tuned and not-yet +tuned data. The values of `raw` are the analog readings made by +the sensor; `hrf` (human readable format) are the clean/trusted +values -- these are typically null. +} diff --git a/man/ls.sensors.Rd b/man/ls.sensors.Rd new file mode 100644 index 0000000..0177205 --- /dev/null +++ b/man/ls.sensors.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/AotClient.R +\name{ls.sensors} +\alias{ls.sensors} +\title{Gets a data frame of `sensor` metadata} +\usage{ +ls.sensors(filters = NULL) +} +\arguments{ +\item{filters}{- A list of tuples to create filters/query params} +} +\value{ +A data frame of sensor metadata +} +\description{ +Sensors are the physical boards inside the nodes that record +the observations. Sensors are in various states of being tuned +and therefor some of their observations are considered to be +experimental. Trustworthy data is listed under the _observations_ +endpoint and experimental data is under _raw-observations_. +} diff --git a/man/stat.node.Rd b/man/stat.node.Rd new file mode 100644 index 0000000..4e64087 --- /dev/null +++ b/man/stat.node.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/AotClient.R +\name{stat.node} +\alias{stat.node} +\title{Gets the details for a single `node` metadata record} +\usage{ +stat.node(vsn, filters = NULL) +} +\arguments{ +\item{vsn}{- The node"s unique identifier} + +\item{filters}{- A list of tuples to create filters/query params} +} +\value{ +A list representing the node +} +\description{ +Nodes are the physical devices deployed to collect observations. +The are comprised of multiple sensors and are grouped by +projects. +} diff --git a/man/stat.project.Rd b/man/stat.project.Rd new file mode 100644 index 0000000..8ca70b6 --- /dev/null +++ b/man/stat.project.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/AotClient.R +\name{stat.project} +\alias{stat.project} +\title{Gets the details for a single `project` metadata record} +\usage{ +stat.project(slug, filters = NULL) +} +\arguments{ +\item{slug}{- The project"s unique identifier} + +\item{filters}{- A list of tuples to create filters/query params} +} +\value{ +A list representing the project +} +\description{ +Projects are the highest entity in the hierarchy of the Array of +Things system. They are generally ambiguous geographic regions used +to roughly group nodes. +} diff --git a/man/stat.sensor.Rd b/man/stat.sensor.Rd new file mode 100644 index 0000000..cdf85dc --- /dev/null +++ b/man/stat.sensor.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/AotClient.R +\name{stat.sensor} +\alias{stat.sensor} +\title{Gets the details for a single `sensor` metadata record} +\usage{ +stat.sensor(path, filters = NULL) +} +\arguments{ +\item{path}{- The sensor"s unique identifier} + +\item{filters}{- A list of tuples to create filters/query params} +} +\value{ +A list representing the sensor +} +\description{ +Sensors are the physical boards inside the nodes that record +the observations. Sensors are in various states of being tuned +and therefor some of their observations are considered to be +experimental. Trustworthy data is listed under the _observations_ +endpoint and experimental data is under _raw-observations_. +} diff --git a/tests/testthat.R b/tests/testthat.R new file mode 100644 index 0000000..3d95439 --- /dev/null +++ b/tests/testthat.R @@ -0,0 +1,4 @@ +library(testthat) +library(AotClient) + +test_check("AotClient") diff --git a/tests/testthat/test_client.R b/tests/testthat/test_client.R new file mode 100644 index 0000000..61018c8 --- /dev/null +++ b/tests/testthat/test_client.R @@ -0,0 +1,104 @@ +context("ls.projects") + +test_that("returns a data frame", { + df <- ls.projects() + expect_equal("data.frame", class(df), label="class") +}) + + +context("stat.project") + +test_that("returns a list", { + chi <- stat.project("chicago") + expect_equal("list", class(chi), label="class") +}) + + +context("ls.nodes") + +test_that("returns a data frame", { + df <- ls.nodes() + expect_equal("data.frame", class(df), label="class") +}) + +test_that("apply filters", { + all <- ls.nodes() + co <- ls.nodes(filters=list(has_sensor="chemsense.co.concentration")) + expect_equal(nrow(all) >= nrow(co), TRUE) +}) + +test_that("unknown filter values return 200 and 0 length arrays", { + zilch <- ls.nodes(filters=list(has_sensor="barf.o.meter")) + expect_equal(nrow(zilch), 0) +}) + + +context("stat.node") + +test_that("returns a list", { + node <- stat.node("004") + expect_equal("list", class(node), label="class") +}) + + +context("ls.sensors") + +test_that("returns a data frame", { + df <- ls.sensors() + expect_equal("data.frame", class(df), label="class") +}) + +test_that("apply filters", { + all <- ls.sensors() + n004 <- ls.sensors(filters=list(onboard_node="004")) + expect_equal(nrow(all) >= nrow(n004), TRUE) +}) + +test_that("unknown filter values return 200 and 0 length arrays", { + zilch <- ls.sensors(filters=list(onboard_node="666")) + expect_equal(nrow(zilch), 0) +}) + + +context("stat.sensor") + +test_that("returns a list", { + sensor <- stat.sensor("metsense.bmp180.temperature") + expect_equal("list", class(sensor), label="class") +}) + + +context("ls.observations") + +test_that("returns a data frame", { + df <- ls.observations() + expect_equal("data.frame", class(df), label="class") +}) + +test_that("apply filters", { + df <- ls.observations(filters=list(by_sensor="metsense.bmp180.temperature")) + expect_equal("list", class(df), label="class") +}) + +test_that("unknown filter values return 200 and 0 length arrays", { + zilch <- ls.observations(filters=list(by_sensor="barf.o.meter")) + expect_equal(nrow(zilch), 0) +}) + + +context("ls.raw_observations") + +test_that("returns a data frame", { + df <- ls.raw_observations() + expect_equal("data.frame", class(df), label="class") +}) + +test_that("apply filters", { + df <- ls.raw_observations(filters=list(by_sensor="metsense.bmp180.temperature")) + expect_equal("list", class(df), label="class") +}) + +test_that("unknown filter values return 200 and 0 length arrays", { + zilch <- ls.raw_observations(filters=list(by_sensor="barf.o.meter")) + expect_equal(nrow(zilch), 0) +})