Skip to contents

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:

config_version: 1
package: existingclient
services: [apis/catalogue.yml]

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.

  1. 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.
  2. 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.

  1. 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, or form_schema need their implementation too; adding ... is insufficient. See transport configuration.
  2. To add session controls, review the installed templates with system.file('templates/options.R', package = 'specmill') and system.file('templates/request.R', package = 'specmill'). Copy the options template into client-owned R/api_options.R, replace CLIENT_PREFIX with the exact package name and OPTION_PREFIX with 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.
  3. 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.