Project 5: Build a Package

A teal hexagon in the style of an R package sticker, with a large yellow question mark in the middle and the words 'your package' along the bottom.

Every package you’ve used this semester started as someone’s functions for a job they kept doing by hand. In this project you’ll write your own: an R package that wraps a web API, with documentation, tests, a data set it built, and a README that shows how to use it.

By completing this project, you will:

Choose your route

Build a package of your own design, or take the BART route, where the API, the question, and the functions are chosen for you. The tabs below all switch together.

Pick a public API with data you’re curious about, and a question it could answer. These work from R without a key:

API Example question
Open-Meteo (weather) How much warmer are Berkeley’s Septembers now than in the 1990s?
USGS Earthquakes Where in California did this year’s earthquakes over magnitude 3 happen?
iNaturalist (wildlife sightings) When in the year are monarch butterflies seen in the Bay Area?
Open Library (books) How has the length of an author’s books changed over their career?
Federal Register (federal rules) Which agencies publish the most rules on wildfire?

APIs with a free key, like NASA’s or FRED’s, are fine too (see “If your API needs a key” below). Your API must:

  • answer requests built with request() and req_url_query() with JSON;
  • be free, with no sign-in or “authorize an app” step (so not Spotify, Google, or Reddit);
  • allow enough requests to build your data set (check its rate limit).

A white BART train with the blue BART logo pulls into an elevated station platform.

Many Berkeley students live farther out and ride BART to Downtown Berkeley. Your question:

Which BART stations could you live near and still make a 9:00 am class on campus, and what would the commute cost you each week?

Your package, bartcommute, has two functions, bart_stations() and bart_trip(), specified in Part 1.

What your package needs

Your deliverable is your repo: an R package that installs from GitHub and

  1. is useful or interesting: its functions together answer a question, and at least one takes arguments that change what it does (Parts 1 and 3);
  2. calls a web API (Part 1);
  3. has at least two exported functions, at least one of which wraps the API and returns a tidy data frame with the right types. The other may wrap the API too, or summarize or plot your package’s data (Part 1);
  4. stores a data set built with its own functions, from at least 10 requests and under 5 MB (Part 3);
  5. is documented: Roxygen for every exported function (title, @param, @returns, @examples, @export) and for the data set (@format, @source) (Parts 1 and 3);
  6. has passing tests of a typical input, an edge case, and an error (Part 2);
  7. has a complete DESCRIPTION and NAMESPACE, so that check() reports no errors or warnings (Parts 1 and 4);
  8. has a README that explains the package and walks through examples and your analysis (Part 5).

Optionally, it also has a pkgdown website (see the end of this page).

Your template repo

Your template repo is an R package. Its project.qmd renders to README.md, the front page of your repo on GitHub (like dplyr’s). It holds:

  • DESCRIPTION, NAMESPACE, and LICENSE: a package skeleton. You’ll fill in DESCRIPTION and LICENSE.
  • project.qmd: your README’s source. Its top comment explains the setup.
  • .Rbuildignore: files in your repo that aren’t part of the package. Leave it as it is.

A key works like a password, so it must never appear in your repo.

  1. Run usethis::edit_r_environ(), add a line like NASA_KEY=abc123, save, and restart R. .Renviron lives in your home folder, so git never sees it.
  2. Give each function a key argument whose default is Sys.getenv("NASA_KEY"), and stop() with a clear message when it’s "".
  3. If a key is ever pushed, replace it: deleting it later doesn’t remove it from your repo’s history.
  4. Start each test that calls the API with skip_if(Sys.getenv("NASA_KEY") == "", "no API key"), and use @examplesIf nzchar(Sys.getenv("NASA_KEY")) instead of @examples, so someone without your key, like your GSI, gets skips instead of failures.
  5. In your README, say where to get a key and what to name it.

Part 1: Wrap the API

Write functions that send requests to the API and return tidy data frames, and document them as you go.

Steps

  1. Fill in DESCRIPTION (Package, Title, Authors@R, Description) and put your name in LICENSE. On your own route, choose a package name of letters and numbers and use it as the title of project.qmd.
  2. Run usethis::use_package("httr2"). Whenever your functions call another package, run it for that package too. Inside a package, call functions with :: (httr2::request()), never library().
  1. Send a request in a browser, then from R with req_url_query() and resp_body_json(simplifyVector = TRUE), as in APIs I. Find the path of $s to your data.

  2. Note the type of each value you’ll keep, and which need converting (numbers stored as strings, dates, codes).

  3. In a file made with use_r(), write your first function. It takes arguments that change the request and returns a data frame with one row per observation and a column of the right type for each value. Give it a Roxygen block: a title, @params, @returns, @examples, and @export.

  4. Write a second function. It can wrap the API too (another endpoint, as bart_stations() feeds bart_trip()), or summarize or plot your package’s data: it takes your first function’s output or your data set, plus an argument that changes the result, like which column to plot. Document and export it like the first. Inside a package, name columns in dplyr and ggplot2 with the .data pronoun, so check() knows they’re columns:

    ggplot(trips, aes(x = .data$minutes, y = .data$fare)) +
      geom_point()

