[{"data":1,"prerenderedAt":1202},["ShallowReactive",2],{"page-\u002Fintegration-database-and-service-testing\u002Fcontract-testing-for-http-apis\u002Fvalidating-responses-against-an-openapi-schema\u002F":3},{"id":4,"title":5,"body":6,"description":1165,"extension":1166,"meta":1167,"navigation":103,"path":1198,"seo":1199,"stem":1200,"__hash__":1201},"content\u002Fintegration-database-and-service-testing\u002Fcontract-testing-for-http-apis\u002Fvalidating-responses-against-an-openapi-schema\u002Findex.md","Validating Responses Against an OpenAPI Schema",{"type":7,"value":8,"toc":1154},"minimark",[9,18,23,59,63,323,485,489,495,498,502,549,553,556,664,671,675,686,808,905,908,912,915,922,944,951,975,978,1082,1085,1091,1095,1101,1107,1113,1117,1145,1150],[10,11,12,13,17],"p",{},"A provider changes ",[14,15,16],"code",{},"total"," from an integer to a string, ships it, and every consumer's error rate rises. Each consumer's test suite was green throughout, because none of them checked that the responses they mocked or recorded still looked like the real thing. Validating every response against the provider's OpenAPI document closes that gap for the cost of one wrapper around the test client.",[19,20,22],"h2",{"id":21},"prerequisites","Prerequisites",[24,25,26,33,36,50],"ul",{},[27,28,29,32],"li",{},[14,30,31],{},"openapi-core >= 0.19",", which validates both requests and responses against OpenAPI 3.0 and 3.1.",[27,34,35],{},"A copy of the provider's OpenAPI document, committed at a known version.",[27,37,38,41,42,45,46,49],{},[14,39,40],{},"pytest >= 8.0",", and a test client — ",[14,43,44],{},"requests",", ",[14,47,48],{},"httpx",", or a framework's own.",[27,51,52,53,58],{},"The wider context in ",[54,55,57],"a",{"href":56},"\u002Fintegration-database-and-service-testing\u002Fcontract-testing-for-http-apis\u002F","contract testing for HTTP APIs",".",[19,60,62],{"id":61},"solution","Solution",[64,65,70],"pre",{"className":66,"code":67,"language":68,"meta":69,"style":69},"language-python shiki shiki-themes github-light github-dark","import pytest\nfrom openapi_core import OpenAPI\nfrom openapi_core.contrib.requests import RequestsOpenAPIRequest, RequestsOpenAPIResponse\nfrom openapi_core.exceptions import OpenAPIError\n\n\n@pytest.fixture(scope=\"session\")\ndef billing_contract():\n    # Vendored at a known version: a remote edit cannot change these results.\n    return OpenAPI.from_file_path(\"contracts\u002Fbilling-openapi-2.4.0.yaml\")\n\n\nclass ValidatingClient:\n    \"\"\"Wraps a requests.Session; every exchange is checked against the contract.\"\"\"\n\n    def __init__(self, session, contract):\n        self._session, self._contract = session, contract\n\n    def request(self, method, url, **kwargs):\n        response = self._session.request(method, url, **kwargs)\n        try:\n            self._contract.validate_response(\n                RequestsOpenAPIRequest(response.request),\n                RequestsOpenAPIResponse(response),\n            )\n        except OpenAPIError as exc:\n            raise AssertionError(\n                f\"{method} {url} → {response.status_code} violates the contract: {exc}\"\n            ) from exc\n        return response\n\n    def get(self, url, **kw):  return self.request(\"GET\", url, **kw)\n    def post(self, url, **kw): return self.request(\"POST\", url, **kw)\n\n\n@pytest.fixture\ndef billing(billing_contract, recorded_session):\n    return ValidatingClient(recorded_session, billing_contract)\n\n\ndef test_invoice_total_is_read_correctly(billing):\n    invoice = billing.get(\"https:\u002F\u002Fbilling.test\u002Finvoices\u002Finv_1\").json()\n    assert invoice[\"total_minor\"] == 1234\n","python","",[14,71,72,80,86,92,98,105,110,116,122,128,134,139,144,150,156,161,167,173,178,184,190,196,202,208,214,220,226,232,238,244,250,255,261,267,272,277,283,289,295,300,305,311,317],{"__ignoreMap":69},[73,74,77],"span",{"class":75,"line":76},"line",1,[73,78,79],{},"import pytest\n",[73,81,83],{"class":75,"line":82},2,[73,84,85],{},"from openapi_core import OpenAPI\n",[73,87,89],{"class":75,"line":88},3,[73,90,91],{},"from openapi_core.contrib.requests import RequestsOpenAPIRequest, RequestsOpenAPIResponse\n",[73,93,95],{"class":75,"line":94},4,[73,96,97],{},"from openapi_core.exceptions import OpenAPIError\n",[73,99,101],{"class":75,"line":100},5,[73,102,104],{"emptyLinePlaceholder":103},true,"\n",[73,106,108],{"class":75,"line":107},6,[73,109,104],{"emptyLinePlaceholder":103},[73,111,113],{"class":75,"line":112},7,[73,114,115],{},"@pytest.fixture(scope=\"session\")\n",[73,117,119],{"class":75,"line":118},8,[73,120,121],{},"def billing_contract():\n",[73,123,125],{"class":75,"line":124},9,[73,126,127],{},"    # Vendored at a known version: a remote edit cannot change these results.\n",[73,129,131],{"class":75,"line":130},10,[73,132,133],{},"    return OpenAPI.from_file_path(\"contracts\u002Fbilling-openapi-2.4.0.yaml\")\n",[73,135,137],{"class":75,"line":136},11,[73,138,104],{"emptyLinePlaceholder":103},[73,140,142],{"class":75,"line":141},12,[73,143,104],{"emptyLinePlaceholder":103},[73,145,147],{"class":75,"line":146},13,[73,148,149],{},"class ValidatingClient:\n",[73,151,153],{"class":75,"line":152},14,[73,154,155],{},"    \"\"\"Wraps a requests.Session; every exchange is checked against the contract.\"\"\"\n",[73,157,159],{"class":75,"line":158},15,[73,160,104],{"emptyLinePlaceholder":103},[73,162,164],{"class":75,"line":163},16,[73,165,166],{},"    def __init__(self, session, contract):\n",[73,168,170],{"class":75,"line":169},17,[73,171,172],{},"        self._session, self._contract = session, contract\n",[73,174,176],{"class":75,"line":175},18,[73,177,104],{"emptyLinePlaceholder":103},[73,179,181],{"class":75,"line":180},19,[73,182,183],{},"    def request(self, method, url, **kwargs):\n",[73,185,187],{"class":75,"line":186},20,[73,188,189],{},"        response = self._session.request(method, url, **kwargs)\n",[73,191,193],{"class":75,"line":192},21,[73,194,195],{},"        try:\n",[73,197,199],{"class":75,"line":198},22,[73,200,201],{},"            self._contract.validate_response(\n",[73,203,205],{"class":75,"line":204},23,[73,206,207],{},"                RequestsOpenAPIRequest(response.request),\n",[73,209,211],{"class":75,"line":210},24,[73,212,213],{},"                RequestsOpenAPIResponse(response),\n",[73,215,217],{"class":75,"line":216},25,[73,218,219],{},"            )\n",[73,221,223],{"class":75,"line":222},26,[73,224,225],{},"        except OpenAPIError as exc:\n",[73,227,229],{"class":75,"line":228},27,[73,230,231],{},"            raise AssertionError(\n",[73,233,235],{"class":75,"line":234},28,[73,236,237],{},"                f\"{method} {url} → {response.status_code} violates the contract: {exc}\"\n",[73,239,241],{"class":75,"line":240},29,[73,242,243],{},"            ) from exc\n",[73,245,247],{"class":75,"line":246},30,[73,248,249],{},"        return response\n",[73,251,253],{"class":75,"line":252},31,[73,254,104],{"emptyLinePlaceholder":103},[73,256,258],{"class":75,"line":257},32,[73,259,260],{},"    def get(self, url, **kw):  return self.request(\"GET\", url, **kw)\n",[73,262,264],{"class":75,"line":263},33,[73,265,266],{},"    def post(self, url, **kw): return self.request(\"POST\", url, **kw)\n",[73,268,270],{"class":75,"line":269},34,[73,271,104],{"emptyLinePlaceholder":103},[73,273,275],{"class":75,"line":274},35,[73,276,104],{"emptyLinePlaceholder":103},[73,278,280],{"class":75,"line":279},36,[73,281,282],{},"@pytest.fixture\n",[73,284,286],{"class":75,"line":285},37,[73,287,288],{},"def billing(billing_contract, recorded_session):\n",[73,290,292],{"class":75,"line":291},38,[73,293,294],{},"    return ValidatingClient(recorded_session, billing_contract)\n",[73,296,298],{"class":75,"line":297},39,[73,299,104],{"emptyLinePlaceholder":103},[73,301,303],{"class":75,"line":302},40,[73,304,104],{"emptyLinePlaceholder":103},[73,306,308],{"class":75,"line":307},41,[73,309,310],{},"def test_invoice_total_is_read_correctly(billing):\n",[73,312,314],{"class":75,"line":313},42,[73,315,316],{},"    invoice = billing.get(\"https:\u002F\u002Fbilling.test\u002Finvoices\u002Finv_1\").json()\n",[73,318,320],{"class":75,"line":319},43,[73,321,322],{},"    assert invoice[\"total_minor\"] == 1234\n",[324,325,328,481],"figure",{"className":326},[327],"diagram",[329,330,337,338,337,342,337,346,337,364,337,372,337,381,337,390,337,395,337,401,337,407,337,412,337,418,337,422,337,426,337,430,337,433,337,437,337,445,337,451,337,456,337,461,337,465,337,470,337,477],"svg",{"viewBox":331,"role":332,"ariaLabelledBy":333,"xmlns":336},"0 0 820 262","img",[334,335],"oa-t","oa-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[339,340,341],"title",{"id":334},"Where validation sits in each exchange",[343,344,345],"desc",{"id":335},"A test calls the validating client, which sends the request through a recorded or live session and receives a response. Before the response reaches the test it is checked against the vendored OpenAPI document; a conforming response is returned, and a non-conforming one fails the test with the path, method and violated rule.",[347,348,349,350,337],"defs",{},"\n    ",[351,352,359],"marker",{"id":353,"viewBox":354,"refX":355,"refY":356,"markerWidth":357,"markerHeight":357,"orient":358},"oa-a","0 0 10 10","9","5","7","auto-start-reverse",[360,361],"path",{"d":362,"fill":363},"M0 0 L10 5 L0 10 z","#3d405b",[365,366],"rect",{"x":367,"y":367,"width":368,"height":369,"rx":370,"fill":371},"0","820","262","14","#fffdf8",[373,374,380],"text",{"x":375,"y":376,"textAnchor":377,"fontSize":378,"fontWeight":379,"fill":363},"410","28","middle","16","700","Every response passes through the contract first",[365,382],{"x":383,"y":384,"width":385,"height":386,"rx":387,"fill":388,"stroke":363,"strokeWidth":389},"26","96","140","60","10","#f4f1de","1.6",[373,391,394],{"x":384,"y":392,"textAnchor":377,"fontSize":393,"fontWeight":379,"fill":363},"131","12","test",[75,396],{"x1":397,"y1":398,"x2":399,"y2":398,"stroke":363,"strokeWidth":389,"markerEnd":400},"170","126","204","url(#oa-a)",[365,402],{"x":403,"y":384,"width":397,"height":386,"rx":387,"fill":404,"stroke":405,"strokeWidth":406},"210","#f7f0da","#f2cc8f","2",[373,408,411],{"x":409,"y":410,"textAnchor":377,"fontSize":393,"fontWeight":379,"fill":363},"295","122","validating client",[373,413,417],{"x":409,"y":414,"textAnchor":377,"fontSize":415,"fill":416},"142","11","#8a5a00","wraps the session",[75,419],{"x1":420,"y1":398,"x2":421,"y2":398,"stroke":363,"strokeWidth":389,"markerEnd":400},"384","418",[365,423],{"x":424,"y":384,"width":425,"height":386,"rx":387,"fill":388,"stroke":363,"strokeWidth":389},"424","160",[373,427,429],{"x":428,"y":410,"textAnchor":377,"fontSize":393,"fontWeight":379,"fill":363},"504","transport",[373,431,432],{"x":428,"y":414,"textAnchor":377,"fontSize":415,"fill":363},"recorded or live",[75,434],{"x1":435,"y1":398,"x2":436,"y2":398,"stroke":363,"strokeWidth":389,"markerEnd":400},"588","622",[365,438],{"x":439,"y":440,"width":441,"height":442,"rx":387,"fill":443,"stroke":444,"strokeWidth":406},"628","56","166","64","#e6f0ea","#81b29a",[373,446,450],{"x":447,"y":448,"textAnchor":377,"fontSize":449,"fontWeight":379,"fill":363},"711","84","11.5","conforms",[373,452,455],{"x":447,"y":453,"textAnchor":377,"fontSize":415,"fill":454},"104","#2a5f49","returned to the test",[365,457],{"x":439,"y":458,"width":441,"height":442,"rx":387,"fill":459,"stroke":460,"strokeWidth":406},"134","#fbe9e3","#e07a5f",[373,462,464],{"x":447,"y":463,"textAnchor":377,"fontSize":449,"fontWeight":379,"fill":363},"162","violates",[373,466,469],{"x":447,"y":467,"textAnchor":377,"fontSize":415,"fill":468},"182","#8f3d22","fails with the rule",[365,471],{"x":383,"y":472,"width":473,"height":474,"rx":355,"fill":371,"stroke":475,"strokeWidth":476},"214","768","32","rgba(61,64,91,0.35)","1.4",[373,478,480],{"x":375,"y":479,"textAnchor":377,"fontSize":449,"fill":363},"235","Because validation lives in the client, no test can forget it and no test has to repeat it.",[482,483,484],"figcaption",{},"Recorded cassettes pass through the same check, which is what stops a stale recording from silently asserting against a response shape the provider no longer sends.",[19,486,488],{"id":487},"why-this-works","Why this works",[10,490,491,494],{},[14,492,493],{},"openapi-core"," resolves the request's path and method against the document to find the matching operation, then checks the response's status code, headers and body against that operation's declared responses. A status code not listed, a required field missing, a value of the wrong type, an enum value not in the list, a string failing its declared format — each raises with a JSON pointer to the offending location.",[10,496,497],{},"Putting the check in the client rather than in each test is what makes it reliable. A validation step that depends on every test author remembering it will be forgotten in the one test that would have caught the change; one that lives in the transport cannot be.",[19,499,501],{"id":500},"edge-cases-and-failure-modes","Edge cases and failure modes",[24,503,504,515,527,537,543],{},[27,505,506,510,511,514],{},[507,508,509],"strong",{},"Permissive published schemas."," Many providers publish ",[14,512,513],{},"additionalProperties: true"," and few required fields, so almost anything validates. Keep a tightened local copy and note the differences.",[27,516,517,526],{},[507,518,519,522,523,58],{},[14,520,521],{},"nullable"," versus ",[14,524,525],{},"type: [string, \"null\"]"," OpenAPI 3.0 and 3.1 express nullability differently; a document mixing them validates inconsistently. Normalise to the version the file declares.",[27,528,529,532,533,536],{},[507,530,531],{},"Server URLs."," The document's ",[14,534,535],{},"servers"," list may not match the test base URL, so path matching fails. Configure the validator with the test server, or strip the host before matching.",[27,538,539,542],{},[507,540,541],{},"Undocumented error responses."," A 429 or 503 the document never mentions fails validation. That is usually correct — and a prompt to ask the provider to document it.",[27,544,545,548],{},[507,546,547],{},"Recorded responses from an older schema."," A cassette recorded against 2.3 validated against 2.4 fails, which is the point: re-record it.",[19,550,552],{"id":551},"tightening-a-loose-schema-without-forking-it","Tightening a loose schema without forking it",[10,554,555],{},"A permissive published schema validates almost anything, which means the check passes while the payload drifts. The fix is an overlay: a small local document that tightens specific schemas, merged over the vendored one at load time.",[64,557,559],{"className":66,"code":558,"language":68,"meta":69,"style":69},"import copy\n\nimport yaml\nfrom openapi_core import OpenAPI\n\n\ndef load_tightened(path: str, overlay: dict) -> OpenAPI:\n    spec = yaml.safe_load(open(path))\n    schemas = spec[\"components\"][\"schemas\"]\n    for name, changes in overlay.items():\n        schemas[name] = {**copy.deepcopy(schemas[name]), **changes}\n    return OpenAPI.from_dict(spec)\n\n\nSTRICT = {\n    \"Invoice\": {\n        \"additionalProperties\": False,                    # no silent extra fields\n        \"required\": [\"id\", \"total_minor\", \"currency\", \"status\"],\n    },\n}\n\ncontract = load_tightened(\"contracts\u002Fbilling-openapi-2.4.0.yaml\", STRICT)\n",[14,560,561,566,570,575,579,583,587,592,597,602,607,612,617,621,625,630,635,640,645,650,655,659],{"__ignoreMap":69},[73,562,563],{"class":75,"line":76},[73,564,565],{},"import copy\n",[73,567,568],{"class":75,"line":82},[73,569,104],{"emptyLinePlaceholder":103},[73,571,572],{"class":75,"line":88},[73,573,574],{},"import yaml\n",[73,576,577],{"class":75,"line":94},[73,578,85],{},[73,580,581],{"class":75,"line":100},[73,582,104],{"emptyLinePlaceholder":103},[73,584,585],{"class":75,"line":107},[73,586,104],{"emptyLinePlaceholder":103},[73,588,589],{"class":75,"line":112},[73,590,591],{},"def load_tightened(path: str, overlay: dict) -> OpenAPI:\n",[73,593,594],{"class":75,"line":118},[73,595,596],{},"    spec = yaml.safe_load(open(path))\n",[73,598,599],{"class":75,"line":124},[73,600,601],{},"    schemas = spec[\"components\"][\"schemas\"]\n",[73,603,604],{"class":75,"line":130},[73,605,606],{},"    for name, changes in overlay.items():\n",[73,608,609],{"class":75,"line":136},[73,610,611],{},"        schemas[name] = {**copy.deepcopy(schemas[name]), **changes}\n",[73,613,614],{"class":75,"line":141},[73,615,616],{},"    return OpenAPI.from_dict(spec)\n",[73,618,619],{"class":75,"line":146},[73,620,104],{"emptyLinePlaceholder":103},[73,622,623],{"class":75,"line":152},[73,624,104],{"emptyLinePlaceholder":103},[73,626,627],{"class":75,"line":158},[73,628,629],{},"STRICT = {\n",[73,631,632],{"class":75,"line":163},[73,633,634],{},"    \"Invoice\": {\n",[73,636,637],{"class":75,"line":169},[73,638,639],{},"        \"additionalProperties\": False,                    # no silent extra fields\n",[73,641,642],{"class":75,"line":175},[73,643,644],{},"        \"required\": [\"id\", \"total_minor\", \"currency\", \"status\"],\n",[73,646,647],{"class":75,"line":180},[73,648,649],{},"    },\n",[73,651,652],{"class":75,"line":186},[73,653,654],{},"}\n",[73,656,657],{"class":75,"line":192},[73,658,104],{"emptyLinePlaceholder":103},[73,660,661],{"class":75,"line":198},[73,662,663],{},"contract = load_tightened(\"contracts\u002Fbilling-openapi-2.4.0.yaml\", STRICT)\n",[10,665,666,667,58],{},"Keeping the overlay separate from the vendored file preserves a clean upgrade path: replacing the provider's document with a new version leaves the local tightening intact, and a diff of the overlay shows exactly which assumptions this consumer makes beyond what the provider promises. Those assumptions are also the natural contents of a consumer-driven contract, covered in ",[54,668,670],{"href":669},"\u002Fintegration-database-and-service-testing\u002Fcontract-testing-for-http-apis\u002Fconsumer-driven-contract-tests-with-pact-python\u002F","consumer-driven contract tests with Pact Python",[19,672,674],{"id":673},"validating-mocked-responses-too","Validating mocked responses too",[10,676,677,678,681,682,685],{},"The highest-value place for this check is not the live or recorded response — it is the hand-written mock. A test that stubs the provider with ",[14,679,680],{},"responses"," or ",[14,683,684],{},"respx"," asserts against whatever JSON its author typed, and that JSON drifts from reality the day it is written. Routing mocks through the same validator makes them honest.",[64,687,689],{"className":66,"code":688,"language":68,"meta":69,"style":69},"import pytest\nimport responses\n\n\n@pytest.fixture\ndef mocked_billing(billing_contract):\n    with responses.RequestsMock() as rsps:\n        original_add = rsps.add\n\n        def add_validated(method, url, json=None, status=200, **kwargs):\n            # Validate the MOCK against the contract before registering it.\n            body = _fake_response(method, url, json, status)\n            billing_contract.validate_response(*body)\n            return original_add(method, url, json=json, status=status, **kwargs)\n\n        rsps.add = add_validated\n        yield rsps\n\n\ndef test_handles_a_paid_invoice(mocked_billing, billing_client):\n    mocked_billing.add(\n        \"GET\", \"https:\u002F\u002Fbilling.test\u002Finvoices\u002Finv_1\",\n        json={\"id\": \"inv_1\", \"total_minor\": 1234, \"currency\": \"GBP\", \"status\": \"paid\"},\n    )\n    assert billing_client.fetch(\"inv_1\").is_paid\n",[14,690,691,695,700,704,708,712,717,722,727,731,736,741,746,751,756,760,765,770,774,778,783,788,793,798,803],{"__ignoreMap":69},[73,692,693],{"class":75,"line":76},[73,694,79],{},[73,696,697],{"class":75,"line":82},[73,698,699],{},"import responses\n",[73,701,702],{"class":75,"line":88},[73,703,104],{"emptyLinePlaceholder":103},[73,705,706],{"class":75,"line":94},[73,707,104],{"emptyLinePlaceholder":103},[73,709,710],{"class":75,"line":100},[73,711,282],{},[73,713,714],{"class":75,"line":107},[73,715,716],{},"def mocked_billing(billing_contract):\n",[73,718,719],{"class":75,"line":112},[73,720,721],{},"    with responses.RequestsMock() as rsps:\n",[73,723,724],{"class":75,"line":118},[73,725,726],{},"        original_add = rsps.add\n",[73,728,729],{"class":75,"line":124},[73,730,104],{"emptyLinePlaceholder":103},[73,732,733],{"class":75,"line":130},[73,734,735],{},"        def add_validated(method, url, json=None, status=200, **kwargs):\n",[73,737,738],{"class":75,"line":136},[73,739,740],{},"            # Validate the MOCK against the contract before registering it.\n",[73,742,743],{"class":75,"line":141},[73,744,745],{},"            body = _fake_response(method, url, json, status)\n",[73,747,748],{"class":75,"line":146},[73,749,750],{},"            billing_contract.validate_response(*body)\n",[73,752,753],{"class":75,"line":152},[73,754,755],{},"            return original_add(method, url, json=json, status=status, **kwargs)\n",[73,757,758],{"class":75,"line":158},[73,759,104],{"emptyLinePlaceholder":103},[73,761,762],{"class":75,"line":163},[73,763,764],{},"        rsps.add = add_validated\n",[73,766,767],{"class":75,"line":169},[73,768,769],{},"        yield rsps\n",[73,771,772],{"class":75,"line":175},[73,773,104],{"emptyLinePlaceholder":103},[73,775,776],{"class":75,"line":180},[73,777,104],{"emptyLinePlaceholder":103},[73,779,780],{"class":75,"line":186},[73,781,782],{},"def test_handles_a_paid_invoice(mocked_billing, billing_client):\n",[73,784,785],{"class":75,"line":192},[73,786,787],{},"    mocked_billing.add(\n",[73,789,790],{"class":75,"line":198},[73,791,792],{},"        \"GET\", \"https:\u002F\u002Fbilling.test\u002Finvoices\u002Finv_1\",\n",[73,794,795],{"class":75,"line":204},[73,796,797],{},"        json={\"id\": \"inv_1\", \"total_minor\": 1234, \"currency\": \"GBP\", \"status\": \"paid\"},\n",[73,799,800],{"class":75,"line":210},[73,801,802],{},"    )\n",[73,804,805],{"class":75,"line":216},[73,806,807],{},"    assert billing_client.fetch(\"inv_1\").is_paid\n",[324,809,811,902],{"className":810},[327],[329,812,337,817,337,820,337,823,337,830,337,834,337,839,337,846,337,851,337,854,337,858,337,861,337,865,337,872,337,875,337,878,337,883,337,888,337,891,337,895,337,899],{"viewBox":813,"role":332,"ariaLabelledBy":814,"xmlns":336},"0 0 800 240",[815,816],"mock-t","mock-d",[339,818,819],{"id":815},"Three sources of responses, one validator",[343,821,822],{"id":816},"Live responses, recorded cassettes and hand-written mocks all pass through the same contract validator before reaching the test. The hand-written mock is the source most likely to drift from reality, so validating it gives the largest benefit.",[347,824,349,825,337],{},[351,826,828],{"id":827,"viewBox":354,"refX":355,"refY":356,"markerWidth":357,"markerHeight":357,"orient":358},"mock-a",[360,829],{"d":362,"fill":444},[365,831],{"x":367,"y":367,"width":832,"height":833,"rx":370,"fill":371},"800","240",[373,835,838],{"x":836,"y":376,"textAnchor":377,"fontSize":837,"fontWeight":379,"fill":363},"400","15.5","Validate wherever a response comes from",[365,840],{"x":841,"y":842,"width":843,"height":844,"rx":387,"fill":443,"stroke":444,"strokeWidth":845},"34","52","220","44","1.8",[373,847,850],{"x":848,"y":849,"textAnchor":377,"fontSize":449,"fill":363},"144","79","live sandbox — rarely drifts",[365,852],{"x":841,"y":853,"width":843,"height":844,"rx":387,"fill":404,"stroke":405,"strokeWidth":845},"108",[373,855,857],{"x":848,"y":856,"textAnchor":377,"fontSize":449,"fill":363},"135","recorded cassette — ages",[365,859],{"x":841,"y":860,"width":843,"height":844,"rx":387,"fill":459,"stroke":460,"strokeWidth":845},"164",[373,862,864],{"x":848,"y":863,"textAnchor":377,"fontSize":449,"fill":363},"191","hand-written mock — drifts first",[75,866],{"x1":867,"y1":868,"x2":869,"y2":870,"stroke":444,"strokeWidth":845,"markerEnd":871},"258","74","350","118","url(#mock-a)",[75,873],{"x1":867,"y1":874,"x2":869,"y2":874,"stroke":444,"strokeWidth":845,"markerEnd":871},"130",[75,876],{"x1":867,"y1":877,"x2":869,"y2":414,"stroke":444,"strokeWidth":845,"markerEnd":871},"186",[365,879],{"x":880,"y":384,"width":881,"height":882,"rx":415,"fill":388,"stroke":363,"strokeWidth":845},"356","200","68",[373,884,887],{"x":885,"y":886,"textAnchor":377,"fontSize":393,"fontWeight":379,"fill":363},"456","124","contract validator",[373,889,890],{"x":885,"y":848,"textAnchor":377,"fontSize":415,"fill":363},"same document for all",[365,892],{"x":893,"y":384,"width":877,"height":882,"rx":415,"fill":371,"stroke":475,"strokeWidth":894},"584","1.5",[373,896,898],{"x":897,"y":886,"textAnchor":377,"fontSize":415,"fill":363},"677","the test only ever sees",[373,900,901],{"x":897,"y":848,"textAnchor":377,"fontSize":415,"fill":363},"contract-shaped data",[482,903,904],{},"A mock validated against the schema cannot claim a field the provider does not send, which is the most common way mocked tests pass against an interface that does not exist.",[10,906,907],{},"This inverts the usual weakness of mocked HTTP tests. Instead of the mock being a place where invented shapes accumulate unchecked, it becomes a place where every invented shape is checked against the provider's own description at the moment it is written.",[19,909,911],{"id":910},"upgrading-the-vendored-document","Upgrading the vendored document",[10,913,914],{},"Vendoring the schema makes upgrades deliberate, which is the point, but it also means somebody has to perform them. A small routine keeps that cheap and turns each upgrade into a reviewable change rather than a surprise.",[10,916,917,918,921],{},"First, diff the old and new documents at the level of operations and schemas rather than as text. Tools such as ",[14,919,920],{},"oasdiff"," classify changes as breaking or non-breaking, which is exactly the question the review needs answered: a new optional field is safe, a removed field or a narrowed enum is not.",[64,923,927],{"className":924,"code":925,"language":926,"meta":69,"style":69},"language-bash shiki shiki-themes github-light github-dark","oasdiff breaking contracts\u002Fbilling-openapi-2.4.0.yaml downloads\u002Fbilling-openapi-2.5.0.yaml\n","bash",[14,928,929],{"__ignoreMap":69},[73,930,931,934,938,941],{"class":75,"line":76},[73,932,920],{"class":933},"sScJk",[73,935,937],{"class":936},"sZZnC"," breaking",[73,939,940],{"class":936}," contracts\u002Fbilling-openapi-2.4.0.yaml",[73,942,943],{"class":936}," downloads\u002Fbilling-openapi-2.5.0.yaml\n",[64,945,949],{"className":946,"code":948,"language":373,"meta":69},[947],"language-text","error  [response-property-removed] GET \u002Finvoices\u002F{id} 200: removed 'legacy_ref'\nwarn   [response-property-enum-value-added] GET \u002Finvoices\u002F{id} 200: status added 'disputed'\n",[14,950,948],{"__ignoreMap":69},[10,952,953,954,958,959,962,963,966,967,970,971,974],{},"Second, swap the file and run the suite. Every test that fails is now a precise list of places in ",[955,956,957],"em",{},"your"," code that depend on something the provider changed — the removed ",[14,960,961],{},"legacy_ref"," will surface wherever it was read, and the new ",[14,964,965],{},"disputed"," status will surface wherever a ",[14,968,969],{},"match"," or an ",[14,972,973],{},"if"," chain assumed the old set was exhaustive.",[10,976,977],{},"Third, commit the new document and the fixes together, with the diff output in the commit message. That record answers the question every future reader of the code will ask — \"when did the provider start sending this?\" — without anyone having to reconstruct it.",[324,979,981,1079],{"className":980},[327],[329,982,337,987,337,990,337,993,337,1000,337,1003,337,1006,337,1009,337,1015,337,1019,337,1023,337,1027,337,1033,337,1036,337,1040,337,1044,337,1047,337,1050,337,1054,337,1058,337,1062,337,1066,337,1069,337,1072,337,1076],{"viewBox":983,"role":332,"ariaLabelledBy":984,"xmlns":336},"0 0 800 244",[985,986],"upg-t","upg-d",[339,988,989],{"id":985},"A deliberate schema upgrade in three steps",[343,991,992],{"id":986},"Three steps. Diff the old and new documents with a tool that classifies breaking changes. Replace the vendored file and run the suite so every dependency on a changed field fails in place. Commit the new document together with the code fixes and the diff output.",[347,994,349,995,337],{},[351,996,998],{"id":997,"viewBox":354,"refX":355,"refY":356,"markerWidth":357,"markerHeight":357,"orient":358},"upg-a",[360,999],{"d":362,"fill":363},[365,1001],{"x":367,"y":367,"width":832,"height":1002,"rx":370,"fill":371},"244",[373,1004,1005],{"x":836,"y":376,"textAnchor":377,"fontSize":837,"fontWeight":379,"fill":363},"The upgrade is a change you review, not an event you suffer",[365,1007],{"x":383,"y":440,"width":1008,"height":874,"rx":393,"fill":404,"stroke":405,"strokeWidth":406},"230",[373,1010,1014],{"x":1011,"y":1012,"textAnchor":377,"fontSize":1013,"fontWeight":379,"fill":363},"141","82","12.5","1 · diff",[373,1016,1018],{"x":844,"y":1017,"fontSize":415,"fill":363},"110","oasdiff breaking",[373,1020,1022],{"x":844,"y":1021,"fontSize":415,"fill":363},"132","removed fields,",[373,1024,1026],{"x":844,"y":1025,"fontSize":415,"fill":363},"154","narrowed enums",[75,1028],{"x1":1029,"y1":1030,"x2":1031,"y2":1030,"stroke":363,"strokeWidth":389,"markerEnd":1032},"260","121","286","url(#upg-a)",[365,1034],{"x":1035,"y":440,"width":1008,"height":874,"rx":393,"fill":443,"stroke":444,"strokeWidth":406},"292",[373,1037,1039],{"x":1038,"y":1012,"textAnchor":377,"fontSize":1013,"fontWeight":379,"fill":363},"407","2 · swap and run",[373,1041,1043],{"x":1042,"y":1017,"fontSize":415,"fill":363},"310","every failure is a place",[373,1045,1046],{"x":1042,"y":1021,"fontSize":415,"fill":363},"your code depended on",[373,1048,1049],{"x":1042,"y":1025,"fontSize":415,"fill":454},"the changed contract",[75,1051],{"x1":1052,"y1":1030,"x2":1053,"y2":1030,"stroke":363,"strokeWidth":389,"markerEnd":1032},"526","552",[365,1055],{"x":1056,"y":440,"width":1057,"height":874,"rx":393,"fill":443,"stroke":444,"strokeWidth":406},"558","216",[373,1059,1061],{"x":1060,"y":1012,"textAnchor":377,"fontSize":1013,"fontWeight":379,"fill":363},"666","3 · commit together",[373,1063,1065],{"x":1064,"y":1017,"fontSize":415,"fill":363},"576","new document,",[373,1067,1068],{"x":1064,"y":1021,"fontSize":415,"fill":363},"code fixes, and the",[373,1070,1071],{"x":1064,"y":1025,"fontSize":415,"fill":363},"diff in the message",[365,1073],{"x":383,"y":881,"width":1074,"height":1075,"rx":355,"fill":371,"stroke":475,"strokeWidth":476},"748","30",[373,1077,1078],{"x":836,"y":843,"textAnchor":377,"fontSize":415,"fill":363},"A schema fetched at runtime skips all three steps — and the review they would have prompted.",[482,1080,1081],{},"Step two is where vendoring pays for itself: the test failures are an exact map of the consumer's exposure to the provider's change.",[10,1083,1084],{},"A scheduled job that downloads the provider's current document and runs the diff against the vendored one — reporting rather than failing — gives advance notice of upcoming changes without letting them destabilise the build. It is the same pattern as the sandbox monitor for third-party providers, applied to the document rather than to live responses.",[10,1086,1087,1088,1090],{},"Finally, validate requests as well as responses where the provider's document describes them. ",[14,1089,493],{}," checks outgoing request bodies, query parameters and headers against the same operation, which catches the mirror-image bug: a consumer sending a field the provider has deprecated, or omitting one it has made required. A request-side failure in the test suite is far cheaper than the 400 responses it would otherwise produce in production, and it surfaces in exactly the test that exercises the call. Enabling it is one extra call in the wrapper above, made before the request is sent rather than after the response arrives, and it uses the document already parsed for the response checks. Between the two directions, every exchange in the suite is checked against the provider's own description of it, which is as close to a guarantee as a consumer can get without the provider's cooperation.",[19,1092,1094],{"id":1093},"frequently-asked-questions","Frequently Asked Questions",[10,1096,1097,1100],{},[507,1098,1099],{},"Should the schema be fetched from the provider at test time?","\nNo. Vendor a copy at a known version and update it deliberately. A schema fetched at runtime means the provider's edit changes your test results with no commit in your repository, and an unreachable provider breaks your suite entirely.",[10,1102,1103,1106],{},[507,1104,1105],{},"Does validation catch a field that changed meaning but not type?","\nNo. Schema validation checks structure — types, required fields, enums, formats. A price that switched from pounds to pence is still an integer. Semantic changes need consumer-driven contracts or explicit assertions on known values.",[10,1108,1109,1112],{},[507,1110,1111],{},"Is validating every response too slow?","\nRarely. Validation is a pure in-memory check against a parsed schema and costs microseconds to low milliseconds per response. Parse the document once per session, and the overhead disappears into the noise of the HTTP call itself.",[19,1114,1116],{"id":1115},"related","Related",[24,1118,1119,1125,1131,1138],{},[27,1120,1121,1124],{},[54,1122,1123],{"href":56},"Contract Testing for HTTP APIs"," — where schema validation sits among the contract techniques.",[27,1126,1127,1130],{},[54,1128,1129],{"href":669},"Consumer-Driven Contract Tests with Pact Python"," — the step that catches semantic changes.",[27,1132,1133,1137],{},[54,1134,1136],{"href":1135},"\u002Fadvanced-mocking-test-doubles-in-python\u002Fmocking-network-and-http-calls\u002Frecording-and-replaying-http-with-vcrpy\u002F","Recording and Replaying HTTP with VCR.py"," — the cassettes this client can validate.",[27,1139,1140,1144],{},[54,1141,1143],{"href":1142},"\u002Fadvanced-mocking-test-doubles-in-python\u002Fmocking-network-and-http-calls\u002Fmocking-httpx-clients-with-respx\u002F","Mocking httpx Clients with respx"," — the transport-level fake to validate against for httpx users.",[10,1146,1147,1148],{},"← Back to ",[54,1149,1123],{"href":56},[1151,1152,1153],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":69,"searchDepth":82,"depth":82,"links":1155},[1156,1157,1158,1159,1160,1161,1162,1163,1164],{"id":21,"depth":82,"text":22},{"id":61,"depth":82,"text":62},{"id":487,"depth":82,"text":488},{"id":500,"depth":82,"text":501},{"id":551,"depth":82,"text":552},{"id":673,"depth":82,"text":674},{"id":910,"depth":82,"text":911},{"id":1093,"depth":82,"text":1094},{"id":1115,"depth":82,"text":1116},"Assert every HTTP response in a test suite conforms to its OpenAPI document with openapi-core: a validating client wrapper, strict schemas, and useful failure messages.","md",{"slug":1168,"type":1169,"breadcrumb":1170,"datePublished":1171,"dateModified":1171,"faq":1172,"howto":1179},"validating-responses-against-an-openapi-schema","article","OpenAPI Validation","2026-09-18",[1173,1175,1177],{"q":1099,"a":1174},"No. Vendor a copy at a known version and update it deliberately. A schema fetched at runtime means the provider's edit changes your test results with no commit in your repository, and an unreachable provider breaks your suite entirely.",{"q":1105,"a":1176},"No. Schema validation checks structure — types, required fields, enums, formats. A price that switched from pounds to pence is still an integer. Semantic changes need consumer-driven contracts or explicit assertions on known values.",{"q":1111,"a":1178},"Rarely. Validation is a pure in-memory check against a parsed schema and costs microseconds to low milliseconds per response. Parse the document once per session, and the overhead disappears into the noise of the HTTP call itself.",{"name":1180,"description":1181,"steps":1182},"How to validate every response against an OpenAPI document","Vendor the schema, parse it once, wrap the test client so every response is checked, and tighten the schema where it is loose.",[1183,1186,1189,1192,1195],{"name":1184,"text":1185},"Vendor the document","Commit the provider's OpenAPI file at a specific version into the repository.",{"name":1187,"text":1188},"Parse it once per session","Load it with openapi-core in a session-scoped fixture so validation adds negligible cost.",{"name":1190,"text":1191},"Wrap the client","Validate the request and response on every call so no test can forget to.",{"name":1193,"text":1194},"Tighten loose schemas","Set additionalProperties to false and mark required fields where the published schema is permissive.",{"name":1196,"text":1197},"Report failures precisely","Include the path, the method and the validation error's JSON pointer in the assertion message.","\u002Fintegration-database-and-service-testing\u002Fcontract-testing-for-http-apis\u002Fvalidating-responses-against-an-openapi-schema",{"title":5,"description":1165},"integration-database-and-service-testing\u002Fcontract-testing-for-http-apis\u002Fvalidating-responses-against-an-openapi-schema\u002Findex","m-4_KUggcLoMBFWeLzgx-XFLeE-yeBsRjuQmdAd_GJY",1789718767493]