
Configure schemas, wrappers, and documentation
Source:vignettes/configuration.Rmd
configuration.RmdUse one project file to select service files. A service combines
local schemas with generation policy. An operation is identified by
service ID, HTTP method, and original schema path.
Names such as GET /items/{item_id} below must match that
path exactly, including trailing slashes.
All project/config/schema paths resolve against the explicit client
root, not the current working directory or the service
YAML’s directory. Use relative paths inside YAML. Parent traversal,
absolute paths, and symlinks are rejected.
JSON and YAML schema files
Pass a local .json, .yaml, or
.yml file directly to read_operations(),
configure_client(), or initialize_client().
The API catalogue, project generation, and schema comparisons use the
same schema loader. No separate conversion command or converted JSON
sidecar is needed. Service schemas.files and
schemas.patterns can select either format. Initialization
keeps the source format and extension in
schema/openapi.<ext> for a single API, or
schema/<api>.<ext> for a reviewed API
catalogue.
YAML uses YAML 1.2 scalar resolution: unquoted yes,
no, on, and off are strings;
true and false are booleans. Quote values when
the schema requires a string, including Swagger’s
swagger: '2.0'. Empty objects ({}), empty
arrays ([]), explicit nulls, and omitted fields remain
distinct. Sequence elements are retained as lists, including
single-element sequences. UTF-8 text is read without depending on the
machine’s locale. Types are never inferred from examples.
Numbers use R’s integer/double representation, as with JSON
ingestion. Values outside the 32-bit integer range use doubles;
arbitrary-precision integers are not supported, and values beyond the
exact double range can lose precision. Non-finite numbers are rejected.
YAML input must contain exactly one document. Duplicate mapping keys,
complex keys, boolean/null keys, and custom tags (including executable
tags) are rejected. Mapping keys must be strings or exact integers with
absolute value at most 2^53 - 1. Integer response keys such
as 200: are read as strings; quoting them is clearer. YAML
anchors and aliases are supported; merge keys (<<)
are rejected.
These rules support the JSON-shaped data model described in the OpenAPI format specification. Local file references resolve relative to the file containing each reference, including references in path items and operations. Remote references are not downloaded. Missing files and unsupported recursive references produce diagnostics when they affect an operation’s request contract. Initialization bundles local dependencies into the copied schema so the generated client does not depend on the original download directory. JSON and YAML share these rules.
Project file: specmill.yml
Schema validation
Generation requires validation by default. Each schema is checked
locally against the official Swagger 2.0, OpenAPI 3.0 or OpenAPI 3.1
JSON Schema; nothing is uploaded. validate_schema() returns
a standalone report. Explicit validation: false bypasses
the gate and should be a reviewed project decision:
References outside the document return unsupported coverage; bundle them first. Request mappings and retained implementations cannot bypass the gate.
Review multiple APIs once
Put the downloaded JSON or YAML schemas in the project root. The API catalogue proposes names from schema titles, detects duplicate content independently of filenames and formatting, and offers one editable table:
apis <- specmill::configure_apis(root, mode = 'apply')Default discovery selects .json, .yaml, and
.yml files directly inside root, excluding
specmill.yml, specmill.yaml,
specmill-apis.yml, and specmill-apis.yaml.
Pass schemas explicitly when other configuration YAML files
share the directory. Equivalent JSON and YAML documents count as
duplicate content.
In an interactive session, review api,
base_url, and include, then close the editor.
Choices are saved in specmill-apis.yml; unchanged inputs
reuse them without prompting again. Use review = TRUE to
revisit choices. In batch scripts, use review = FALSE;
choices accepts a data frame keyed by schema
for scripted name, URL and inclusion overrides. Planning is read-only by
default.
Names come from info.title, with suffixes when distinct
schemas share a title. Relative servers need the download origin: pass
origins, a character vector of URLs named by schema paths
relative to root. Missing/ambiguous servers need explicit base URLs
before saving. Server-variable defaults and Swagger host/basePath
metadata are supported. Incorrect reverse-proxy metadata still requires
review.
Pass the reviewed table as schema to
configure_client() or initialize_client(). The
generated YAML keeps tag grouping within each API, prefixes function
names with its reviewed API name. initialize_client() also
creates a request helper for each included API;
configure_client() only proposes configuration and schema
copies. Credential names use api.scheme, so identical
scheme names in different APIs do not share tokens accidentally. Service
authentication maps refer to these project credential names. Set a
credential with
set_api_token(token, scheme = 'reviewed_api.api_key').
For a new multi-API package, use the reviewed table in the same initialization and generation sequence as a single schema:
# root is an existing directory containing schemas, with no package scaffold yet.
apis <- specmill::configure_apis(root, mode = 'apply')
proposal <- specmill::configure_client(
root, apis, package = 'combinedclient', name_case = 'snake_case'
)
proposal$diagnostics
# After reviewing API names, URLs, inclusion, and proposed function names:
specmill::initialize_client(
root, apis, package = 'combinedclient', title = 'Combined API Client',
author = list(given = 'Example', family = 'Maintainer', email = 'you@example.org'),
license = 'MIT + file LICENSE', name_case = 'snake_case'
)
plan <- specmill::generate_client(root, config = 'specmill.yml', mode = 'plan')
plan$diagnostics
plan$files
# Review, then apply and check as in the new-client guide.Keep specmill-apis.yml in Git alongside the schema
snapshots and reviewed service YAML. For an existing package, stop at
the configuration proposal and follow adoption; initialization would conflict
with its scaffold. Updating the catalogue does not automatically merge
service YAML or refresh client-owned helpers.
Response policy and selected formatting
New request helpers accept response_policy. Select a
retained client function once under project, API/group, or service
defaults:
The name must resolve to a function in client source. YAML contains a
name, never executable R. Operation overrides can select a different
function; response_policy: null removes the generated
selection and restores the helper default. Complete
request.arguments mappings own the entire call and must
pass response_policy explicitly, using a literal function
name binding.
The contract is function(response, context, decode). It
runs once on the final httr2_response, after any retries,
before HTTP errors and decoding. context contains
method, status, and normalized actual response
media. When the request has an input to attribute,
context$query also holds it. That is the items of an array
body (an unnamed list or vector), otherwise the value of the sole path
parameter, otherwise the value of the sole URL query parameter. A query
value is reported even if it is a credential, since it was already sent
in the URL. Headers, cookies, object bodies, and the URL itself are
never included. The response itself can contain sensitive information;
client policy owns its observation and logging. Policies do not run for
dry runs or transport failures without a response.
observe_response <- function(response, context, decode) {
# Call a client-owned observer here, including on 4xx/5xx responses.
decode()
}decode() applies the existing status check and native
JSON/list, text/string, binary/raw, or empty/NULL decoding. Calling it
delegates. Any callback return value, including NULL, is a
replacement result. Warnings and errors propagate. A policy may call
decode(check_status = FALSE) to read a selected error body,
or return a replacement without decoding malformed content. Policies can
retain metadata for a future paginator by returning
list(response = response, result = decode()). Native
defaults do not attach metadata or discard envelope fields.
For direct helper calls, pass a function or a client function name. A
package option such as
options(myclient.response_policy = observe_response)
selects a shared helper default. Its prefix follows the package
request-option prefix. An explicit helper argument overrides that
option, including NULL. Generated YAML selections are
explicit arguments and take precedence over the option. Named functions
resolve in the helper’s enclosing client environment.
For CSV/TSV, select the emitted <helper>_delimited
policy:
It calls decode(format = 'delimited'). The actual
response Content-Type selects CSV for text/csv or
application/csv, and TSV for
text/tab-separated-values or text/tsv. Other
media retain native decoding, including plain text. Empty delimited
input returns a data frame with zero columns; header-only input retains
its columns. Parsing uses utils::read.table(), preserves
column names, respects quoted separators and newlines, disables
comments, and rejects ragged rows and parser warnings such as
unterminated quotes. Parser errors propagate. Native JSON errors retain
safe HTTP/media diagnostics.
Type inference follows read.table(). Select known
identifier or date types with
decode(format = 'delimited', col_classes = c(id = 'character'))
in a shared client policy. Without a selected policy, CSV/TSV remain
text strings.
Decoded-result transformations belong in existing
post_response hooks. The helper emits two reusable
functions, named with that helper’s prefix:
format_results <- function(state) {
records <- api_request_records(
state$result, collection = c('payload', 'records'),
query = state$params$id, null_to_na = TRUE
)
api_request_table(records, type_convert = TRUE, as_table = tibble::as_tibble)
}api_request_records() extracts only the selected path.
It wraps a named object as one record, preserves arrays and nested
fields, converts data frames to rows, and returns list()
for NULL or empty input. Query attribution must have length one or match
the record count; it becomes each record’s query attribute.
NULL-to-NA cleanup is shallow and opt-in. Use keyed = TRUE
for a named outer record collection, then
api_request_table(records, names_to = 'query_id') to retain
those keys in a column.
A response policy can attribute rows the same way from
context$query. A single-object response becomes one row
with a query column:
attributed_table <- function(response, context, decode) {
api_request_table(api_request_records(decode(), query = context$query))
}api_request_table() is explicitly lossy compatibility
formatting. It unions heterogeneous fields, represents missing fields as
NA, collapses nested values with '; ', and optionally
recovers types with utils::type.convert(). Set
type_convert = FALSE to keep strings such as
'001'. It rejects attribution that would overwrite a record
field. Its default output is a base data frame, including for empty
results. Passing tibble::as_tibble selects a tibble, also
for empty results, and requires tibble in the client’s
DESCRIPTION Imports. The generated helpers have no tibble
or specmill runtime dependency.
Reuse a shared formatting function in the existing hook executor when a whole service needs it; routine policy does not require one callback per endpoint. Hooks receive decoded results and public parameters. They cannot inspect failed responses or replace policy before decoding, and no new wrapper stage is added.
Existing helpers are never rewritten by normal generation. Review
inspect_client(root)$helpers and manually adopt the
proposed template, keeping client customizations and the recorded
baseline. Emitted companion names also follow helper renames, preventing
multi-service helper collisions. EPA settings, observer registries,
PubChem Fault handling, and image conversion remain client code. This
capability does not migrate any downstream client.
Per-service base URLs
By default every wrapper uses the server URL recorded at generation,
and the base_url override applies to the whole client. To
give each service its own host, which you can change at call time,
select a client-owned resolver:
Define the resolver in the client’s R/ source. It
receives the key and the wrapper’s recorded server, and returns an
absolute HTTP(S) URL:
service_url <- function(key, server) {
getOption(paste0('myclient.', key), Sys.getenv(key, 'https://example.org'))
}Services resolve independently, so changing one key leaves the others
in place. A relative schema server joins the resolver’s host:
/api/chet gives https://example.org/api/chet
and replaces any path the resolver returned, and an empty server keeps
the returned URL unchanged. Absolute schema servers use the returned URL
as is. The <package>.request base_url
option still takes precedence; the initialization override applies only
to services without a resolver. Generation fails if the function is
missing. It also fails if the client’s request helper does not accept
server_resolver; helpers created before this setting need
the upstream template change (see inspect_client()).
Request controls and batch limits
Generated project YAML includes request defaults:
Timeout is seconds per attempt. max_retries: 2 permits
three total attempts. These values use the same inheritance as other
defaults: project, API, group or service, then operation. After editing
YAML, regenerate wrappers to update their helper calls. Runtime
<package>.request options override individual YAML
fields. The client-owned helper must accept
request_controls and apply those defaults; normal
generation never overwrites it. Complete request mappings own their
helper arguments and must pass any controls explicitly. Write retries
still require retry_writes: true; use that only when replay
is safe for the API.
Select a client-owned retry predicate by function name in declarative YAML:
Define the predicate in the client’s R/ source. It
receives an httr2 response and returns one nonmissing logical value:
retry_server_errors <- function(response) {
status <- httr2::resp_status(response)
status == 429L || (status >= 500L && status < 600L)
}This selection excludes 408 and includes 501 and 505. A service can
select a narrower predicate. With no selection, native statuses remain
408, 429, 500, 502, 503 and 504, and retries remain disabled by default.
Runtime <package>.request options also accept
retry_policy as a client function name or function. YAML
accepts names only, never executable code. Invalid selections fail
before HTTP; predicates that fail or return invalid values abort safely.
Write replay still requires retry_writes, and httr2 still
handles Retry-After and backoff. Connection and JSON-decode failures are
not retried.
Generated defaults expose batch.max_items and
batch.max_bytes as null (unconfigured). A top-level array
request schema’s maxItems becomes an operation-specific
default. Set limits under service defaults or individual operations, for
example:
Shared limits apply only to applicable request bodies, so ordinary
GET requests are unaffected. The helper rejects oversized requests
before sending them. Item limits apply to top-level array bodies
supplied as unnamed lists; byte limits measure the serialized UTF-8
JSON, UTF-8 plain text, or raw binary payload. Limits embedded only in
prose need explicit review; they are not silently treated as schema
constraints. Nested-array limits remain separate work. Existing
client-owned helpers need a batch argument before using
these settings; generation verifies that compatibility. To send
oversized input in several requests instead of rejecting it, see the
next section.
Many inputs in one call: fan_out and split
Two batch settings let one wrapper call send several
requests and return one combined result:
-
fan_out: <parameter>makes a scalar path or query parameter accept a vector. The wrapper sends one request per element. -
split: truesends a top-level array body that is longer thanmax_itemsin chunks of at mostmax_itemsitems. There is no separate chunk size. The limit the API enforces is the size the wrapper splits to. -
on_error: stop | dropdecides what a failed request does. The default isstop.
Set the per-API limit once and opt operations in. Settings inherit
like every other batch field, through project, API, group,
service and operation:
# apis/comptox.yml
defaults:
batch:
max_items: 200 # what the API accepts per request
split: true # chunk larger array bodies instead of rejecting them
on_error: drop
operations:
GET /hazard/toxval/search/by-dtxsid/{dtxsid}:
batch:
fan_out: dtxsid
ct_hazard_toxval_search(c('DTXSID7020182', 'DTXSID2021315')) # two GET requests
ct_chemical_detail_search_bulk(ids) # ceiling(length(ids) / 200) POST requestsWhat is sent. Every element of a fan-out vector is
validated against the parameter’s scalar schema before any request goes
out, so a bad element fails the whole call with no partial traffic. A
split body is validated as a whole, except for its schema
maxItems, and the helper still checks
max_items and max_bytes on each chunk. Input
that fits in one request, including a single element, is sent unchanged
as one request. Requests are sequential. Other arguments, such as
projection alongside a fanned-out dtxsid, are
the same on every request.
Hooks and policies run per request. The wrapper runs
pre_request, the request, and post_response
once per element or chunk:
-
pre_requestseesstate$paramsholding one element (fan-out) or one chunk (split). Settingskip_requestskips only that request, for example on a per-ID cache hit. -
post_responsecleans or converts each response on its own. -
response_policyreceives the element or chunk ascontext$query, provided attribution applies to the request (see Response policy). A split chunk is always attributed. A fanned-out path parameter is attributed when it is the only path parameter. A fanned-out query parameter is attributed when the operation has no path parameters and it is the only query value sent.
What is returned. The per-request results are
combined, whatever the number of inputs, so f('A') and
f(c('A', 'B')) return the same type:
- If every result is a data frame (for example, a hook returned
tibbles), they are stacked with
rbind(). A column missing from some results is filled withNA. - Otherwise the result is one list of records. A single-object response adds one record; an array response adds each of its elements, in request order.
- Requests that return nothing, such as an empty array or a hook
returning
NULL, add nothing. They are not failures.
Note that a fan-out wrapper given one ID returns
list(record), not record.
Failures. With on_error: stop, the
first failed request raises its error, and nothing is returned. With
on_error: drop, each failure gives a warning naming its
input, and the call continues:
Warning message:
Request for DTXSID2021315 failed: HTTP 404 application/json response
The combined result then carries attr(result, 'failed'),
a named character vector mapping each failed input to its error message.
For a split, the name is the chunk’s items joined with ,,
because a chunk fails as a whole. The attribute is absent when every
request succeeds. Use it to detect or retry failures in code:
x <- ct_hazard_toxval_search(ids)
failed <- attr(x, 'failed')
if (length(failed)) retry <- ct_hazard_toxval_search(names(failed))Runtime override. The generated
on_error is a default. The helper’s batching option
overrides it for every fan-out and split wrapper of that helper, for
example to fail fast while debugging:
The option prefix follows the package request-option prefix, as
described under Pagination. Only
stop and drop are accepted here. The
companion’s keep mode does not apply, because combining
would discard its placeholders.
Configuration rules. Inherited settings apply only
where they fit. An API-level split: true affects only
operations with an array body and a max_items limit, and a
group-level fan_out affects only operations with that
parameter. Set on an individual operation, a setting that does not fit
stops generation:
| Setting on an operation | Requirement |
|---|---|
fan_out |
Names a path or query parameter with a scalar (string, integer, number, boolean) schema |
split |
max_items is set, on a top-level JSON array or
line-delimited text body |
on_error |
fan_out or split also applies |
fan_out with split
|
Not allowed on the same operation |
Helper adoption. Fan-out and split wrappers call two
functions in the client-owned request helper file:
<helper>_each(), which runs the requests and records
failures, and <helper>_combine(), which joins
results. New clients get both. An existing helper file that lacks them
stops generation with a message naming them; compare the helper with the
current template (see Existing
clients) and copy the two functions in. Operations without
fan_out or split generate exactly as
before.
For a new package, initialize_client() writes editable
YAML grouped by schema tags. To preview that configuration first, or
propose it for an existing package:
proposal <- specmill::configure_client(
root, schema, package = 'petstoretrial', naming = 'tag_prefix'
)
proposal$files[['apis/pet.yml']]
proposal$diagnostics
proposal$changesThe default naming = 'operation_id' preserves valid
schema names; tag_prefix turns getPetById into
pet_get_by_id. Exact tag tokens and their simple plural are
removed before adding the prefix; synonyms are not guessed. Missing IDs
use method/path names. Every proposed name is explicit in the YAML for
editing. Multiple tags use the first, with a diagnostic; missing tags
use default. group_by = 'none' creates a
single default service instead.
Use name_case independently of that naming strategy. Its
choices are asis (the default), snake_case,
camel_case, pascal_case,
screaming_snake_case, and dot_case. Thus
naming = 'tag_prefix' with
name_case = 'camel_case' proposes petGetById.
Names already reviewed in the service YAML are kept exactly, so an
existing package can retain its convention.
When generated names collide, proposal$diagnostics lists
every contributing METHOD /path; multi-API diagnostics
include the API-qualified proposed name. Edit the matching
names entries in the existing service YAML, then rerun
configure_client(). Matching reviewed names are reused
across reruns and schema ordering, while unrelated service settings and
deliberate operation overrides remain untouched. Noninteractive runs
return these diagnostics and never prompt or silently rename a public
function.
mode = 'apply' writes absent configuration files only.
If existing YAML or the schema copy differs, nothing is written: review
the before/after text and make the desired edits yourself. Filename
collisions block writing; function-name collisions must be resolved in
service YAML before wrapper generation. A later proposal carries forward
matching reviewed names but never overwrites the YAML. Unsupported
operations remain included, with diagnostics. Configuration generation
does not create custom transports or hide unsupported endpoints.
load_project() and maintenance commands use
specmill.yml by default. Pass config
explicitly to use a different project filename.
Generated YAML exposes the method filter, path exclusions, schema discovery, public names, shared defaults, documentation switch and per-operation overrides. Comments explain each section and show optional authentication, formatting, callbacks, hooks and contract-test settings without enabling them unnecessarily.
Exclude methods without editing endpoint lists
In each generated service file (apis/pet.yml,
apis/store.yml, and apis/user.yml for
Petstore), replace the selection.methods list with:
selection:
methods: [GET, POST]
exclude: []
include: # Keep the existing entries below this key.
# ... existing METHOD /path entries ...This excerpt shows where to edit; keep the actual generated
include list. Selection filters intersect: an included
endpoint still has to match the allowed methods and avoid the excluded
path patterns. Leave PUT/DELETE/PATCH entries in include,
names, and operations; they do not override
the method filter. Excluded operations stay visible in the inventory but
are skipped before parsing and wrapper generation. You do not need to
review or delete hundreds of entries.
Regenerate wrappers and documentation, then reload the package:
plan <- specmill::generate_client(root, config = 'specmill.yml', mode = 'plan')
plan$operations
specmill::generate_client(root, config = 'specmill.yml', mode = 'apply')
specmill::generate_client(root, config = 'specmill.yml', mode = 'check')
devtools::load_all(root)For the bundled Petstore schema, this retains 14 GET/POST wrappers
and removes five previously generated PUT/DELETE wrappers, their exports
and help pages, including when wrappers share files. Manually modified
or unowned files remain protected; resolve any reported conflicts before
proceeding. Restore the methods and regenerate to bring the wrappers
back. Existing YAML is never overwritten automatically: older projects
can preview configure_client() and copy the desired
settings from its proposal.
| Field | Meaning |
|---|---|
config_version |
Required integer 1
|
services |
Required nonempty sequence of distinct service YAML paths |
package |
R package name; read from DESCRIPTION when omitted |
formatter |
Optional {name: air, version: '0.9.0'}; the exact
installed version is required |
callback_files |
Optional sequence of development callback source paths to fingerprint, without sourcing them |
authentication |
Map from schema security-scheme names to credential environment-variable names; never put tokens here |
Use specmill::load_project(root) to validate and inspect
the resolved service configuration before generating. Callback names
require an explicit environment even for a read-only plan.
Service files
Multiple APIs: one file per API, with nested groups
When schema is a reviewed configure_apis()
data frame, initialization writes one apis/<api>.yml
per included API. Schema tags become nested groups, not
additional YAML files. For example, 27 schemas with 69 tag groups
produce 27 API files containing those 69 groups. A single-schema
initialization still offers the original flat tag files, and existing
flat service files remain supported.
# apis/chemicals.yml
api: chemicals
schemas:
files: [schema/chemicals.json]
patterns: []
exclude: []
selection:
methods: [GET, POST]
exclude: ['^/admin(?:/|$)']
helper: chemicals_request
authentication: {}
documentation: true
defaults:
batch: {max_items: null, max_bytes: null}
groups:
chemicals_search:
selection:
methods: [GET, POST]
exclude: []
include: ['GET /search']
names:
GET /search: chemicals_search
defaults:
file: R/chemicals_search.R
operations: {}List this API file in specmill.yml under
services. Each group key is its stable service ID and must
be unique across the project. Keep those IDs when moving existing
configuration so ownership and operation identities remain stable.
Selection runs through project, API, then group.
Allowed methods intersect; path exclusions accumulate. A group cannot
restore an endpoint prohibited by its API or project. An API exclusion
affects only its own groups. The preview names the policy level
responsible for an exclusion. Group include lists and
public names remain intact even when a higher-level rule excludes an
operation.
Edit API selection.exclude once for shared route policy;
use group selection.exclude only for additional
restrictions. schemas.exclude instead filters schema
filenames. Neither setting edits the original schema document.
API schema sources, helper, authentication references, and
documentation settings are inherited unless a group overrides them.
Operation defaults merge in order: project, API, group, then operation.
Set function names and output file in groups or operations;
API defaults reject them to avoid accidental shared output names. Use
group_by = 'none' when tags are not useful: each API still
has one file with a single group. YAML file count, tag grouping, and
generated R-file layout are separate choices.
configure_client() proposals do not overwrite existing
configuration. Existing flat YAML continues to work; consolidate
reviewed settings explicitly instead of replacing them with a fresh
scaffold and losing names, exclusions, or overrides.
Project-wide limits and inherited settings
Set package-wide selection in specmill.yml, for
example:
selection:
methods: [GET, POST]
exclude: ['^/admin(?:/|$)']
helper: api_request
documentation: true
defaults:
implementation: generatedEvery service must satisfy both project and service selection rules.
Service methods intersect project methods; path exclusions accumulate. A
service cannot re-enable a project-prohibited endpoint. Exact
include lists stay in service YAML, along with names and
individual operations overrides.
Services inherit helper, documentation, and
defaults. Service values take precedence; defaults merge
recursively before individual operation overrides. Generated service
YAML leaves helper and documentation overrides commented so changing the
project setting actually affects all services. Existing explicit service
values continue to take precedence. Project defaults reject
name and file: set names and file layout per
service to avoid accidental collisions.
Print the preview to see separate lists with reasons:
plan <- specmill::generate_client(root, config = 'specmill.yml', mode = 'plan')
print(plan)
plan$excludedThe preview identifies project versus service restrictions and
matching regexes, then lists files to remove. Excluded endpoints keep
their YAML mappings so they can be restored later. The complete
plan$inventory also includes operations outside each
service’s include list; the printed summary omits those duplicates.
The default transport supports scalar path/query/header parameters,
OpenAPI string query arrays with style: form (repeated keys
when explode: true, comma-separated values otherwise), JSON
bodies, and application/octet-stream bodies declared as
type: string, format: binary, plus URL-encoded
and multipart form bodies. Pass a character vector for query arrays and
a raw vector for binary uploads. JSON is selected when offered alongside
other supported alternatives.
Initialization proposes authentication configuration for schemas declaring security schemes. Map each scheme to an environment variable for its token:
Here api_key must be the security-scheme name in the
schema; CATALOGUE_API_KEY is the environment variable
chosen by the package maintainer. Keep real tokens out of this file.
Generation writes R/api_auth.R with documented helpers:
# In the generated package, after loading it:
set_api_token('YOUR_TOKEN', scheme = 'api_key') # this session
set_api_token('YOUR_TOKEN', scheme = 'api_key', persist = TRUE) # also savePersistence updates the user .Renviron (or
R_ENVIRON_USER), preserves unrelated lines, and makes the
token available immediately. Credentials are plain text in that file. A
project .Renviron can take precedence at the next R
startup. Neither loading the package nor generating it writes
credentials. api_token() retrieves the configured value and
fails with setup instructions when it is missing.
Requests inherit top-level security unless overridden by
the operation. security: [] is public; {}
within the alternatives permits anonymous access. Public/anonymous
requests receive no automatic credentials. Otherwise, generation
supports API keys in headers, query parameters, or cookies and HTTP
bearer tokens. Each alternative is an OR choice; all schemes inside one
choice are required. Missing credentials fail before any HTTP request.
OAuth login, refresh, and OpenID Connect are deferred; an
unsupported-only requirement produces a clear runtime error.
Omitting authentication preserves existing client-owned
authentication. Explicit custom request mappings also retain
responsibility for authentication. Adding the field opts into generated
authentication.
Existing helpers need optional headers,
query_serialization, body_media,
auth, parameter_serialization,
cookies, body_encoding, or
form_schema arguments when an operation uses these
features. Generation validates helper compatibility and does not
overwrite client-owned helpers.
Dry run and verbose messages
New clients export package-prefixed session controls. For
catalogueclient:
catalogueclient::catalogueclient_dry_run(TRUE)
request <- catalogueclient::get_item(item_id = 'item-1')
catalogueclient::catalogueclient_dry_run(FALSE)
catalogueclient::catalogueclient_run_verbose(TRUE)
# Subsequent requests report their HTTP method and response status.
catalogueclient::catalogueclient_run_verbose(FALSE)Both controls default to off and accept one nonmissing logical value.
Function names preserve the exact package name, including case and dots.
The option prefix is lowercase with punctuation replaced by
_. All helpers in a multi-API client share the controls.
They change session options, not profile files.
Dry-run helpers return a prepared httr2_request without
sending it. Input validation and authentication still run, so private
endpoints need credentials. The returned request may contain
credentials. Verbose messages omit URLs, headers, query values, and
bodies.
An explicit catalogueclient.dry_run option, including
FALSE, overrides the legacy
CATALOGUECLIENT_DRY_RUN environment flag. Remove the option
with options(catalogueclient.dry_run = NULL) to restore
that fallback. The flag accepts true, 1, or
yes, ignoring case. Other values leave dry run off.
These controls live in client-owned R/api_options.R and
the request helper. Existing clients need manual
adoption; regeneration does not install the setters or refresh the
helper.
Form bodies and file uploads
Generated form operations take a named body list. Omit
optional fields by leaving their names out; required fields are checked
before sending. Explicit NULL fields and empty arrays are
rejected. An empty string is a present field; an empty URL-encoded form
sends zero bytes with its form content type. Use unnamed vectors or
lists for arrays and named lists for object fields. Empty multipart
bodies are rejected; omit an optional body argument instead.
# application/x-www-form-urlencoded
create_item(body = list(name = 'Milo', tags = list('red', 'blue')))
# multipart/form-data: a file plus a JSON object part
upload_item(body = list(
file = curl::form_file('photo.png', name = 'photo.png'),
metadata = list(title = 'Milo')
))Binary parts accept raw vectors or curl::form_file()
values. Arrays of parts use repeated field names. Multipart objects use
JSON; primitive parts use text, and binary parts use
application/octet-stream, unless the schema supplies a
supported encoding.contentType. URL-encoded fields use the
schema’s encoding.style and encoding.explode
rules. Swagger 2 formData parameters and their
collectionFormat are also supported. Unsupported nested
URL-encoded shapes, dynamic form maps, and custom part headers produce
diagnostics.
To choose an offered media type, set a service default or an operation override:
defaults:
body_media: application/x-www-form-urlencoded
operations:
POST /uploads:
body_media: multipart/form-dataThe selected media type must exist in that operation’s schema.
Existing client-owned helpers need the optional body_media,
body_encoding, and form_schema arguments and
must implement their encoding; generation will not replace them. New
clients use the bundled transport and declare curl in
Imports. Adding the default transport to an existing package requires
declaring curl first; retaining a client-owned helper adds
no such requirement.
Plain-text request bodies
OpenAPI requestBody.content and Swagger
consumes may declare text/plain with a scalar
string schema. The generated wrapper validates that schema and sends
UTF-8 text with Content-Type: text/plain. Scalar strings
preserve embedded and trailing newlines, blank lines and duplicates. An
empty string sends an explicitly empty body; omitting an optional
argument omits the body. Missing values and explicit NULL fail
validation.
Select body_media: text/plain when the schema offers
several media types. Array, object, binary and composed text schemas
remain unsupported. Archived schemas are never rewritten to guess a
request contract.
For a reviewed vector-of-lines input, select encoding explicitly:
operations:
POST /write:
body_media: text/plain
text_encoding: lines
batch:
max_items: 100
max_bytes: 1048576text_encoding: scalar is the default. lines
joins a character vector with LF and adds no final newline. Empty
entries, duplicates and newlines within each entry remain unchanged; an
empty vector sends an empty body. Item limits count input entries before
joining. Scalar text has no inferred record boundaries. String schema
constraints apply to the encoded text, and byte limits count its UTF-8
bytes. No automatic splitting is performed. Existing client helpers need
manual adoption of plain-text transport; generation preserves their code
and provenance baseline.
Parameter serialization
OpenAPI 3.0 and 3.1 support the following flat parameter shapes. Arrays contain strings, integers, numbers, or booleans; objects contain scalar properties or scalar-valued maps. Use unnamed vectors/lists for arrays and named lists for objects. Nested arrays/objects and unconstrained array items have no supported parameter encoding; JSON body support is broader.
| Location | Style | Shapes | Explode |
|---|---|---|---|
| Query |
form (default) |
Scalar, array, flat object | Both; default true |
| Query |
spaceDelimited, pipeDelimited
|
Array | Both; default false |
| Query | deepObject |
Flat object | Explicit true required |
| Path |
simple (default), label,
matrix
|
Scalar, array, flat object | Both; default false |
| Header | simple |
Scalar, array, flat object | Both; default false |
| Cookie | form |
Scalar, array, flat object | Both; default true |
Swagger 2 supports primitive arrays with
collectionFormat: csv (default), ssv,
tsv, or pipes in query, path, and header
parameters; multi is query-only. Ordinary cookies and
object parameters are not Swagger 2 types. Parameter
content and allowReserved: true remain
explicitly diagnosed rather than guessed. Binary parameters require
source-contract review, especially files declared in query strings
(#16).
Some generators publish an upload variant as extra query parameters
on a JSON operation; Springdoc, for example, adds files[]
(binary) and request (object) beside an
application/json body. After review, drop them for that
operation only, keeping the schema snapshot verbatim:
Quote names containing brackets; unquoted
[files[], request] is not valid YAML. Only query, header,
and cookie parameters can be excluded, and each name must exist on the
operation. The setting is per operation, not a default; other operations
keep their diagnostics, and real uploads declared as a multipart request
body are unaffected. The operation’s inventory record lists
excluded_parameters. The direct reader accepts
read_operations(file, policy = list(exclude_parameters_overrides = list('POST /api/hazard' = c('files[]', 'request')))).
Hook-selected routes
When a pre-request hook chooses another schema route, declare it on the keyed operation:
Each reference is schema-file METHOD /path. Use a unique
basename or a path relative to the project root. The file must be loaded
by the same service; ambiguous basenames and unknown routes fail
configuration loading. Targets need not be selected for wrapper
generation. routes belongs under operations, not defaults,
and duplicate declarations are rejected.
The keyed route and every target must use the same effective helper,
resolved schema server URL, and selected request body media, including
absence of a body. Unresolved servers fail this comparison. Binding
server from hook_state does not relax the
schema-server check; runtime server overrides remain client-owned. This
declaration does not verify the hook’s parameters or runtime route
choices.
For operations with declarations, the inventory’s optional
routes field lists the keyed route followed by its targets,
with project-relative schema paths, schema hashes, operation keys,
helpers, servers, and body media. Generated-file provenance records the
same list under .specmill/manifest.json →
files → output filename → routes → operation
ID. A hook-bound endpoint or method without
declarations emits a configuration warning. Runtime guarding is opt-in.
For standard generated helper calls, set route_guard: true
on the operation. For complete request mappings, name the helper
arguments that carry the method and schema path explicitly:
operations:
GET /api/descriptors:
routes:
- 'chemi-rdkit-prod.json GET /api/rdkit'
route_guard: {method: method, path: endpoint}The guard evaluates the final mapped arguments once after pre-request
hooks and rejects a method/path pair absent from the reviewed routes
before calling the helper. It accepts the keyed route and declared
targets, using exact uppercase methods and schema paths, including any
unexpanded path placeholders. Both values must be single nonmissing
strings. Full URLs are not schema paths. Complete mappings must supply
both named arguments; names are never inferred. The guard requires
declared routes and a generated implementation. Set
route_guard: false or omit it to preserve existing
behavior. Skipped requests bypass the guard, including
post_on_skip handling.
Runtime server overrides are independent of method/path review and remain available. This guard does not validate the destination server, redirects, parameters, or changes made inside a custom helper. Generated guards use base R and do not require specmill at runtime.
For APIs that explicitly require repeated bracketed query-array names, opt in:
brackets sends a flat primitive array as
expand[]=customer&expand[]=invoice (with URI percent
encoding). It applies to query arrays in the selected service or
operation, regardless of their declared array style. schema
restores the normal schema-driven behavior for an operation. This is an
explicit convention, not an interpretation of OpenAPI
deepObject for arrays: the OpenAPI
definition defines deepObject for objects, while Stripe’s expansion
examples show repeated expand[] keys.
Only arrays of strings, integers, numbers, or booleans are covered.
Object, path, header, cookie, JSON, and form-body encodings keep their
existing behavior. Nested arrays, object items, unions, binary items,
null elements, and empty arrays remain unsupported. No API is detected
automatically and no schema is rewritten. The direct reader accepts the
same opt-in as
read_operations(file, policy = list(query_array_style = 'brackets')).
Existing helpers must implement the bracket style in their existing
parameter_serialization handling; accepting the argument
alone is insufficient.
For example, a query object list(R = 1L, G = 2L) becomes
R=1&G=2 with exploded form, p=R,1,G,2 with
non-exploded form, or p[R]=1&p[G]=2 with deepObject.
Exploded cookies become separate name=value pairs joined by
;. URI tokens are UTF-8 percent-encoded before delimiters
are added; literal percent escapes are data and are encoded again.
Header values are not URI-encoded. New boolean array/object encodings
use true and false; existing scalar helper
behavior is retained.
Omitting an optional argument uses its schema default, if any;
explicit NULL omits it. Empty scalar strings remain
provided values (p= in query/cookies, ;p in
matrix paths, . in label paths). Empty containers are
rejected because URI templates do not give them an unambiguous
representation distinct from omission. Null elements, missing/non-finite
scalars, duplicate object names, and nested values are rejected before
HTTP. Non-exploded whitespace-delimited values cannot themselves contain
the whitespace delimiter; composite header values cannot contain their
structural delimiter. These cases fail instead of silently changing the
values received by the server.
Existing scalar and form-style string-query-array wrappers retain
their helper arguments, including query_serialization.
Newly supported encodings pass parameter_serialization
metadata (location, name, schema, style, explode, required status, and
Swagger collection format); cookies additionally pass a
cookies list. Before generating these operations into an
existing client, review its helper against the current
inst/templates/request.R implementation and add these
optional arguments and their serialization behavior,
retaining the client’s base URL, authentication, hooks, and response
handling. Merely accepting ... does not implement the
encoding. Generation never replaces a client-owned helper, and a helper
without the required arguments fails planning with an explicit migration
message. Complete custom request mappings continue to own their
serialization.
The matrix follows the OpenAPI parameter specification and RFC 6570, including comma-separated non-exploded labels. Offline tests verify the received URI, headers, and cookies through the generated client rather than relying on helper return values.
JSON body values
Generated wrappers accept named lists for objects and unnamed lists
for arrays, including single-item arrays. Where the schema type is
exactly array, an unnamed atomic vector is also accepted
and sent as a JSON array, so c('a') sends
["a"] and character() sends [].
Union types and oneOf/anyOf/allOf
schemas, including values nested inside them, still reject vectors
because a length-one vector could be a scalar; pass a list there. For a
declared object, list() becomes {}; for a
declared array it stays []. In unconstrained values, use
setNames(list(), character()) for {} and
list() for []. Scalars are single strings,
finite numbers, or logical values. Heterogeneous arrays can contain
these scalars, nested lists, and NULL. Other atomic
vectors, data frames, classed objects, missing values, and duplicate or
empty object names are rejected; convert them to plain JSON-shaped lists
explicitly.
Omit an optional body argument to send no body. Explicit
body = NULL sends JSON null when allowed by
the schema: unconstrained values, a nullable OpenAPI 3.0 type, or an
OpenAPI 3.1 null type (including a type array); otherwise
it fails. Inside an object, list(field = NULL) retains the
field as null, while leaving the name out omits it.
Required fields must be present, even when their values may be null.
Wrappers pass an explicitly unboxed JSON null to the existing helper for
a top-level null; no new helper arguments are needed. Client-owned
helpers still control their own serialization.
Open objects accept extra fields. A schema in
additionalProperties validates each extra value;
additionalProperties: false rejects extra fields. Fixtures
and wrappers share these checks. Examples may supply fixture values but
never establish a type. Swagger 2 body arrays may omit
items; OpenAPI 3.0 arrays and Swagger 2 non-body arrays
must supply it. Explicit items: {} accepts any JSON value.
Boolean schemas remain diagnosed; this is not a full JSON Schema
validator.
JSON bodies support finite recursion when a plain local
$ref points back to an ancestor schema from an optional
object property. Default fixtures omit that recursive child. Supplied
children retain the ancestor’s required fields, types, and constraints.
Required back-edges, recursion through array items or typed maps, and
recursive references with siblings remain outside this slice and produce
diagnostics. This restriction also applies to nullable required cycles.
Parameters and form bodies retain their existing recursion
diagnostics.
Body values are limited to 32 levels below the root and 20,000 nodes, including unconstrained fields. Validation also stops after 20,000 schema visits. Environments are rejected as non-JSON values; cyclic lists terminate at the depth limit with a possible-cycle error. Values beyond these limits fail before transport, without dropping or truncating content.
JSON request bodies support oneOf, anyOf,
and allOf at the body root, in properties, in array items,
and in typed maps. Pass ordinary R values:
# For oneOf branches requiring either values or records:
body <- list(values = list('first', 'second'))
# The other branch might accept:
body <- list(records = list(list(id = 1L), list(id = 'second')))oneOf requires exactly one matching branch;
anyOf requires one or more; allOf requires
every branch. Sibling constraints also apply. Supplying fields that
satisfy two open-object branches of oneOf is an error, even
if a discriminator names one branch. Discriminators are hints, not
validation shortcuts, and wrappers neither insert tags nor strip fields.
These rules follow JSON
Schema composition.
Within composed values, container shape is explicit: use
setNames(list(), character()) for {} and
list() for []. Branch matching never changes
one into the other. Closed allOf members each check the
whole object; declaring a field in another member does not override
additionalProperties: false. OpenAPI 3.1
type: [string, 'null'] accepts strings and explicit null;
nullable alone has no validation meaning in 3.1.
Fixtures try a finite set of candidates from declared examples,
defaults, enums, and branch shapes, then validate against the complete
schema. They do not infer types from examples or solve arbitrary
intersections. If no candidate passes, supply a reviewed fixture
override; an ambiguous oneOf value still fails.
Branch matching checks types, required fields, typed maps, closed objects, enum/const values, numeric and string bounds, patterns, array length/uniqueness, and object size. Unsupported assertion keywords in JSON bodies produce an operation-specific diagnostic; malformed supported constraints are schema defects. Formats remain annotations. Form-body compositions remain diagnosed because their wire representation needs separate support; response schemas remain metadata, without generated response validation.
Path, query, header, and cookie parameters also accept
oneOf, anyOf, allOf, and OpenAPI
3.1 type arrays. specmill encodes the value by the branches’ shared wire
shape and validates it against the complete schema, so each branch keeps
its constraints:
- A nullable parameter, such as
anyOf: [{type: integer}, {type: 'null'}], encodes like its non-null branch. Parameters have no encoding for JSON null:NULLomits an optional parameter, and a required one must be supplied. - A union of scalar types accepts a value of any branch type.
- A union of a scalar and an array encodes as an array; a single value sends one item.
- A union of objects encodes the fields of the matching branch.
-
allOfmerges its branches, which must share a type.
Unions that mix object and non-object branches, allOf
branches without a shared type, and schemas that only admit null remain
parameter_composition diagnostics. A default that fails the
complete schema is reported by the default check below. A client-owned
request helper predating this support encodes scalar unions only in
simple query, path, and header positions; adopt the current helper for
label, matrix, or cookie scalar unions and for arrays of mixed scalar
types. Complete request mappings and explicitly retained implementations
remain the customization paths for unsupported contracts.
For nullable parameters, omission uses a declared default if present;
explicit R NULL omits an optional parameter. Required
arguments must be supplied with a non-null scalar. There is no inferred
URL encoding for JSON null: neither an empty query value nor the literal
string "null" represents null automatically. Sending a
service-specific null encoding requires an explicit client mapping or
helper.
Local references are resolved relative to the containing file; remote
references are never fetched. Cycles and reference chains exceeding 100
levels are diagnosed. OpenAPI 3.1 local schema-reference siblings are
conjunctive. For a bundled reference with assertion siblings, use an
explicit allOf containing the reference and the sibling
constraints; a diagnostic prevents lossy merging.
id: catalogue
schemas:
files: [schema/catalogue.json]
helper: api_request
selection:
methods: [GET, POST]
exclude: ['^/internal/']
documentation: true
policy_version: reviewed-1| Field | Meaning |
|---|---|
id |
Required stable service identity, unique in the project |
schemas.files |
Sequence of literal local JSON, YAML, or YML paths |
schemas.patterns |
Sequence of root-relative file globs; each must match files |
schemas.exclude |
Sequence of case-sensitive regexes matched against schema basenames |
helper |
Required client runtime function name, defined in
R/
|
selection.methods |
Uppercase methods to include; omitted means all standard HTTP methods |
selection.include |
Exact METHOD /path allowlist; omitted means all
operations, empty selects none, unknown keys fail |
selection.exclude |
Case-sensitive stringr regexes; exclude an original path if any matches |
names |
Map from METHOD /path to public wrapper name |
documentation |
Boolean; true generates roxygen, help pages, and
exports; initialization sets it to true |
policy_version |
Your review/version label, recorded in generation metadata |
defaults |
Operation settings shared across the service |
operations |
Settings keyed by METHOD /original/path
|
hooks |
Map from public wrapper name to ordered pre/post hook sequences |
hook_config |
Existing runtime hook YAML path; use this or hooks,
never both |
hook_callback |
Client hook executor name; default run_hook
|
prepare |
Development callback name; receives and returns an operation, preserving identity |
contracts |
Inline fixed request expectations; see Testing |
contracts_file |
RDS fixed call sequences under tests/testthat/,
preserving R classes |
response_fixture |
Default mock response for inline single-call contracts |
files and patterns can be combined; paths
are deduplicated and sorted. Missing files, unmatched globs, or an empty
final schema selection fail. Unknown fields, duplicate explicit keys,
invalid types/regexes, executable YAML tags, and name collisions are
errors. YAML aliases are supported; explicit keys override merged keys.
YAML contains data, never R source strings.
Name and document a wrapper
Generation checks the effective default of each optional API parameter after configuration overrides and preparation callbacks. A default must pass the same supported schema-value checks as a supplied argument. Native request defaults must also pass parameter encoding checks. Empty query strings, empty containers, header newlines, and ambiguous serialization delimiters can therefore fail independently of schema constraints.
mode = 'plan' reports
parameter_default_schema or
parameter_default_transport, the operation and parameter,
the value and its origin, the source location, and guidance. Both codes
use review_required: an incompatible default annotation
does not establish that an OpenAPI 3.1 schema is invalid. Apply and
check stop before writing output when a selected operation has an
invalid effective default. Specmill does not coerce or silently discard
such defaults.
Correct a default in service configuration without changing the input schema:
operations:
GET /chem/tanimoto:
parameters:
query nBits: {default: 2048}
query radius: {default: 2}
GET /depict/2D_enhanced:
parameters:
query arrow: {default: null}An explicit null override emits R NULL, which omits an
optional parameter. Defaults on required arguments are ignored because
no R default is emitted; excluded parameters and defaults on body
schemas are also outside this check. Client-only inputs, custom
renderers, and explicitly retained implementations keep their existing
validation contracts. Complete request mappings retain schema-value and
wrapper checks but own their transport encoding.
Add this to the service that selects the catalogue schema:
operations:
GET /items/{item_id}:
name: fetch_item
parameters:
path item_id: {name: identifier, description: Catalogue item ID.}
query language: {default: en}
docs:
title: Fetch one catalogue item
description: Retrieve one item in the requested language.
parameters:
identifier: The catalogue identifier, for example item-1.
language: Response language; en or fr.
return: An R list containing the decoded item response.
examples:
- {identifier: item-1}
- {identifier: item-1, language: fr}
tags:
family: catalogue endpointsParameter overrides use original location plus name,
such as path item_id. Documentation and example inputs use
the resulting public R names, such as
identifier. Examples are input maps rendered as calls
inside dontrun, so a documentation build does not send
their requests.
docs supports title,
description, parameters, return,
examples, tags, and lifecycle.
Without overrides, titles/parameter text come from schema metadata and
the return text describes the helper response. Improve these for your
users: document units, valid IDs, result classes, and service-specific
errors.
lifecycle accepts experimental,
stable, maturing, superseded,
deprecated, defunct, questioning,
or soft-deprecated. Install lifecycle in the
documentation environment when using badges. Lifecycle is explicit
policy; upstream deprecation is not automatic permission to change a
wrapper’s lifecycle. Protected lifecycle badges can prevent later
regeneration.
tags supports metadata such as family,
keywords, seealso, aliases, and
client custom tags with existing roxygen handlers. Maintain custom
handlers in your client package. Executable and structural built-in tags
are rejected. Schema descriptions are treated as prose, not executable
configuration.
Group endpoint families
Initialization groups operations by tag and writes the resulting
choices as editable YAML. Function prefixes are opt-in through
naming = 'tag_prefix'; the package still has one R
namespace. For example, a Petstore service can contain:
id: pet
schemas: {files: [schema/openapi.json]}
selection: {include: ['GET /pet/{petId}']}
helper: api_request
documentation: true
names:
GET /pet/{petId}: pet_get
defaults:
file: R/pet.R
docs:
tags: {family: pet endpoints}
operations:
GET /pet/{petId}:
parameters:
path petId: {name: pet_id}This exposes pet_get(pet_id) while preserving
/pet/{petId} on the wire. List separate
apis/pet.yml, apis/store.yml, and
apis/user.yml files in the project’s services
sequence to maintain independent policies for each group. Their combined
selections can cover the entire schema. The full
Petstore example demonstrates native generation, explicit mappings,
and retained client code.
Operation settings
| Field | Meaning |
|---|---|
name |
Override public R function name; must agree with names
if both specify it |
helper |
Override the service helper for this operation |
file |
Output path directly under R/, for example
R/catalogue.R
|
implementation |
generated (default) or existing, which
retains a declared implementation |
parameters |
Schema parameter overrides: name, default,
required, exclude,
description
|
extra_parameters |
Additional public inputs: default,
required, type, description
|
inputs |
Complete public input map, replacing schema-derived inputs; same
fields plus missing_as_null
|
parameter_order |
Sequence of public names to place first; remaining parameters follow |
request.arguments |
Named helper arguments built from typed bindings |
post_on_skip |
Boolean, default false; run post-hook after a pre-hook skips the request |
post_state |
parameters (default) or hook_state for the
full pre-hook state |
docs |
The documentation policy described above |
Per-operation settings merge into defaults. Named maps merge
recursively; sequences, a complete inputs map, individual
request bindings, and default values replace their
corresponding defaults. Keep defaults small enough that you can see the
effective behavior of each operation.
A complete inputs map needs
request.arguments. Required inputs have no R default and
preserve ordinary R argument semantics, including explicitly supplied
NULL; the existing helper/hooks own value validation.
missing_as_null: true is available only for explicitly
required inputs when an existing hook must see omission as
NULL. Do not use it merely to silence missing-input
errors.
Map public inputs to your existing helper
The default helper contract is:
api_request(method, path, path_params, query, body)To preserve a different helper interface, explicitly replace its argument map:
operations:
GET /items/{item_id}:
name: fetch_item
inputs:
identifier: {required: true, type: character}
verbose: {default: false, type: logical}
request:
arguments:
endpoint: {value: items}
identifier: {from: [params, identifier]}
options:
compact_object:
verbose: {from: [params, verbose]}Here the helper must accept endpoint,
identifier, and options. Extra inputs are sent
only when explicitly bound. Each binding has exactly one form:
| Binding | Result |
|---|---|
{value: items} |
Literal R data |
{from: [params, identifier]} |
Read a public input |
{from: [hook_state, query]} |
Read pre-hook state; requires a pre-hook |
object: {key: <binding>} |
Named R list, retaining NULL entries |
compact_object: {key: <binding>} |
Named list omitting NULL entries; empty result is
list()
|
array: [<binding>] |
Unnamed list, preserving positions including NULL |
vector: {key: <binding>} |
Named c() values |
{callback: batch_limit} |
Development callback result: literal data or an R language object |
For JSON, use named lists for objects and unnamed lists for arrays,
including one-element arrays. NULL, false,
zero, and one-element lists are distinct. Do not turn arrays into scalar
values for convenience. The helper owns final serialization and
transport checks. Schema-derived public parameters and bodies are
validated before request mapping and runtime hooks. Complete client
inputs maps retain their documented client-owned
validation.
Parameter transports use optional NULL for omission and
reject required NULL; they do not encode JSON null. Empty
parameter containers have no unambiguous wire representation. Form
fields likewise reject null and empty arrays. JSON bodies retain their
declared nullability and container constraints. Ordinary scalar
query/path/header booleans retain R’s
TRUE/FALSE spelling; structured parameter
serialization, JSON, and forms use
true/false.
Development callbacks versus runtime hooks
For a complete example, see Configure and maintain runtime hooks, including optional executor scaffolding, installed-client behavior, and regeneration.
A development binding callback receives one operation while generating source:
callbacks <- new.env(parent = baseenv())
callbacks$batch_limit <- function(operation) {
quote(as.numeric(Sys.getenv('batch_limit', '1000')))
}
specmill::generate_client(
root, config = 'specmill.yml', callbacks = callbacks, mode = 'plan'
)Use {callback: batch_limit} as a request argument
binding. A language object such as quote(...) becomes
client runtime code; a string remains a quoted literal. A
prepare callback instead transforms an operation record and
must preserve its identity. These functions run during planning too:
keep them deterministic and free of filesystem/network side effects.
Runtime hooks execute when a user calls the generated wrapper:
Hooks are client-owned request-shaping code. They may intentionally
change a validated public value’s type, remove values, or build a
different request body and options under their returned state. Existing
hook_state mappings select which state reaches the helper.
Hook authors own these transformations and any validation they require;
specmill adds no mandatory post-hook schema gate or outgoing-contract
declaration. Generic helpers retain their transport checks. Hook
exceptions propagate without calling a fallback request.
Schema-derived public inputs, including empty-query restrictions, are
checked before pre_request. An empty query created by a
hook is therefore the hook author’s responsibility. Explicit client
inputs preserve their existing missing/NULL behavior
described above.
hook_callback: run_hook
hooks:
fetch_item:
pre_request: [validate_identifier]
post_response: [tidy_item]The wrapper invokes the client’s executor as
run_hook('fetch_item', 'pre_request', list(params = params)).
The executor runs the named chain in order and returns state. Returned
public parameters replace only those provided by the pre-hook. State can
contain skip_request and result. By default, a
skipped request returns immediately. Post-hook input normally contains
result and params, and the post-hook returns
the final wrapper result.
Set post_on_skip: true to run post-processing on a
skipped result, or post_state: hook_state to pass the
complete pre-hook state with its result replaced. Both require a
pre-hook. Hook functions/executor must exist in client runtime source.
Unused runtime declarations appear separately in
unused_hooks.
Validate the result
After any configuration change, run load_project(), then
generate_client(..., mode = 'plan'). Inspect
files, drift, diagnostics,
mapping_diagnostics, and retained_diagnostics.
A client-mapped operation uses your explicit serializer
mapping and keeps its original parser limitations visible; it does not
acquire native schema support.
Apply only reviewed changes, then run check and the client’s tests.
Pagination
Select companions = c('batching', 'pagination') in
initialize_client() to emit reusable helper-prefixed
functions into the existing client-owned request helper. They use the
same ownership baseline and manual-adoption rules as transport. Ordinary
wrappers remain single requests; to make a wrapper loop over its own
input, use batch.fan_out or batch.split (see
Many inputs in one
call). Emission requires no specmill runtime dependency. Pagination
adds digest to a new client’s Imports; an existing DESCRIPTION must
declare it first.
Use a shared policy list in retained client code, or the
package/helper-scoped options
<package>.<helper>.batching,
<package>.<helper>.pagination, and
<package>.<helper>.pagination_links. The
package prefix uses the lowercase dry-run prefix. For example
catalogueclient.api_request.pagination applies to that
helper only. A leading dot in the helper name is dropped, so helper
.ct_request reads
comptoxr_native.ct_request.batching. Multi-API helper
prefixes keep service policies separate. Explicit pagination arguments
override policy fields; a supplied batching policy replaces the shared
list. There is no pagination YAML field or name inference. Maintainers
supply API knowledge once per service, with operation exceptions; the
companions supply the loops.
Batching accepts explicit normalize(items),
build(chunk, index), and merge(results)
functions. Values/order and unmerged results are preserved by default.
on_error = 'drop' or 'keep' warns on failures
and respectively drops them or retains error_result. The
size must be a positive integer before HTTP. Duplicate/missing/empty
removal, singleton JSON arrays and raw text construction remain selected
client choices. Every chunk routes through existing transport. An
explicit first_batch_only = TRUE batching policy preserves
ComptoxR’s legacy pagination of only its first batch. General
composition processes every chunk.
The convention inventory used for this implementation is:
| Convention | Inputs and response | Support |
|---|---|---|
| Page number | A zero- or one-based page, a page size, and a collection, possibly nested | Explicit starting page; increment by one |
| Offset | An offset, a limit, and a collection | Explicit starting offset; increment by requested size |
| Cursor | An opaque token in a response body and a token argument on the next request | Explicit token argument and extractor; repeated tokens rejected |
| Next link | A response body URL or an HTTP Link header |
paginated_links() with a bodyless httr2 GET request;
same origin only |
The bundled catalogue’s list_items(page) demonstrates
why parameter names are insufficient: its schema supplies neither a size
parameter nor an end condition. It is not automatically pageable.
Existing extension points include public input and request mappings,
pre-request and post-response hooks, custom request helpers, and
retained implementation: existing functions. Hooks can
normalize a response; a separate companion can orchestrate repeated
calls without changing the public single-request contract. The helper
and companion are client-owned R files.
For an operation with public arguments page,
per_page, and category:
list_all_items <- function(category, max_pages = 100, max_items = NULL) {
api_request_paginated(
list_items, category = category,
mode = 'page', parameter = 'page', size_parameter = 'per_page',
start = 1, page_size = 50, items = function(response) response$data,
max_pages = max_pages, max_items = max_items
)
}
result <- list_all_items('books')
result$stop_reason
result$pagesFor a top-level collection and offset/limit arguments:
search_all <- function(query, max_pages = 100, max_items = NULL) {
api_request_paginated(
search_items, query = query,
mode = 'offset', parameter = 'offset', size_parameter = 'limit',
start = 0, page_size = 50, items = identity,
max_pages = max_pages, max_items = max_items
)
}Use exact public argument names, including any configured renames.
Fixed arguments must be named and cannot override the pagination
arguments. If an operation’s argument collides with a
paginated() control such as items or
start, use a small adapter function that closes over that
fixed value. Use exact nested paths such as
parameter = c('body', 'pagination', 'cursor') and
size_parameter = c('body', 'pagination', 'limit') to update
request state without rebuilding filters, options or sort fields.
Companions and generated functions remain independent of specmill at
runtime.
The extractor must return a list, atomic vector, or data frame. Data
frames count rows. NULL is rejected to catch missing response fields;
explicitly convert a known null/204 end response to an empty collection
in the extractor if the API uses that convention. Extracted pages retain
their collection type and are not merged. Empty pages are omitted and
produce stop_reason = 'empty_page'. Short pages continue; a
full final page therefore requires another call to discover an empty
page. Page numbers advance by one, offsets by the fixed requested size.
For response-selected offsets, supply
advance = function(response, position, page_size) position + response$recordsCount.
Select Spring completion with
completed = function(response, state) response$last or
compare state$requests to an explicit
response$totalPages. Nested hasNext metadata belongs in the
same callback. stop_on_short = TRUE selects short-page
termination.
max_pages and an optional max_items must be
finite positive integers. Emitted companions default to 100 calls and no
item cap, and warn on truncation. The toolkit functions retain 100/10000
and silent-limit defaults for backward compatibility. The last
collection is sliced to meet the item limit without changing the request
size. Limits bound calls and retained items, not response bytes or total
time. Transport retries can make multiple HTTP attempts per call. A
limit produces max_pages or max_items, with
the item limit taking precedence when both are reached. Neither means
that all available items were fetched. Repeated nonempty pages terminate
at these limits without deduplication. Request, decode, and extraction
errors propagate immediately by default. Explicit
error_policy = 'partial' or 'empty' warns
after a failed request and returns successful pages or an empty
collection respectively. Extraction and security failures still
propagate. Keep #63 response policy output as a decoded envelope, or
retain {response, result} explicitly and extract from
result. Totals and cursor metadata must survive until iteration
finishes. A selected format(result) runs once afterward;
post-response hooks that remove metadata must be omitted or adapted for
these fetches.
Cursor responses, including AMOS
mode = 'cursor' takes the exact public cursor argument
name and a next_cursor function. The default
start = NULL is passed explicitly on the first call. Use
start = '' if the operation requires an empty string
instead. Supply a resume token explicitly when resuming.
size_parameter and page_size may both be NULL
when the companion fixes the size itself. Cursor values must be strings
or NULL; they are never decoded, modified, inferred from item IDs, or
treated as URLs.
AMOS development and staging return a results array and
a pagination object containing hasNext,
limit, and nextCursor. Configure a generated
GET wrapper for /api/amos/method_keyset_pagination/{limit}
as follows:
all_methods <- function(max_pages = 10, max_items = 500) {
api_request_paginated(
amos_page, mode = 'cursor', parameter = 'cursor',
size_parameter = 'limit', page_size = 50,
items = function(x) x$results,
next_cursor = function(x) {
if (isTRUE(x$pagination$hasNext)) x$pagination$nextCursor else NULL
},
max_pages = max_pages, max_items = max_items
)
}For the filtered POST form, put the cursor in the body through a companion:
filtered_page <- function(cursor, limit) {
amos_filtered(limit = limit, body = list(
cursor = if (is.null(cursor)) '' else cursor,
filters = list(internal_id = list(
filterType = 'text', type = 'startsWith', filter = 'GJ-'
))
))
}Pass filtered_page in place of amos_page.
Keep filters and sort order fixed throughout retrieval. The generated
functions retain their one-request behavior. NULL or an empty next token
ends with no_next_cursor. Repeated tokens, including cycles
back to the initial token, raise an error before another request. Empty
pages stop even if a token is present; limits stop before token
extraction. These stops take precedence over detecting repetition in an
unused next token.
Next links in bodies or headers
Generated wrappers return decoded bodies, so they do not expose HTTP
headers. paginated_links() instead takes an httr2 request;
its extractors receive the full httr2 response. It requires httr2 1.3.0
or later and a bodyless GET request.
request <- httr2::request('https://example.org/items?limit=50') %>%
httr2::req_timeout(20)
# HTTP Link header, rel="next", is the default next-link extractor.
result <- api_request_paginated_links(
request, items = function(response) httr2::resp_body_json(response)$data,
max_pages = 10, max_items = 500
)
# A body-provided next URL uses the same retrieval and origin checks.
result <- api_request_paginated_links(
request, items = function(response) httr2::resp_body_json(response)$data,
next_link = function(response) httr2::resp_body_json(response)$next,
max_pages = 10, max_items = 500
)Configure authentication on the original request. Each link must
match its scheme, hostname, and effective port before that request’s
credentials are used. Relative links resolve against the current
response URL. URL userinfo and fragments are rejected. All redirects are
disabled, including same-origin redirects; a redirect response is an
error. The next URL supplies its complete query string, so initial query
arguments are not automatically appended. NULL or an empty link ends
with no_next_link. Repeated resolved links are errors;
finite limits also bound cycles that change their URL spelling. Error
messages for invalid/repeated links do not include their values.
Cross-origin links, redirect traversal, link-based POSTs, and
composite/non-string cursor objects are unsupported. Use a client-owned
helper for such contracts; never edit generated wrappers. AMOS validates
cursor behavior, not next-link behavior. The localhost tests exercise
body and header links, relative resolution, cycles, and credential
isolation. The opt-in dev/verify_amos_pagination.R script
checks generated GET and filtered POST wrappers against dev or staging
without production access or credentials.