The trip planner is the endpoint sched.aspx. This request asks for the last trip from Millbrae (MLBR) that reaches Downtown Berkeley (DBRK) by 8:45 am on November 18, 2026, 15 minutes before a 9:00 am class:

https://api.bart.gov/api/sched.aspx?cmd=arrive&orig=MLBR&dest=DBRK&date=11/18/2026&time=8:45am&b=0&a=0&key=MW9S-E7SL-26DU-VV8V&json=y

cmd=arrive asks for trips that arrive by time, and b=0&a=0 returns only the last of them. (BART’s documentation describes b and a differently; this is how they behave.)

  1. Send the request in a browser, then from R as in APIs I, and find the path of $s to the trip. Look at the start of the response to see why so many names begin with @.

  2. Work out which of @fare, @clipper, and fares is the adult Clipper fare, and check it on bart.gov’s trip planner.

  3. In R/stations.R (use_r("stations")), write bart_stations(). Its one argument, key, defaults to BART’s public key, "MW9S-E7SL-26DU-VV8V". It requests the station list from PS 6 and returns one row per station with the columns abbr, name, city, county, lat, and lon, with lat and lon (BART’s gtfs_latitude and gtfs_longitude) as numbers. Give it a Roxygen block: a title, @param, @returns, @examples, and @export.

  4. In R/trip.R, write bart_trip():

    bart_trip <- function(orig, dest = "DBRK", arrive_by = "8:45am",
                          date = "11/18/2026", key = "MW9S-E7SL-26DU-VV8V") {
      # Your code here.
    }

    It sends the request above with its arguments filled in, and returns one row:

    Column Holds
    orig, dest the abbreviations of the stations the trip starts and ends at
    depart, arrive when the trip leaves orig and reaches dest, as date-times in Pacific time
    minutes the length of the trip, in minutes
    fare the fare with a Clipper card, in dollars
    transfers the number of times you change trains

    Every value BART sends is a string, so convert minutes and fare to numbers. lubridate::mdy_hm() parses "11/18/2026 07:31 AM", and each train you ride is a row of the trip’s leg. Give each argument an @param.

  1. Run document() and load_all(), and try each function and its help page. Then run check() and fix everything it reports. (A “no visible binding for global variable” note means a function names a column bare, as in filter(fare > 5); write .data$fare instead.)

Part 2: Test It

Test your functions on typical inputs, edge cases, and errors, as in the Testing lecture. Because the API’s data changes, don’t test today’s exact values: test what your function promises, the shape and types of its output and rules every response follows, which expect_true(all(...)) checks for every value at once. For celsius() from PS 6:

test_that("temperatures above freezing are above 0 °C", {
  temps <- celsius(c(33, 50, 72, 98.6, 212))
  expect_true(all(temps > 0))
})

Steps

  1. Run use_testthat(), then use_test() for each file in R/.
  1. Typical input, shape: test for the columns you documented, in order, with the right types.
  2. Typical input, rules: test at least two rules every response follows, like dates in the range you asked for, counts that are never negative, or IDs that are unique.
  3. Edge case: find an input that makes the output unusual (no matches, one match, a missing value), decide what your function should return, and test it.
  4. Errors: make your function stop() with a clear message on bad input (the wrong type, more than one value), and test each case with expect_error() and its message.
  5. Hunt for a bug on inputs you didn’t design for, like every value of a lookup or the ends of a date range. Write a failing test first, then fix the function until it passes.

If your second function summarizes or plots, test it with steps 2–5 too. For a plot, expect_true(inherits(p, "ggplot")) checks that it made one; for a summary, check its columns and number of rows.

  1. Typical input, shape: test that bart_stations() returns Part 1’s six columns, in order, with lat and lon as numbers, and that bart_trip("MLBR") returns one row with Part 1’s seven columns, in order.
  2. Typical input, rules: test at least two rules every trip follows, like arriving by arrive_by, minutes matching difftime(arrive, depart, units = "mins"), or a positive fare and minutes.
  3. Edge cases: test a trip with no transfers (from MLBR) and one with a transfer (from DUBL).
  4. Errors: make bart_trip() stop() with a clear message when orig isn’t a single string, and test bart_trip(c("MLBR", "DUBL")) with expect_error() and your message. Then test that bart_trip("XXXX"), a station that doesn’t exist, gives an error too: BART’s own HTTP 400 message is fine there.
  5. A classmate reports: “bart_trip() stopped at Downtown Berkeley with HTTP 400 Bad Request.” Decide what bart_trip("DBRK") should do (open the Part 1 URL with orig=DBRK to see why BART refuses). Write a failing test first, then fix bart_trip() until it passes.
  1. Run test() and check(), and fix anything that fails.

