4
\$\begingroup\$

As a simple project to tackle during the holidays I've set to write a simple wrapper around Citymapper's api to familiarise with httr, testthat and a simple CI setup (on CircleCI).

The API mainly revolves around 2 things:

  • Cheking if a given point falls within their covered areas. As on their api docs there are 2 versions of this endpoint (a second one accepts multiple inputs via a JSON payload), I've implemented 2 different functions to reflect this, no idea if it'd make sense to collapse it into one.
  • Computing travel time (using public transport) between two points.

Below you can find the main methods, here a link to the repo.

get_travel_time.R

#' Get travel time between two points
#'
#' Computes estimated travel time between two points using public transport.
#' If \code{time} and \code{time_type} are not specified, time of travel will be assumed to be the same time at which the request is made.
#'
#' @param start_coord Geographical coordinates of the start point in WGS84 '<latitude>,<longitude>' format.
#' @param end_coord Geographical coordinates of the arrival point in WGS84 '<latitude>,<longitude>' format.
#' @param time A date & time in ISO-8601 format (e.g \code{2014-11-06T19:00:02-0500}). If omitted travel time will be computed for travel at the time of the request.
#' @param time_type Required if `time` is provided. At the moment the only defined type is `arrival`, it computes the travel time for arriving at `end_coord` at the given time.
#' @inheritParams citymappr_setup
#' @return Estimated travel time in minutes (int).
#'
#' @examples
#' \dontrun{
#' get_travel_time(start_coord = "45.448643,9.207645",
#'                 end_coord = "45.452349,9.180225")
#' }
#'
#'
#' @importFrom httr GET stop_for_status content
#' @importFrom jsonlite fromJSON
#'
#' @export
get_travel_time <- function(start_coord,
                            end_coord,
                            time="",
                            time_type="",
                            api_token = Sys.getenv("CITYMAPPER_API_TOKEN")) {


  resp <-GET(url = "https://developer.citymapper.com/api/1/traveltime/",
             query = list(
               key = api_token,
               startcoord = start_coord,
               endcoord = end_coord,
               time = time,
               time_type = time_type
             )
  )

  stop_for_status(resp)

  return(
    fromJSON(
      content(resp, "text"))$travel_time_minutes
  )

}

check_coverage.R

#' Check if a point is in covered area
#'
#' Checks if a given point falls within Citymapper's covered areas.
#' It is good practice to refresh this value regularly as covered areas might change.
#'
#' @inheritParams citymappr_setup
#' @param point Geographical coordinates of the point in WGS84 \code{'<latitude>,<longitude>'} format.
#' @return A tibble containing boolean responses for each point.
#'
#' @importFrom magrittr %>%
#' @importFrom rlang .data
#' @importFrom httr GET content stop_for_status
#' @importFrom jsonlite fromJSON
#' @importFrom dplyr mutate
#' @importFrom tibble as_tibble
#'
#' @examples
#' \dontrun{
#' check_coverage("45.448643,9.207645")
#' }
#'
#' @export
check_coverage <- function(point,
                           api_token=Sys.getenv("CITYMAPPER_API_TOKEN")) {

  resp <- GET(url="https://developer.citymapper.com/api/1/singlepointcoverage/",
              query = list(key = api_token,
                           coord = point))

  stop_for_status(resp)

  return(
    fromJSON(
      content(resp, "text"))[["points"]]$covered
  )

}

check_coverage_multi.R

#' Checks if multiple points are in covered area
#'
#' Checks if  multiple points fall within Citymapper's covered areas.
#' It is good practice to refresh this values regularly as covered areas might change.
#'
#' @inheritParams citymappr_setup
#' @param points Dataframe containing geographical coordinates of the start point in WGS84 \code{'<latitude>,<longitude>'} format.
#'   Columns should be set as \code{id (optional)} and \code{coord}.
#' @return A tibble containing boolean responses for each point. IDs column passed in \code{points} will be mirrored back in response.
#'
#' @examples
#' \dontrun{
#' # Make dataframes with coordinates and (optional) ids
#'
#' }
#'
#' @seealso \code{\link{check_coverage}}
#'
#' @importFrom magrittr %>%
#' @importFrom jsonlite fromJSON toJSON
#' @importFrom httr POST content stop_for_status
#' @importFrom tibble as_tibble
#'
#' @export
#TODO: there are problems with json parsin (coords are not parsed as a list)
check_coverage_multi <- function(points,
                                 api_token=Sys.getenv("CITYMAPPER_API_TOKEN")) {

  if (!is.data.frame(points)) {
    points <- as_tibble(points)
  }

  # Check if column name respect c("id", "coord") convention
  if(!all(c("coord") %in% names(points))) {
    stop("Dataframe has wrong column names, make sure they are set as c('id', 'coord')")
  }

  # Get input in required JSON format
  points$coord <- lapply(
    strsplit(as.character(points$coord), ","),
    as.numeric
  )

  cured_input <- toJSON(list(`points` = points))

  resp <- POST(paste0("https://developer.citymapper.com/api/1/coverage/?key=",
                      api_token),
               body = cured_input,
               encode="json")

  stop_for_status(resp)

  return(
    fromJSON(
      content(resp, "text"))[["points"]]
    )

}

Even being quite a simple package, it'd be good to understand if project structure makes sense and if there's any room for improvement.

| improve this question | | | | |
\$\endgroup\$
  • \$\begingroup\$ By the way, on L41 of check_coverage_multi.R, are you sure you don't mean !all(c("id", "coord") %in% names(points))? \$\endgroup\$ – Droplet Jan 9 at 12:21
  • \$\begingroup\$ Good questions, I am checking just for the coord columns as it's mandatory (id is optional as it'll be just mirrored back from the endpoint for quick reference). Is there a more elegant solution? I do believe I left that if(!all(...)) there as I began checking for both fields in the first place. \$\endgroup\$ – anddt Jan 9 at 12:44
2
\$\begingroup\$

I think overall, it looks quite good. Some polishing is needed before you submit to CRAN (e.g. you didn't update the Title and Description fields in your DESCRIPTION). Below are also specific points you might want to consider.

Structure of your specific project

  • In terms of structure, I don't really understand the need of having two separate functions for checking coverage for a single point and for multiple points. I know the API has two different URLs for this but surely, it would be simpler for users if they have a single function that works no matter if they provide one or multiple points. Or am I missing something here?

  • Currently, if the user didn't register an API key, they get a cryptic error message (Forbidden (HTTP 403)). It might be better to check if api_token is empty and return a more specific error message.

  • You seem to import a lot of packages and functions that (as I far as I can tell) you're not using. If this is actually the case, it might be good to remove them from your dependency list.

General package structure comments

  • Add a BugReports field to your DESCRIPTION to guide users to the appropriate place to report issues and contribute patches.

  • According to CRAN, your LICENSE file should only contain two lines with your name and the year, not the full license text.

  • You have non-standard files in your package root, namely circle.yml (should be add to .Rbuildignore) and citymappR_logo.png (should be moved to man/figures).

Specific points relevant to API packages:

Optional:

| improve this answer | | | | |
\$\endgroup\$
  • \$\begingroup\$ Many thanks for you time. I don't think I'll submit it to CRAN as it is just a pet project I've developed during some free time. Thanks for the spot-on feedback, especially regarding folder structure and the custom user-agent. \$\endgroup\$ – anddt Jan 6 at 15:32

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.