Skip to contents

Use 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:

validation: false

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:

defaults:
  response_policy: observe_response

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:

defaults:
  response_policy: api_request_delimited

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:

defaults:
  server:
    resolver: service_url
    key: chemi_burl # Optional; defaults to the service id.

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:

defaults:
  request_controls:
    timeout: 30
    max_retries: 0
    retry_writes: false

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:

defaults:
  request_controls:
    max_retries: 2
    retry_policy: retry_server_errors

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:

operations:
  POST /batch:
    batch:
      max_items: 100
      max_bytes: 1048576

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: true sends a top-level array body that is longer than max_items in chunks of at most max_items items. There is no separate chunk size. The limit the API enforces is the size the wrapper splits to.
  • on_error: stop | drop decides what a failed request does. The default is stop.

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 requests

What 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_request sees state$params holding one element (fan-out) or one chunk (split). Setting skip_request skips only that request, for example on a per-ID cache hit.
  • post_response cleans or converts each response on its own.
  • response_policy receives the element or chunk as context$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 with NA.
  • 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:

options(comptoxr.api_request.batching = list(on_error = 'stop'))

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$changes

The 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.

config_version: 1
package: catalogueclient
services: [apis/catalogue.yml]
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: generated

Every 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$excluded

The 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:

authentication:
  api_key: CATALOGUE_API_KEY

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 save

Persistence 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-data

The 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: 1048576

text_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:

operations:
  POST /api/hazard:
    exclude_parameters: ["files[]", request]

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:

operations:
  GET /api/descriptors:
    routes:
      - 'chemi-rdkit-prod.json GET /api/rdkit'

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:

defaults:
  query_array_style: brackets
operations:
  GET /ordinary-items:
    query_array_style: schema

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: NULL omits 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.
  • allOf merges 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 endpoints

Parameter 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$pages

For 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.

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.