Developer guide

This guide is for people extending or maintaining nhsbsa. It explains how the package is put together and how to add new functionality consistently. For using the package, see vignette("nhsbsa") instead.

Design goals

nhsbsa is a low-level, general-purpose client for the NHS Business Services Authority Open Data Portal, a CKAN catalogue. Two principles shape every decision:

  1. Purity. There is one exported function per CKAN action, named after it, with an argument for every documented API parameter. A user who knows the CKAN API should not have to learn anything new. The only deliberate exceptions are the two convenience helpers nhsbsa_list_resources() and nhsbsa_download_resource(). They are “exceptions” because, unlike every other exported function, neither corresponds to a single CKAN action: nhsbsa_list_resources() calls nhsbsa_package_show() and reshapes the nested resources list into a tibble, and nhsbsa_download_resource() resolves a resource (via nhsbsa_package_show()) and then streams its file url — an ordinary HTTP download that is not part of the /action/ API at all. They earn their place because together they are the operations a caller most often needs.
  2. No domain knowledge. The package knows nothing about specific datasets (BNF, prescribing, dental, and so on). It returns plain data — tibbles for tabular results, lists for metadata — and leaves interpretation to the caller. Dataset-specific logic belongs in a separate package that depends on this one.

Architecture

The package is a thin shell around a single request layer.

endpoint functions  ->  nhsbsa_query()  ->  httr2  ->  CKAN
   (R/package.R,         (R/core.R)
    resources.R,
    datastore.R,
    catalogue.R)

The core request layer (R/core.R)

nhsbsa_query(action) does all the work:

Because parameters are read from the function signature, an endpoint wrapper is usually a single line — see below.

Conditions (R/conditions.R)

All messages, warnings and errors go through nhsbsa_abort(), nhsbsa_warn() and nhsbsa_inform(). These wrap the corresponding cli functions, always append a base class (nhsbsa_error / nhsbsa_warning / nhsbsa_message), and accept an optional class to prepend a more specific subclass. Give a condition a specific subclass whenever a caller might reasonably want to catch it, e.g.

nhsbsa_abort(
  c("x" = "No resource matched."),
  class = "nhsbsa_resource_not_found"
)

Do not call cli::cli_abort() (or any other package’s error helpers) directly.

Return-type conventions

Which actions are wrapped

The portal exposes the standard CKAN read API (you can confirm what a given instance supports with help_show, e.g. https://opendata.nhsbsa.net/api/3/action/help_show?name=package_search). The package wraps the useful read subset of those actions — dataset discovery, resource listing/metadata/download, datastore queries, and the catalogue listings. Other registered actions (the *_autocomplete family, datastore_info, license_list, status_show, and so on) are intentionally not wrapped yet. If a gap is discovered, the convention is to open an issue and then add the wrapper as below.

Adding a new endpoint

To wrap another CKAN action, add a thin function whose arguments mirror the API parameters. For example, to wrap tag_show:

#' Show a tag
#'
#' Wraps the CKAN `tag_show` action.
#'
#' @param id Character scalar. The tag name or id.
#' @inheritParams nhsbsa_package_list
#'
#' @return A list of tag metadata.
#' @export
#' @examplesIf identical(Sys.getenv("IN_PKGDOWN"), "true")
#' nhsbsa_tag_show("prescribing")
nhsbsa_tag_show <- function(id, .return_raw = FALSE) {
  nhsbsa_query("tag_show")
}

That is the whole implementation: nhsbsa_query() picks up id from the signature and sends it as a query parameter. If the action returns tabular data, post-process the result with nhsbsa_records_to_tibble().

Then:

devtools::document() # regenerate NAMESPACE and the .Rd file

Keep examples gated with @examplesIf identical(Sys.getenv("IN_PKGDOWN"), "true") so they render on the documentation site but never run on CRAN.

Testing

Tests use testthat (edition 3). The suite is designed to run fully offline and deterministically; a small set of live tests is gated separately.

Offline tests with recorded fixtures

Network calls are recorded as httptest2 fixtures and replayed. Wrap a block in with_mock_dir(); the first run records the responses, later runs replay them:

with_mock_dir("pl", {
  test_that("nhsbsa_package_list returns dataset ids", {
    out <- nhsbsa_package_list()
    expect_type(out, "character")
  })
})

Keep the mock directory name short. The portal’s URL path (opendata.nhsbsa.net/api/3/action/) is deep, and R CMD check rejects tarball paths longer than 100 bytes, so a long directory name plus a long action name can tip a fixture over the limit.

Offline tests with mocked responses

For error paths and edge cases it is simpler to mock the HTTP response directly, without a fixture, using httr2::with_mocked_responses():

test_that("an HTTP error surfaces as nhsbsa_api_error", {
  resp <- httr2::response(
    status_code = 404L,
    headers = list(`Content-Type` = "application/json"),
    body = charToRaw('{"success":false,"error":{"message":"nope"}}')
  )
  httr2::with_mocked_responses(function(req) resp, {
    expect_error(nhsbsa_package_show("x"), class = "nhsbsa_http_404")
  })
})

Live tests

Tests that contact the real API live in tests/testthat/test-live.R. They are skipped on CRAN, skipped when offline, and only run when the NHSBSA_LIVE_TESTS environment variable is set. The continuous integration workflows set this variable so the live tests run there. Use live tests sparingly, as a smoke test that the real API still behaves as expected.

Checks before a pull request

air format .
devtools::document()
devtools::test()
lintr::lint_package()
spelling::spell_check_package()
devtools::check()

Aim for a clean devtools::check() (no errors, warnings or notes). New user-facing terms that the spell checker flags can be added to inst/WORDLIST.