Part 3: Store and Answer

Build a data set with your functions, store it in your package, and use it to answer your question.

Steps

  1. Build your data set by calling your functions many times. Run usethis::use_data_raw("name"), naming your data set (the BART route’s is commutes); it creates the script data-raw/name.R. Start it with devtools::load_all(), so the script can find your functions. Then call your function once for each thing you want to ask about (each date, place, or name), and stack the results into one data frame. It’s the pattern you used in Project 3, with requests in place of files:

    # Project 3: read one file per path
    events <- lapply(paths, fromJSON) |>
      bind_rows()
    
    # Here: send one request per input
    results <- lapply(inputs, your_function) |>
      bind_rows()

    End the script with usethis::use_data(name, overwrite = TRUE), which saves the data frame in your package as data/name.rda, and run the script once at the console. Your package now carries a snapshot of the data, so your analysis never has to ask the API again. (data-raw/ isn’t part of the installed package.)

    Size: build it from at least 10 requests, and keep data/name.rda under 5 MB (check with file.size("data/name.rda") / 1e6). GitHub refuses files over 100 MB, and CRAN keeps a package’s data under 5 MB. If yours is too big, keep only the rows and columns your analysis uses. Stay within your API’s rate limit: a few hundred requests is plenty.

  2. Document the data set in R/data.R with a title, @format (every column, with units), and @source (where and when you got it). Run document() only after use_data(): it errors if the data set doesn’t exist yet.

  1. Make a plot that speaks to your question.
  2. Find a place where the data doesn’t do what you’d expect, show it in a plot or table, and explain it.
  3. Answer your question in a paragraph, citing your plots, and name one limitation of the data.

In step 1, commutes joins bart_stations() to each station’s bart_trip(), using lapply(stations$abbr, bart_trip) (50 requests).

  1. Map the stations colored by commute time (st_as_sf(coords = c("lon", "lat"), crs = 4326), then geom_sf()), and make a table of those within 30 minutes.
  2. Add a weekly cost (a round trip, five days a week) and plot commute time against it, colored by transfers. Find the stations that break the pattern and explain them with bart.gov’s fares.
  3. Name up to three stations you’d live near and say why, using time, cost, and transfers. Give one limitation of these data.

Part 4: Ship It

Make sure someone else can install and use your package.

Steps

  1. Run check(): 0 errors and 0 warnings, and fix or explain any notes.
  2. Run devtools::install(), restart R, and load your package with library(). Try an example and your data set’s help page.
  3. Commit and push.

Part 5: Write the README

Your README is your package’s front page. Write it in project.qmd, in the five sections below. Run anything that calls the API at the console and paste its output (see the template’s comment), so the README renders without the API.

A. Overview

What your package does, the API it wraps, and the question it answers.

B. Installation

The remotes::install_github() line for your repo, and how to set up a key if it needs one. Your repo is private for now, so the line won’t work for other people yet. At the end of the course we’ll show you how to make your repos public, if you’d like a portfolio of your work.

C. Usage

An example of each exported function with its output, and the first rows of your data set. On the BART route, show bart_stations() and bart_trip("MLBR").

D. Analysis

Your question, your plots (each with a fig-cap) and tables, your answer, and its limitation, from Part 3.

E. Testing

Your test descriptions (the sentences that finish “Test that …”) grouped as typical inputs, edge cases, and errors; your bug test from Part 2 and what it found; and the last lines of test() and check().

Steps

  1. Render project.qmd.
  2. Turn off your wifi and render it again. If it fails, find the chunk that calls the API and fix it.
  3. Push, and read your README on GitHub as a stranger would: once your repo is public, could they install your package and run your examples?
TipOptional: Publish your package’s website

pkgdown builds a website for your package: the README as its home page, plus a help page for every function and data set (see dplyr’s).

  1. Run install.packages("pkgdown"), then usethis::use_pkgdown() once.
  2. Give every plot a fig-cap, render project.qmd, and run pkgdown::build_site().
  3. Drag the docs/ folder onto https://app.netlify.com/drop, signed in to the Netlify account you made for Project 4. To update the site, rebuild it and drag docs/ onto the site’s Deploys page.

Bring the link to your Project Studio.