specmill turns local API schemas and reviewed YAML policy into R wrappers, documentation, and request contract tests. It is a development dependency: people installing your generated package do not need specmill. Your package owns HTTP requests, authentication, response parsing, and runtime hooks.
This walkthrough creates a temporary catalogue client and checks it without contacting a service. Every evaluated example runs when this guide is built. For an existing package, use Adopt an existing client instead of initialization.
1. Install the development tools
This guide follows the current development version on
main. Install it once in R; the older v0.1.4
release does not include all the features shown here:
install.packages('remotes')
remotes::install_github('seanthimons/specmill@main')
install.packages(c('httr2', 'curl', 'testthat', 'devtools'))If a project supplies an installer and toolkit lock, use those
instead: they select the version reviewed for that project. In a local
specmill source checkout, devtools::install('.') installs
the checked-out version. Start a fresh R session after changing an
already loaded version. Record the reviewed commit SHA in your project
installer or dependency lock before relying on reproducible output. A
branch name moves, and packageVersion() alone does not
identify a development checkout. GitHub installs also record
RemoteSha in
packageDescription("specmill").
packageVersion('specmill')
#> [1] '0.1.15'You also need R package build tools appropriate to your operating system when installing dependencies from source. The tutorial itself needs no credentials.
2. Choose a local schema and a new package directory
Use the bundled OpenAPI example first. It contains four operations: get an item, list items, create an item, and refresh the catalogue.
schema <- system.file('catalogue/schema.json', package = 'specmill', mustWork = TRUE)
root <- tempfile('catalogue-client-')root is temporary so this guide can be rerun. For your
own project, replace it with an unused permanent directory such as
C:/projects/catalogueclient or
~/projects/catalogueclient, and replace schema
with your downloaded JSON, YAML, or YML schema. Save the schema’s origin
and version in your project; generation reads local files and does not
download or refresh schemas.
For request and response customization, the runtime hooks guide covers optional client-owned executor setup, examples, testing, and regeneration.
3. Initialize the package
Preview names, selection, and diagnostics before writing the package:
proposal <- specmill::configure_client(
root, schema, package = 'catalogueclient', name_case = 'snake_case'
)
proposal$changes
#> [[1]]
#> [[1]]$file
#> [1] "schema/openapi.json"
#>
#> [[1]]$action
#> [1] "create"
#>
#> [[1]]$before
#> NULL
#>
#> [[1]]$after
#> [1] "{\n \"openapi\": \"3.0.3\",\n \"info\": {\"title\": \"Catalogue\", \"version\": \"1\"},\n \"paths\": {\n \"/items/{item_id}\": {\"get\": {\"operationId\": \"get_item\", \"parameters\": [\n {\"name\": \"item_id\", \"in\": \"path\", \"required\": true, \"schema\": {\"type\": \"string\"}},\n {\"name\": \"language\", \"in\": \"query\", \"schema\": {\"type\": \"string\", \"enum\": [\"en\", \"fr\"]}}\n ], \"responses\": {\"200\": {\"description\": \"Item\"}}}},\n \"/items\": {\n \"get\": {\"operationId\": \"list_items\", \"parameters\": [\n {\"name\": \"page\", \"in\": \"query\", \"schema\": {\"type\": \"integer\", \"minimum\": 1}}\n ], \"responses\": {\"200\": {\"description\": \"Items\"}}},\n \"post\": {\"operationId\": \"create_item\", \"requestBody\": {\"required\": true, \"content\": {\n \"application/json\": {\"schema\": {\"$ref\": \"#/components/schemas/Item\"}}\n }}, \"responses\": {\"201\": {\"description\": \"Created\"}}}\n },\n \"/refresh\": {\"post\": {\"operationId\": \"refresh\", \"responses\": {\"204\": {\"description\": \"Refreshed\"}}}}\n },\n \"components\": {\"schemas\": {\"Item\": {\"type\": \"object\", \"required\": [\"title\"], \"properties\": {\n \"title\": {\"type\": \"string\"}, \"count\": {\"type\": \"integer\"}\n }}}}\n}"
#>
#>
#> [[2]]
#> [[2]]$file
#> [1] "specmill.yml"
#>
#> [[2]]$action
#> [1] "create"
#>
#> [[2]]$before
#> NULL
#>
#> [[2]]$after
#> [1] "config_version: 1\n# Package identity; keep consistent with DESCRIPTION.\npackage: catalogueclient\n# Service configuration files, relative to the package root.\nservices:\n- apis/default.yml\n# Package-wide limits: services cannot re-enable these excluded methods or paths.\nselection:\n methods:\n - GET\n - POST\n - PUT\n - PATCH\n - DELETE\n - HEAD\n - OPTIONS\n - TRACE\n exclude: []\n# Default runtime helper; services may override it.\nhelper: api_request\n# Generate help and exports unless a service overrides this setting.\ndocumentation: yes\n# Shared settings; service defaults and individual overrides take precedence.\n# Request controls: seconds per attempt, retries after the first attempt, and explicit write replay permission.\n# Runtime package.request options override these generated defaults.\n# Keep function names and output files in service YAML.\ndefaults:\n implementation: generated\n request_controls:\n timeout: 30\n max_retries: 0\n retry_writes: no\n batch:\n max_items: ~\n max_bytes: ~\n# authentication: {} # Opt in to generated auth when the schema declares security.\n# Optional formatting: uncomment and use your exact installed air version.\n# formatter: {name: air, version: \"0.9.0\"}\n# Callback source files to fingerprint; pass their functions via callbacks, too.\ncallback_files: []"
#>
#>
#> [[3]]
#> [[3]]$file
#> [1] "apis/default.yml"
#>
#> [[3]]$action
#> [1] "create"
#>
#> [[3]]$before
#> NULL
#>
#> [[3]]$after
#> [1] "id: catalogueclient\n# Local schema files; patterns are file globs, exclude matches basenames by regex.\n# All paths resolve from the package root.\nschemas:\n files:\n - schema/openapi.json\n patterns: []\n exclude: []\n# An operation must pass methods AND include AND not match exclude (path regexes).\n# Keep only GET and POST below to omit PUT/PATCH/DELETE wrappers.\n# Leave their include/name entries in place; regeneration removes unchanged owned output.\n# An empty include selects nothing; remove include to allow every matching operation.\nselection:\n methods:\n - GET\n - POST\n - PUT\n - PATCH\n - DELETE\n - HEAD\n - OPTIONS\n - TRACE\n exclude: []\n include:\n - GET /items\n - POST /items\n - GET /items/{item_id}\n - POST /refresh\n# Edit the public R function names here; keys stay METHOD /original/path.\nnames:\n GET /items: list_items\n POST /items: create_item\n GET /items/{item_id}: get_item\n POST /refresh: refresh\n# Shared operation settings. Per-operation settings below override these.\n# file groups wrappers in one R file; omit it for one file per function.\n# Example: add file: R/endpoints.R under defaults.\n# docs can set title, description, return, parameters, examples, tags and lifecycle.\ndefaults: {}\n# Optional overrides keyed by METHOD /original/path. Replace {} with entries.\n# Each entry can set file, helper, parameters, parameter_order and docs.\n# parameters keys use the original location and name, e.g. \"query limit\".\n# Example parameter setting: {name: max_results, default: 10, description: Maximum results.}\n# Advanced facades use inputs, extra_parameters and request.arguments mappings.\noperations: {}\n# Inherited from specmill.yml; uncomment to override for this service:\n# helper: api_request\n# documentation: true\n# Optional hooks: define client functions before enabling these settings.\n# hooks: {} # Public wrapper name -> pre_request/post_response hook-name sequences.\n# hook_callback: run_hook\n# hook_config: inst/hooks.yml # Alternative to inline hooks, not both.\n# prepare: prepare_operation # Development callback; pass an explicit callbacks environment.\n# policy_version: \"1\" # Your review label, recorded in generation metadata.\n# Optional fixed request expectations for generated tests:\n# contracts: {} # Public wrapper name -> fixed request expectations.\n# contracts_file: tests/testthat/contracts.rds\n# response_fixture: {} # Mock response used by inline single-call contracts.\n# Full configuration examples: https://seanthimons.github.io/specmill/articles/configuration.html"
proposal$diagnostics
#> [[1]]
#> [[1]]$key
#> [1] "GET /items"
#>
#> [[1]]$code
#> [1] "missing_tag"
#>
#> [[1]]$message
#> [1] "Assigned to default"
#>
#>
#> [[2]]
#> [[2]]$key
#> [1] "POST /items"
#>
#> [[2]]$code
#> [1] "missing_tag"
#>
#> [[2]]$message
#> [1] "Assigned to default"
#>
#>
#> [[3]]
#> [[3]]$key
#> [1] "GET /items/{item_id}"
#>
#> [[3]]$code
#> [1] "missing_tag"
#>
#> [[3]]$message
#> [1] "Assigned to default"
#>
#>
#> [[4]]
#> [[4]]$key
#> [1] "POST /refresh"
#>
#> [[4]]$code
#> [1] "missing_tag"
#>
#> [[4]]$message
#> [1] "Assigned to default"
stopifnot(!dir.exists(root))Supply your own metadata and the service’s real base URL. The example URL below is a placeholder and is never contacted in this guide.
created <- specmill::initialize_client(
root, schema,
package = 'catalogueclient',
title = 'Catalogue API Client',
author = list(given = 'Example', family = 'Maintainer', email = 'you@example.org'),
license = 'MIT + file LICENSE',
base_url = 'https://api.example.org',
name_case = 'snake_case'
)
list.files(root, recursive = TRUE, all.files = TRUE)
#> [1] ".Rbuildignore" ".specmill/helpers/api_request.json"
#> [3] ".specmill/manifest.json" "apis/default.yml"
#> [5] "DESCRIPTION" "LICENSE"
#> [7] "NAMESPACE" "R/api_options.R"
#> [9] "R/api_request.R" "schema/openapi.json"
#> [11] "specmill.yml"Initialization writes immediately and refuses existing-file conflicts. It creates:
| File | What you maintain |
|---|---|
DESCRIPTION, LICENSE
|
Package identity, dependencies, and your license |
R/api_request.R |
Client-owned httr2 transport for JSON/binary bodies, headers, query values, and generated authentication |
R/api_options.R |
Client-owned <package>_run_verbose() and
<package>_dry_run() setters for package-scoped
session options |
schema/openapi.json |
Local schema copy; YAML inputs keep .yaml or
.yml
|
.specmill/helpers/api_request.json |
Initial transport source and settings for later helper comparisons |
specmill.yml |
Project version and selected service files |
apis/<tag>.yml (or apis/default.yml
here) |
Exact operation selection, editable names, helper, and generation policy |
NAMESPACE, .Rbuildignore
|
Package exports and exclusion of development inputs |
.specmill/manifest.json |
Ownership hashes maintained by specmill |
The scaffold declares httr2, jsonlite, and
curl as runtime dependencies for requests, JSON bodies, and
form uploads. Existing packages adopting the default transport must
declare these dependencies too.
Tagged schemas automatically get one service file per first operation
tag, plus grouped source files and help families. This catalogue has no
tags, so it uses default and keeps one source file per
function. Pass naming = 'tag_prefix' to propose names such
as pet_get_by_id, or group_by = 'none' to keep
one service. name_case accepts asis,
snake_case, camel_case,
pascal_case, screaming_snake_case, and
dot_case. It changes proposed names only; the editable
names map in service YAML remains authoritative. Inspect
attr(created, 'configuration')$diagnostics for grouping,
naming, and unsupported-operation findings.
configure_client() previews the same configuration without
initializing a package or overwriting existing YAML.
It does not create a Git repository, README, test runner, or pkgdown site for the client. Those are ordinary package maintenance tasks. Do not rerun initialization to update a client; edit policy and regenerate instead.
4. Inspect the schema and policy
parsed <- specmill::read_operations(file.path(root, 'schema/openapi.json'))
names(parsed$operations)
#> [1] "get_item" "list_items" "create_item" "refresh"
parsed$diagnostics
#> list()
cat(readLines(file.path(root, 'apis/default.yml')), sep = '\n')
#> id: catalogueclient
#> # Local schema files; patterns are file globs, exclude matches basenames by regex.
#> # All paths resolve from the package root.
#> schemas:
#> files:
#> - schema/openapi.json
#> patterns: []
#> exclude: []
#> # An operation must pass methods AND include AND not match exclude (path regexes).
#> # Keep only GET and POST below to omit PUT/PATCH/DELETE wrappers.
#> # Leave their include/name entries in place; regeneration removes unchanged owned output.
#> # An empty include selects nothing; remove include to allow every matching operation.
#> selection:
#> methods:
#> - GET
#> - POST
#> - PUT
#> - PATCH
#> - DELETE
#> - HEAD
#> - OPTIONS
#> - TRACE
#> exclude: []
#> include:
#> - GET /items
#> - POST /items
#> - GET /items/{item_id}
#> - POST /refresh
#> # Edit the public R function names here; keys stay METHOD /original/path.
#> names:
#> GET /items: list_items
#> POST /items: create_item
#> GET /items/{item_id}: get_item
#> POST /refresh: refresh
#> # Shared operation settings. Per-operation settings below override these.
#> # file groups wrappers in one R file; omit it for one file per function.
#> # Example: add file: R/endpoints.R under defaults.
#> # docs can set title, description, return, parameters, examples, tags and lifecycle.
#> defaults: {}
#> # Optional overrides keyed by METHOD /original/path. Replace {} with entries.
#> # Each entry can set file, helper, parameters, parameter_order and docs.
#> # parameters keys use the original location and name, e.g. "query limit".
#> # Example parameter setting: {name: max_results, default: 10, description: Maximum results.}
#> # Advanced facades use inputs, extra_parameters and request.arguments mappings.
#> operations: {}
#> # Inherited from specmill.yml; uncomment to override for this service:
#> # helper: api_request
#> # documentation: true
#> # Optional hooks: define client functions before enabling these settings.
#> # hooks: {} # Public wrapper name -> pre_request/post_response hook-name sequences.
#> # hook_callback: run_hook
#> # hook_config: inst/hooks.yml # Alternative to inline hooks, not both.
#> # prepare: prepare_operation # Development callback; pass an explicit callbacks environment.
#> # policy_version: "1" # Your review label, recorded in generation metadata.
#> # Optional fixed request expectations for generated tests:
#> # contracts: {} # Public wrapper name -> fixed request expectations.
#> # contracts_file: tests/testthat/contracts.rds
#> # response_fixture: {} # Mock response used by inline single-call contracts.
#> # Full configuration examples: https://seanthimons.github.io/specmill/articles/configuration.htmlAn empty diagnostics list means these operations fit the supported parser subset. It does not prove the service works. Review diagnostics before using real schemas; see Supported schemas and troubleshooting.
The service’s documentation: true enables roxygen
comments, help pages, and exports during generation. Configuration explains how to select
methods, exclude paths, name functions, and improve their help and
examples.
5. Preview changes before applying them
Generation normally validates each schema locally against its official OpenAPI JSON Schema. This tutorial explicitly bypasses that check for its bundled fixture; use the default gate for real schemas.
plan <- specmill::generate_client(root, config = 'specmill.yml', mode = 'plan', validation = FALSE)
data.frame(
file = vapply(plan$files, `[[`, character(1), 'file'),
action = vapply(plan$files, `[[`, character(1), 'action')
)
#> file action
#> 1 R/get_item.R write
#> 2 R/list_items.R write
#> 3 R/create_item.R write
#> 4 R/refresh.R write
#> 5 R/api_validation.R write
#> 6 NAMESPACE write
#> 7 man/catalogueclient_dry_run.Rd write
#> 8 man/catalogueclient_run_verbose.Rd write
#> 9 man/create_item.Rd write
#> 10 man/get_item.Rd write
#> 11 man/list_items.Rd write
#> 12 man/refresh.Rd write
#> 13 .specmill/manifest.json write
stopifnot(length(plan$operations) == 4L, !length(plan$diagnostics))plan parses and stages candidate output without changing
the client. Review the file actions, plan$drift, and
diagnostics. write means a new or changed file,
unchanged means current output, protected
needs your review, and retained means a proposed removal is
being protected. An eligible deletion appears as remove.
Manifest metadata can also appear in the plan.
The plan also checks the default of each optional parameter against
its schema and wire encoding. An invalid default, such as the string
"2048" for an integer parameter, appears in
plan$diagnostics as parameter_default_schema
or parameter_default_transport. It blocks
apply and check until you review it; specmill
does not coerce or drop it. Set a valid default, or
default: null to omit the argument, in the service YAML;
see parameter
defaults.
6. Apply, then check reproducibility
applied <- specmill::generate_client(root, config = 'specmill.yml', mode = 'apply', validation = FALSE)
checked <- specmill::generate_client(root, config = 'specmill.yml', mode = 'check', validation = FALSE)
stopifnot(all(vapply(
checked$files, function(x) x$action %in% c('unchanged', 'retained'), logical(1)
)))You now have four wrapper files in R/, their help pages
in man/, and exports in NAMESPACE.
check returns normally when output is current and raises an
error when it is stale or protected. It is the default mode of
generate_client(). A successful check does not mean tests
were run or that every selected schema operation is supported; inspect
diagnostics too.
Edit YAML policy or your client helper to change behavior. Editing a generated wrapper makes its recorded hash differ and protects it from replacement.
7. Verify a wrapper without sending HTTP
For this small demonstration, source the helper, the shared validators and one wrapper into a private environment, then replace the helper there with a recorder. The generated client source has no runtime dependency on specmill.
runtime <- new.env(parent = baseenv())
sys.source(file.path(root, 'R/api_request.R'), runtime)
sys.source(file.path(root, 'R/api_validation.R'), runtime)
sys.source(file.path(root, 'R/get_item.R'), runtime)
captured <- NULL
runtime$api_request <- function(...) {
captured <<- list(...)
list(id = 'item-1')
}
result <- runtime$get_item(item_id = 'item-1', language = 'en')
stopifnot(
identical(result, list(id = 'item-1')),
identical(captured, list(
method = 'GET', path = '/items/{item_id}',
path_params = list(item_id = 'item-1'), query = list(language = 'en'), body = NULL,
request_controls = list(timeout = 30L, max_retries = 0L, retry_writes = FALSE),
server = list(diagnostic = 'Relative server URL requires a recorded origin or explicit base URL override')
))
)
result
#> $id
#> [1] "item-1"This checks the wrapper-to-helper contract. Follow Write and run contract tests to persist fixed expectations in the package and run them with testthat. Transport tests are separate: they verify the final URL, headers, serialized body, and response handling.
8. Install and use your client
Wrappers that validate schema parameters or bodies call shared
validators that generation writes once to
R/api_validation.R. That file is regenerated with the
wrappers; do not edit it. The client still needs no specmill at
runtime.
For schemas with API-key or HTTP bearer security, initialization also
proposes an authentication map in
specmill.yml. Generation creates R/api_auth.R
with set_api_token() and api_token().
Configure the token at runtime; keep it out of YAML. Public endpoints
need no token. OAuth login and refresh are deferred. See the configuration guide for setup and
persistence.
After configuring the actual service and any required credentials, run:
devtools::install(root)
help('get_item', package = 'catalogueclient')
catalogueclient::catalogueclient_dry_run(TRUE)
request <- catalogueclient::get_item(item_id = 'a-real-id', language = 'en')
catalogueclient::catalogueclient_dry_run(FALSE)
# This call sends HTTP:
catalogueclient::get_item(item_id = 'a-real-id', language = 'en')Dry runs still validate inputs and require credentials for
authenticated operations. The returned request may contain credentials;
do not publish it. Use catalogueclient_run_verbose(TRUE) to
report the method and response status, and
catalogueclient_run_verbose(FALSE) to turn messages
off.
The last call sends a real request. The default helper performs one
request, including when an argument is named page. It
returns JSON objects and arrays as R lists, JSON scalars as their
corresponding R scalars, text/SVG as strings, binary as raw bytes, and
empty bodies or JSON null as NULL. It raises
errors for HTTP failures or malformed nonempty JSON. Those errors report
the status and response media type without including the request URL,
response body, or credential values. A missing or unrecognized
Content-Type returns raw bytes. A JSON
Content-Type with a non-JSON body is a decode error, while
a text type always returns text even if its body happens to contain
JSON. The parser records declared response media and schema metadata,
but runtime decoding follows the actual Content-Type. The
generator does not pass declared response schemas to the helper or
validate response bodies against them. Such validation would need an
explicit generated helper argument and remains unsupported. Credentials,
pagination, batching, and service-specific validation belong in the
client. Optional paginated() companions support explicitly
configured page/offset and cursor retrieval, with
paginated_links() for next links; see pagination configuration. New
helpers expose timeout, URL override, and bounded safe retries through
one package-scoped R option; see the request
controls guide. Existing client-owned helpers require an explicit
update to adopt these controls.
9. Save the project and maintain it
Commit the schema, YAML policy, client helper, generated
source/help/tests, .specmill/manifest.json, and
.specmill/helpers/ together. Keep the manifest tracked: it
establishes which output can be updated safely. Keep credentials outside
tracked files. Add a package test runner as shown in the testing
guide.
When a schema or policy changes, repeat plan -> review -> apply -> check -> tests. See Routine maintenance and CI for thin commands, schema comparisons, coverage reports, and building documentation for your client.
