Adoption connects an existing R package to specmill without recreating the package or replacing its HTTP implementation. Work on a dedicated branch, record a clean baseline, and begin with one representative operation.
If your project already has specmill.yml, use the upgrade workflow
below or the maintenance loop.
1. Record the existing contract
Before adding configuration, run the package’s targeted tests and record what a schema cannot describe: authentication, hooks, response classes, pagination, batching, lifecycle policy, and manual wrappers absent from schemas. Wrapper names, formals, helper calls, and documentation are checked mechanically in step 6, so they need no manual inventory.
Do not run initialize_client() on an established client
merely to add YAML. It adds a new httr2 helper and refuses conflicts
with existing scaffold files.
2. Add project and service files
For a schema-driven starting point, preview configuration without writing files:
root <- normalizePath('/path/to/existingclient', winslash = '/', mustWork = TRUE)
proposal <- specmill::configure_client(
root, file.path(root, 'schema/catalogue.yaml'), name_case = 'snake_case'
)
proposal$changes
proposal$diagnostics
proposal$files[['apis/default.yml']] # For a schema without tags.Tagged schemas propose one service per first tag. Review the proposed
names against the package’s public interface, narrow
selection.include to the first operation, and configure
your actual helper and mappings before generating.
configure_client(..., mode = 'apply') creates absent files
only and refuses conflicting existing files. It creates no helper or
package metadata. Copy reviewed changes into existing YAML manually.
You can also write a minimal policy directly. Create
specmill.yml in the client root:
Save a reviewed local schema as schema/catalogue.json,
then create apis/catalogue.yml:
id: catalogue
schemas:
files: [schema/catalogue.json]
selection:
methods: [GET]
exclude: ['^/items$']
helper: request_item
documentation: true
operations:
GET /items/{item_id}:
name: fetch_item
inputs:
identifier: {type: character, required: true, description: Catalogue item ID.}
language: {type: character, default: en, description: Response language.}
request:
arguments:
endpoint: {value: items}
id: {from: [params, identifier]}
language: {from: [params, language]}
docs:
title: Fetch a catalogue item
return: The item returned by the existing request helper.
examples: [{identifier: item-1}]This preserves fetch_item(identifier, language = 'en')
while mapping it to
request_item(endpoint = 'items', id = identifier, language = language).
The helper must be defined in the client’s R/ source.
specmill parses its arguments before applying generated code.
inputs declares the complete public interface and
requires an explicit request mapping. parameters is the
alternative when only a few schema-derived arguments need changing. See
Configuration.
Exclude config, schemas, and .specmill from the built
client package using .Rbuildignore, but keep them in Git.
Do not add specmill to runtime dependencies.
3. Keep executable customizations in R
For development callbacks, use an explicit environment:
root <- normalizePath('/path/to/existingclient', winslash = '/', mustWork = TRUE)
callbacks <- new.env(parent = baseenv())
sys.source(file.path(root, 'dev/callbacks.R'), envir = callbacks)
plan <- specmill::generate_client(
root, config = 'specmill.yml', callbacks = callbacks, mode = 'plan'
)Use callback_files: [dev/callbacks.R] in the project
file to fingerprint that source. It does not source the file. Only
source code you maintain and trust. Runtime hooks remain client code;
hook_config can point at existing declarations instead of
duplicating them.
4. Review and adopt existing generated files
New paths can be created immediately. Existing paths need recorded ownership or an explicit adoption hash. A generated header alone is insufficient.
This runnable example uses a temporary copy of the bundled client, adds an old generated wrapper, and adopts it after inspection. It never touches your package. The YAML above illustrates a custom mapping; this ownership example uses the bundled helper’s default contract.
root <- tempfile('existing-client-')
dir.create(root)
catalogue <- system.file('catalogue', package = 'specmill', mustWork = TRUE)
stopifnot(all(file.copy(list.files(catalogue, full.names = TRUE), root, recursive = TRUE)))
writeLines(c('config_version: 1', 'services: [catalogue.yml]'), file.path(root, 'specmill.yml'))
writeLines(c(
'id: catalogue', 'schemas: {files: [schema.json]}',
'helper: catalogue_request'
), file.path(root, 'catalogue.yml'))
legacy <- file.path(root, 'R/get_item.R')
writeLines(c(
'# Generated by wrapmaint; do not edit by hand.',
'get_item <- function(item_id, language = NULL) {',
' catalogue_request(method = "GET", path = "/items/{item_id}",',
' path_params = list(item_id = item_id), query = list(language = language), body = NULL)',
'}'
), legacy)
# Offline fixture only; real schemas use the required validation gate.
plan <- specmill::generate_client(validation = FALSE, root, config = 'specmill.yml', mode = 'plan')
Filter(function(x) x$action == 'protected', plan$files)
#> [[1]]
#> [[1]]$file
#> [1] "R/get_item.R"
#>
#> [[1]]$path
#> [1] "/tmp/RtmpDagDt8/existing-client-24502c43a473/R/get_item.R"
#>
#> [[1]]$action
#> [1] "protected"Review the file against the candidate behavior, including required-input checks and documentation. Hash only files you have reviewed, using UTF-8 text, LF line endings, and no terminal newline:
text <- paste(readLines(legacy, warn = FALSE, encoding = 'UTF-8'), collapse = '\n')
hash <- digest::digest(enc2utf8(text), algo = 'sha256', serialize = FALSE)
adopt <- list('R/get_item.R' = hash)
reviewed <- specmill::generate_client(validation = FALSE,
root, config = 'specmill.yml', mode = 'plan', adopt = adopt
)
applied <- specmill::generate_client(validation = FALSE,
root, config = 'specmill.yml', mode = 'apply', adopt = adopt
)
checked <- specmill::generate_client(validation = FALSE, root, config = 'specmill.yml', mode = 'check')
stopifnot(!any(vapply(checked$files, function(x) x$action == 'protected', logical(1))))The manifest now records ownership. Adoption hashes are a one-time
review input; do not keep passing old hashes on later runs. A changed
hash blocks adoption. Lifecycle and mixed-file protection still apply.
Review existing documentation or shared NAMESPACE conflicts
separately.
5. Retain manual implementations and grouped files
For a schema operation that must keep its handwritten wrapper, add
implementation: existing, along with its explicit
name, complete inputs, and
request mapping. specmill checks the actual formals and
retains the source. It can generate a fixed contract test, but does not
rewrite that implementation’s documentation.
To preserve grouped generated output, set
file: R/catalogue.R. Every top-level definition in that
file must be accounted for. Undeclared functions, other top-level code,
or retained implementations block replacement of the group. Keep those
files manual or separate them deliberately before adoption.
6. Verify and expand
Once a service file exists (step 2), propose mappings for the wrappers you want specmill to own. Nothing in the project is written:
proposal <- specmill::propose_mappings(root, c('R/items.R', 'R/search.R'), service = 'catalogue')
proposal$operations # Status, reason, and matched operation for every wrapper.
cat(proposal$yaml)A wrapper is proposed only when its body is a single call to the
service helper, its defaults and examples are plain literals, and the
generated replacement passes verify_adoption(). Everything
else is retained with a reason: keep it with
implementation: existing, or map it by hand. When helper
calls do not carry literal method and path
arguments, pass a route callback. Pass a
bindings callback for computed helper arguments, and a
prelude callback for statements that build helper arguments
before the call. The propose_mappings() help page shows a
prelude that turns an optional-query idiom into
compact_object.
After copying reviewed YAML into the service file, or writing mappings by hand, check them against the wrappers they replace:
verified <- specmill::verify_adoption(root, config = 'specmill.yml')
Filter(function(x) x$status == 'failed', verified$operations)Verification generates on a temporary copy and compares formals,
helper calls, arguments, and results across required, explicit, falsy,
and missing inputs, plus the Rd each roxygen block produces. Save
verified$contracts with saveRDS() to the
service contracts_file, delete the files named in
verified$files, and apply. Deletion is the adoption step
here: adoption hashes do not lift lifecycle protection. Run generated
contract tests and existing bespoke tests, and check that a second apply
is a no-op. Only then add more operations/services.
Commit policy, schemas, callbacks, fixtures, generated output, and manifest together. Keep the prior toolkit pin with matching output available for rollback. See Testing and Troubleshooting.
Update an already configured client
Keep the current toolkit pin and a clean baseline before upgrading.
Install the candidate toolkit in a separate R library or project
environment when comparing versions, then restart R. The current guides
describe main; use a reviewed commit SHA or your project’s
installer to make subsequent runs reproducible.
- Run
generate_client(..., mode = 'plan')with the existing policy and callbacks. Review diagnostics, file actions, public signatures, and request mappings. Do not rerun initialization or replace reviewed YAML with a fresh proposal. - Inspect client-owned helpers with the candidate toolkit:
inspection <- specmill::inspect_client(root, config = 'specmill.yml')
inspection$helpers
inspection$protected_sources
helper <- inspection$helpers[['api_request']] # Use your configured helper name.
if (identical(helper$baseline, 'known')) {
helper$customized
helper$upstream_changed
helper$missing_explicit_arguments
cat(helper$comparison$proposed)
}For projects with development callbacks, pass the same
callbacks environment to inspection and generation. New
scaffolds keep helper baselines under .specmill/helpers/;
commit that directory with the manifest. Older helpers without
provenance report an unknown baseline. Inspection writes nothing and
cannot establish whether a helper behaves correctly.
- Manually merge the transport changes needed by your selected
operations. Preserve authentication, hooks, response classes, and
service-specific behavior. New arguments such as
server,request_controls,parameter_serialization, orform_schemaneed their implementation too; adding...is insufficient. See transport configuration. - To add session controls, review the installed templates with
system.file('templates/options.R', package = 'specmill')andsystem.file('templates/request.R', package = 'specmill'). Copy the options template into client-ownedR/api_options.R, replaceCLIENT_PREFIXwith the exact package name andOPTION_PREFIXwith a quoted lowercase package name with punctuation replaced by_, and merge the helper’s option handling. Generate documentation to export the setters. Existing clients do not receive either change through regeneration alone. - Replan, apply reviewed changes, check, and run contract and transport tests. Exercise dry-run and verbose controls if adopted, including turning them off. Compare public names, arguments, exports, and results with the baseline. Commit the toolkit pin, policy, helper edits, output, and manifest together.
You can keep an existing helper when no newly selected feature
requires changes. Generated wrappers and emitted companions need no
specmill runtime dependency. Select
companions = c('batching', 'pagination') during new-client
initialization. Existing clients must manually review and adopt the
companion additions with the request helper. Initialize a temporary
client from the reviewed local schema with the same package/helper
settings and selected companions, then compare its helper against your
current helper and recorded baseline. Merge the reviewed additions while
preserving client customizations. Keep the matching scaffold record in
.specmill/helpers/ when recording adoption, and rerun your
contract checks. Normal regeneration never overwrites runtime code. The
emitted names use your helper prefix, for example
api_request_batched and api_request_paginated.
Pagination needs digest in Imports and next links need httr2 >=
1.3.0. Calling toolkit specmill::paginated() or
batched() directly still requires specmill at runtime, so
use emitted companions for independent clients. Keep response metadata
until pagination finishes and format afterward.
Wrappers configured with batch.fan_out or
batch.split also call <helper>_each()
and <helper>_combine(). These come with the base
helper template, not a companion. Generation stops until an existing
helper file defines them; adopt them the same way.
