An upstream API revision can break one operation while the rest stay healthy. specmill gives you three ways to handle it. This guide runs each one against a small schema so you can see what specmill reads and decides at every step. Every evaluated example runs when this guide is built and contacts no service.
| You want to | Use | What happens to existing code |
|---|---|---|
| Wait for upstream to fix the contract |
Defer
(deferrals = list(enroll = ...)) |
Kept. Never deleted. |
| Stop wrapping the operation on purpose |
Skip (selection: exclude) |
An eligible wrapper is removed. |
| Wrap it now | Fix the schema or a copy of it | Generated normally. |
The reference for the deferral lifecycle is in Routine maintenance and CI. This guide is the worked example.
1. The project and the schema
The project has one service and one schema. GET /bad has
a 200 response with no description, which
Swagger 2.0 requires.
root <- tempfile('deferral-demo-')
dir.create(file.path(root, 'R'), recursive = TRUE)
writeLines('request_helper <- function(...) list(...)', file.path(root, 'R/helper.R'))
writeLines(c('config_version: 1', 'services: [service.yml]'), file.path(root, 'specmill.yml'))
writeLines(c(
'id: demo',
'schemas: {files: [api.yml]}',
'helper: request_helper',
'names:',
' GET /good: demo_good',
' GET /bad: demo_bad',
' GET /health: demo_health'
), file.path(root, 'service.yml'))
write_schema <- function(bad_description = NULL) {
response <- if (is.null(bad_description)) {
' "200": {schema: {type: object}}'
} else {
paste0(' "200": {description: ', bad_description, ', schema: {type: object}}')
}
writeLines(c(
'swagger: "2.0"',
'info: {title: Demo, version: "1"}',
'host: api.example.org',
'schemes: [https]',
'paths:',
' /good:',
' get:',
' operationId: good',
' responses:',
' "200": {description: OK}',
' /bad:',
' get:',
' operationId: bad',
' responses:',
response,
' /health:',
' get:',
' operationId: health',
' responses:',
' "200": {description: OK}'
), file.path(root, 'api.yml'))
}
write_schema()
cat(readLines(file.path(root, 'api.yml')), sep = '\n')
#> swagger: "2.0"
#> info: {title: Demo, version: "1"}
#> host: api.example.org
#> schemes: [https]
#> paths:
#> /good:
#> get:
#> operationId: good
#> responses:
#> "200": {description: OK}
#> /bad:
#> get:
#> operationId: bad
#> responses:
#> "200": {schema: {type: object}}
#> /health:
#> get:
#> operationId: health
#> responses:
#> "200": {description: OK}2. What specmill sees
specmill reads the file twice, with different jobs. The parser translates operations. The validator checks the whole document against the official OpenAPI JSON Schema.
parsed <- specmill::read_operations(file.path(root, 'api.yml'))
names(parsed$operations)
#> [1] "good" "bad" "health"
length(parsed$diagnostics)
#> [1] 0The parser accepts all three operations. The validator does not:
validation <- specmill::validate_schema(file.path(root, 'api.yml'))
validation$status
#> [1] "invalid"
validation$reason
#> [1] "The schema does not conform to its OpenAPI specification."
data.frame(
level = vapply(validation$findings, `[[`, character(1), 'level'),
scope = vapply(validation$findings, `[[`, character(1), 'scope'),
operation = vapply(validation$findings, function(x) x$keys[[1]], character(1)),
message = vapply(validation$findings, `[[`, character(1), 'message')
)
#> level scope operation
#> 1 error operation GET /bad
#> message
#> 1 #/paths/~1bad/get/responses/200 must have required property 'description'The finding is scope: operation and names exactly one
operation. That is what makes it deferrable. A document-level finding,
or an unreadable file, blocks the whole run and cannot be deferred.
3. The blocked plan
Plan is read-only. With default strict validation, the failing
operation is reported as unsupported and the run cannot be
applied:
plan <- specmill::generate_client(root, config = 'specmill.yml', mode = 'plan')
blocked <- plan$diagnostics[[1]]
unlist(blocked[c('id', 'status', 'classification', 'code', 'reason', 'guidance')])
#> id
#> "demo GET /bad"
#> status
#> "unsupported"
#> classification
#> "schema_defect"
#> code
#> "schema_validation_invalid"
#> reason
#> "#/paths/~1bad/get/responses/200 must have required property 'description'"
#> guidance
#> "Correct or bundle the source before generation; request mappings cannot bypass schema validation."apply refuses to continue until you choose one of the
three paths below:
tryCatch(
specmill::generate_client(root, config = 'specmill.yml', mode = 'apply'),
error = function(e) cat(conditionMessage(e), '\n')
)
#> Unsupported selected operations; inspect plan diagnostics before generation4. Defer
Enrollment is explicit. The key is
<service id> <METHOD> <path> and the
reason is one line.
enroll <- list(enroll = c('demo GET /bad' = 'Response lacks a description'))
plan <- specmill::generate_client(
root, config = 'specmill.yml', mode = 'plan', deferrals = enroll
)
data.frame(
operation = names(plan$deferrals),
state = vapply(plan$deferrals, `[[`, character(1), 'state'),
detail = vapply(plan$deferrals, `[[`, character(1), 'detail'),
row.names = NULL
)
#> operation state detail
#> 1 demo GET /bad enrolled Response lacks a descriptionThe plan only proposes the enrollment. apply records it
and writes the healthy operations:
invisible(specmill::generate_client(
root, config = 'specmill.yml', mode = 'apply', deferrals = enroll
))
list.files(file.path(root, 'R'))
#> [1] "demo_good.R" "demo_health.R" "helper.R"demo_good.R and demo_health.R exist. There
is no demo_bad.R, and no placeholder for it either. The
manifest now holds the enrollment:
manifest <- jsonlite::read_json(file.path(root, '.specmill/manifest.json'))
str(manifest$deferrals)
#> List of 1
#> $ demo GET /bad:List of 6
#> ..$ key : chr "GET /bad"
#> ..$ source : chr "api.yml"
#> ..$ reason : chr "Response lacks a description"
#> ..$ fingerprint: chr "v1:898972e943df825f89a5573d6cb8d641cb789bddf5e54b361814e4f9de545091"
#> ..$ version : int 1
#> ..$ functions : list()The fingerprint is a SHA-256 of the operation and every
local definition it references. It is what lets specmill tell a real
contract change from noise. From now on check passes and
reports the operation as deferred:
checked <- specmill::generate_client(root, config = 'specmill.yml', mode = 'check')
vapply(checked$deferrals, `[[`, character(1), 'state')
#> demo GET /bad
#> "deferred"The contract changes but is still broken
An edit to the operation changes its fingerprint, so specmill retries
it. Here upstream adds prose to GET /bad but still omits
the response description:
lines <- readLines(file.path(root, 'api.yml'))
lines <- sub(
'operationId: bad', 'operationId: bad\n description: Now with prose', lines
)
writeLines(lines, file.path(root, 'api.yml'))
plan <- specmill::generate_client(root, config = 'specmill.yml', mode = 'plan')
plan$deferrals[[1]]
#> $state
#> [1] "retry_blocked"
#>
#> $detail
#> [1] "#/paths/~1bad/get/responses/200 must have required property 'description'"retry_blocked still needs a decision: fix it, enroll it
again with a new reason, or exclude it. Edits to unrelated
operations do not wake the enrollment.
The contract is fixed
Now upstream supplies the description:
write_schema(bad_description = 'OK')
plan <- specmill::generate_client(root, config = 'specmill.yml', mode = 'plan')
plan$deferrals[[1]]
#> $state
#> [1] "retry_recovered"
#>
#> $detail
#> [1] "generated from the changed contract; apply to commit"
vapply(plan$operations, `[[`, character(1), 'id')
#> demo_good demo_bad demo_health
#> "demo GET /good" "demo GET /bad" "demo GET /health"retry_recovered means the operation now passes. The plan
already shows the new wrapper. apply writes it through the
normal gates and drops the enrollment:
invisible(specmill::generate_client(root, config = 'specmill.yml', mode = 'apply'))
list.files(file.path(root, 'R'))
#> [1] "demo_bad.R" "demo_good.R" "demo_health.R" "helper.R"
manifest <- jsonlite::read_json(file.path(root, '.specmill/manifest.json'))
length(manifest$deferrals)
#> [1] 0To retry an operation without waiting for a contract change, clear it
explicitly with deferrals = list(clear = 'demo GET /bad').
A toolkit update alone never wakes an enrollment.
To enroll failures automatically, add
deferral: {automatic: [schema_validation_invalid]} to the
service. See Automatic
contract-change deferral.
5. Skip
Skipping is a different decision. You are saying you do not want this
operation wrapped, whatever its contract looks like. Add an
exclude regex to the service:
writeLines(
c(readLines(file.path(root, 'service.yml')), 'selection:', " exclude: ['^/health']"),
file.path(root, 'service.yml')
)
plan <- specmill::generate_client(root, config = 'specmill.yml', mode = 'plan')
excluded <- plan$excluded[[1]]
unlist(excluded[c('id', 'status', 'classification', 'reason', 'guidance')])
#> id
#> "demo GET /health"
#> status
#> "excluded"
#> classification
#> "policy_exclusion"
#> reason
#> "Matches service exclusion: ^/health"
#> guidance
#> "Review project and service selection policy to reconsider this operation; its schema was not validated."An excluded operation is listed in the inventory but its schema is never validated. Unlike a deferral, exclusion plans to remove an existing wrapper:
data.frame(
file = vapply(plan$files, function(x) basename(x$file), character(1)),
action = vapply(plan$files, `[[`, character(1), 'action')
)
#> file action
#> 1 demo_good.R unchanged
#> 2 demo_bad.R unchanged
#> 3 demo_health.R remove
#> 4 manifest.json writeThe remove row is demo_health.R. Removal
needs verified ownership, so a file you edited by hand is protected
instead. Review it in plan before you apply.
6. Which one to use
| Situation | Choose |
|---|---|
| Upstream is expected to fix a single operation, and you have a working wrapper you must keep | Defer |
| You never want the operation wrapped, for example a health check | Skip |
| You control the schema copy and can correct it | Fix, then plan again |
| The whole document fails validation, or a file is unreadable | None of these. Fix the document first. |
For the full state table and its limits, return to Routine maintenance and CI. For selection policy, see Configuration.
