--- title: "Developer guide" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Developer guide} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", eval = FALSE ) ``` 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](https://ckan.org) 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: * It inspects the **calling endpoint function** with `rlang::caller_fn()` and `rlang::fn_fmls_names()`, reads those argument values from the caller's environment, and turns them into query parameters. Arguments prefixed with `.` (such as `.return_raw`) are reserved for client behaviour and never sent to the API. * Values are coerced for CKAN by `nhsbsa_format_query_value()`: `NULL`s are dropped, logicals become `"true"`/`"false"`, multi-element vectors are joined with commas, and lists (such as `filters`) are encoded as JSON. * The request is performed with retry/backoff. `nhsbsa_perform()` translates transport failures into a graceful `nhsbsa_offline` error and HTTP errors into `nhsbsa_api_error` (plus a status-specific subclass such as `nhsbsa_http_404`). * The CKAN envelope is validated: on `success = FALSE` it raises an `nhsbsa_api_error`; otherwise it returns the `result` element (or, when the caller passed `.return_raw = TRUE`, the whole parsed envelope). 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. ```{r} 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 * Catalogue/metadata actions return a **list** (`package_show`, `package_search`, `resource_show`). * Listing actions return a **character vector** (`package_list`, `organization_list`, `group_list`, `tag_list`). * Row queries return a **tibble** (`datastore_search`, `datastore_search_sql`, `list_resources`). * `nhsbsa_download_resource()` returns the destination path invisibly. * Any read-only function called with `.return_raw = TRUE` returns the parsed envelope as a list. ## 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](https://github.com/rmgpanw/nhsbsa/issues) 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`: ```{r} #' 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: ```{r} 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](https://enpiar.com/httptest2/) fixtures and replayed. Wrap a block in `with_mock_dir()`; the first run records the responses, later runs replay them: ```{r} 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()`: ```{r} 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 ```sh air format . ``` ```r 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`.