Validating your OpenAPI specification with reality
After spending a lot of time with OpenAPI schemas and trying to answer the question of “are my HTTP requests and responses actually conforming to what my users are sending and receiving?”, I set out to finally build the solution. Like all projects, it started out small, found some pitfalls, and eventually landed on a pretty solid approach that scales to any spec size.
My initial approach was to piggyback on existing log data. The idea was simple enough – parse the access logs, extract the HTTP information, then validate those against the OpenAPI spec. No code changes required, just point it at your logs and let it rip. It didn’t matter the size of the logs or the spec, it would just run in the background and churn through everything as it came in and notify you of any mismatches. The goal here was to provide a service that someone could, even temporarily, send their logs to get a sense of how well their traffic aligned with their OpenAPI documentation.
Note: I’m using Ruby here because it’s nicer to read for this kind of thing, but the concepts apply to any language.
# .. snipped for brevity
log_entries.each do |entry|
validator.validate_endpoint(
method: entry[:method],
path: entry[:path],
status: entry[:status]
)
end
This worked…sort of. I could validate that endpoints existed in the spec and that response codes aligned with what was documented. But there were some pretty glaring issues:
- This only worked for HTTP requests without bodies (like GET, HEAD) and didn’t allow inspecting the payloads
- No way to validate request or response bodies against the schema
- No header validation beyond what made it into the access logs
- Timing was all over the place depending on log rotation, processing and size of the spec
While sounding great on the surface, this approach quickly fell apart when trying to validate anything beyond the most basic of requests. It was a good proof of concept, but ultimately not viable for real-world usage.
After digging into the problem a little more, I came up with the next iteration: inline middleware that captures the full HTTP context and ships it off to a background job for validation.
The middleware is dead simple - capture everything, enqueue it, and then validate asynchronously.
class OpenAPIValidationMiddleware
def initialize(app, spec_path)
@app = app
@spec_path = spec_path
end
def call(env)
request = Rack::Request.new(env)
request_context = {
method: request.request_method,
path: request.path,
query_params: request.query_string,
headers: extract_headers(request.env),
body: request.body.read.tap { request.body.rewind }
}
status, headers, response = @app.call(env)
response_body = []
response.each { |chunk| response_body << chunk }
response_context = {
status: status,
headers: headers,
body: response_body.join
}
OpenAPIValidationJob.enqueue(
spec_path: @spec_path,
request: request_context,
response: response_context,
timestamp: Time.now.utc
)
[status, headers, response_body]
end
private
def extract_headers(env)
env.select { |k, v| k.start_with?('HTTP_') }
.transform_keys { |k| k.sub(/^HTTP_/, '').split('_').map(&:capitalize).join('-') }
end
end
The background job then does the heavy lifting with the help of an existing OpenAPI validation library:
class OpenAPIValidationJob
def perform(spec_path:, request:, response:, timestamp:)
spec = OpenAPIParser.parse(spec_path)
request_errors = spec.validate_request(
method: request[:method],
path: request[:path],
query_params: request[:query_params],
headers: request[:headers],
body: request[:body]
)
response_errors = spec.validate_response(
method: request[:method],
path: request[:path],
status: response[:status],
headers: response[:headers],
body: response[:body]
)
if request_errors.any? || response_errors.any?
ValidationLogger.log(
timestamp: timestamp,
endpoint: "#{request[:method]} #{request[:path]}",
request_errors: request_errors,
response_errors: response_errors
)
end
end
end
The background job approach gives us the best of both worlds:
- Zero impact on request latency: The middleware overhead is minimal, just copying data and enqueueing a job
- Full validation coverage: We have access to the complete request and response, including payloads or anything the client/server sends
- Scalability: Background job workers can scale independently from web workers
- Failure isolation: If validation crashes or takes ages, it doesn’t affect the user’s request
- Flexible processing: We can batch validations, add retries, or even validate against multiple spec versions for non-production and production versions
The trade-off is that validation isn’t synchronous anymore. If you’re hoping to block invalid requests before they hit your application, this won’t help. But that’s not what this is for. This is about continuous validation of your production traffic against your OpenAPI spec to catch drift between what’s documented and what’s actually happening.
What I’m using this for
The main use case has been catching spec drift. You know, when someone updates an endpoint but forgets to update the OpenAPI spec. Or when a client is sending unexpected data that somehow makes it through. Or when error responses don’t match what’s documented.
You can then collect the validation failures in a metrics system and alert when patterns arise. For example:
- If 10% of requests to an endpoint fail validation, something’s probably wrong
- If a specific client consistently sends invalid requests, they might need help
- If error responses don’t match the spec, the documentation is incorrect and needs attention
It’s also proven useful for finding endpoints that aren’t in the spec at all. Legacy endpoints that nobody remembered to document. Third-party integrations that were “temporary” five years ago.
The real win here isn’t just technical validation - it’s closing the loop between what you document and what actually happens in production. Requests flow through without overhead, specs stay accurate, and you catch drift before it becomes a problem for users. If you’re maintaining an OpenAPI spec, treating it as a living contract rather than stale documentation makes all the difference.