QA How-To
REST Assured POJO serialization: A Practical Guide (2026)
Master REST Assured POJO serialization with runnable Java records, Jackson configuration, typed responses, null and date handling, generics, and contract tests.
25 min read | 2,862 words
TL;DR
REST Assured POJO serialization works by setting a media type and passing an object to `.body(...)`; an available mapper such as Jackson converts it to JSON. Pin the mapper, use contract-focused request and response models, configure dates and nulls deliberately, validate first, then deserialize with `.extract().as(...)` or `TypeRef`.
Key Takeaways
- Set the request content type before passing a POJO so REST Assured selects the intended serializer.
- Pin one mapper implementation and configuration instead of relying on accidental classpath discovery.
- Use separate request and response types when the API owns IDs, timestamps, links, or status fields.
- Model null, absent, default, enum, decimal, and date-time behavior as explicit contract decisions.
- Validate the HTTP contract before deserializing a response into a Java type.
- Use `TypeRef` for generic collections whose element type would otherwise be erased.
- Compare semantic fields and server rules, not merely a serialized request echoed back unchanged.
REST Assured POJO serialization lets a Java API test send a typed object instead of assembling quote-heavy JSON strings. Set Content-Type: application/json, pass the object to .body(payload), and REST Assured delegates conversion to an object mapper on the test classpath. The convenient line is easy. The hard part is making field names, nulls, dates, decimals, enums, and response types match the API contract across environments and upgrades.
This guide uses Java 17 records, REST Assured 6.0.1, JUnit 5, and the maintained Jackson 2.21 line. REST Assured 6 also supports Jackson 3, whose packages moved from com.fasterxml.jackson to tools.jackson. Choose one line deliberately and do not let transitive dependencies decide your wire format.
TL;DR
record CreateUserRequest(String name, String email, String role) {}
CreateUserRequest request = new CreateUserRequest(
"Maya Rao",
"maya.rao@example.test",
"reviewer"
);
given()
.baseUri(System.getProperty("baseUrl", "http://localhost:8080"))
.contentType("application/json")
.body(request)
.when()
.post("/api/users")
.then()
.statusCode(201)
.body("email", equalTo("maya.rao@example.test"));
| Payload style | Strength | Weakness | Best fit |
|---|---|---|---|
| Java record or POJO | Compile-time names and reusable builders | Mapper configuration can surprise | Stable request contracts |
Map<String, Object> |
Flexible for sparse or dynamic bodies | Typos and types are runtime concerns | Small targeted variations |
| Raw JSON string | Exact wire text is visible | Escaping and maintenance are error-prone | Malformed JSON and exact fixture tests |
| JSON tree model | Precise structural mutation | Coupled to mapper API | Patch-style and dynamic payloads |
| External JSON fixture | Realistic large documents | Hidden values and fixture drift | Approved golden examples |
1. How REST Assured POJO serialization works
When .body(someObject) receives an ordinary Java object, REST Assured chooses serialization based primarily on the request content type and the object mappers available on the classpath. With JSON content, supported choices include Jackson and Gson families. With XML, supported XML mappers apply. A Java String, byte array, or input stream is treated as already serialized content rather than a POJO to map.
given()
.contentType(io.restassured.http.ContentType.JSON)
.body(new CreateUserRequest("Maya Rao", "maya@example.test", "reviewer"))
.when()
.post("/api/users");
The Content-Type header describes what is being sent. Accept describes what response representation the client wants. Set both when the contract requires JSON. Forgetting content type can select the wrong mapper, omit a header the server requires, or make behavior depend on which optional library happened to be loaded.
Serialization is not request validation. A mapper can produce valid JSON that violates required fields, formats, enum values, or cross-field rules. The server remains responsible for enforcing its contract, and tests should assert those outcomes. Likewise, a POJO field named userName does not guarantee a JSON property named username; naming strategy and annotations control the wire name.
Use typed payloads for stable contracts, but do not force every negative test through a type that prevents invalid input. A raw string or JSON tree is correct when the scenario intentionally sends duplicate keys, malformed syntax, a wrong JSON type, or a field that Java cannot represent in the normal model.
2. Configure a runnable mapper dependency
REST Assured needs a supported object mapper available. This Maven configuration pins REST Assured, JUnit, Jackson databind, and Java time support. A dependency-management BOM is preferable in a larger project, but explicit properties make the example self-contained.
<properties>
<maven.compiler.release>17</maven.compiler.release>
<rest-assured.version>6.0.1</rest-assured.version>
<junit.version>5.13.4</junit.version>
<jackson.version>2.21.3</jackson.version>
</properties>
<dependencies>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<version>${rest-assured.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>${jackson.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
Inspect the effective dependency tree during upgrades. Multiple Jackson minor lines can compile but fail at runtime with missing-method errors. Frameworks such as Spring Boot manage a compatible mapper set, so follow their dependency management rather than overriding one component in isolation.
REST Assured 6 can coexist with Jackson 2 and Jackson 3 support, but their databind packages differ. Existing com.fasterxml.jackson.databind.ObjectMapper customization is Jackson 2 code. A migration to tools.jackson needs deliberate source and configuration changes. Version pinning is not only build hygiene, it protects the JSON contract from silent defaults changing.
3. Design request POJOs and Java records
A request type should contain only fields the client is allowed to send. If the service creates IDs, timestamps, audit principals, and status, omit them from CreateUserRequest. Separate request and response models make authority clear and prevent tests from accidentally submitting server-owned values.
import com.fasterxml.jackson.annotation.JsonProperty;
public record AddressRequest(
String line1,
String city,
@JsonProperty("postal_code") String postalCode,
String country
) {}
public record CreateCustomerRequest(
String name,
String email,
AddressRequest address
) {}
Java records are concise immutable carriers and work well with current Jackson. Traditional POJOs remain appropriate when a framework baseline, mutable builder, or library requires no-argument construction and setters. Pick consistency based on the test stack, not fashion.
Annotations belong at the contract boundary. @JsonProperty documents a wire name that differs from Java naming. @JsonInclude controls inclusion. @JsonFormat can define a contractual date representation. Avoid adding broad annotations merely to make one surprising test pass. Each annotation can affect both serialization and deserialization.
Use domain types carefully. An enum prevents spelling mistakes in happy-path requests, but negative tests still need a way to send an unknown string. BigDecimal is appropriate for exact monetary values. Instant is appropriate for a moment in UTC, while LocalDate is appropriate for a calendar date. Do not represent every value as String just because JSON is textual.
A builder or fixture method should expose scenario intent. aValidCustomer().withCountry("IN") is more readable than a constructor with twelve positional values, provided the builder does not hide risky defaults.
4. Serialize nested objects, lists, enums, and decimals
REST Assured can serialize an object graph, including nested records and lists, through the selected mapper. The model should reflect JSON structure rather than internal database tables.
import java.math.BigDecimal;
import java.util.List;
enum OrderChannel { WEB, MOBILE }
record OrderLineRequest(String sku, int quantity, BigDecimal unitPrice) {}
record CreateOrderRequest(
String customerId,
OrderChannel channel,
List<OrderLineRequest> lines
) {}
CreateOrderRequest order = new CreateOrderRequest(
"cust-421",
OrderChannel.WEB,
List.of(
new OrderLineRequest("BOOK-API", 2, new BigDecimal("19.95")),
new OrderLineRequest("NOTE-01", 1, new BigDecimal("4.50"))
)
);
given()
.baseUri(baseUrl)
.contentType("application/json")
.accept("application/json")
.body(order)
.when()
.post("/api/orders")
.then()
.statusCode(201)
.body("customerId", equalTo("cust-421"))
.body("lines.size()", equalTo(2));
Confirm enum representation. Default Jackson serialization uses the enum name, such as WEB. If the API expects web, define the contract with @JsonValue, an explicit field, or a reviewed naming strategy. Do not override toString() for display and accidentally make wire behavior depend on it.
BigDecimal preserves decimal intent inside Java, but JSON does not preserve a lexical scale promise unless the contract says so. The server might return 44.40, 44.4, or a string. Assert numeric business value or exact textual representation according to the API specification, not mapper coincidence.
Collections should usually be immutable test inputs. Avoid reusing and mutating one list across parameterized tests, which creates order-dependent payloads.
5. Control JSON field names, nulls, and absent values
Null and absence are not interchangeable in every API. In a PATCH request, an absent field can mean "leave unchanged" while an explicit null means "clear this value." A create contract may reject null and absence with different error codes. Configure inclusion per payload type or mapper only after deciding semantics.
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
@JsonInclude(JsonInclude.Include.NON_NULL)
record UpdateProfileRequest(
@JsonProperty("display_name") String displayName,
String bio,
String locale
) {}
NON_NULL omits null properties, so this type cannot express "send JSON null." If PATCH needs three states, model them explicitly, use a JSON tree, or define a wrapper that distinguishes undefined from present-null. An Optional field is not automatically the right wire model and can introduce mapper-specific behavior.
Global omission of empty values is even riskier. An empty string, empty list, zero, and false can be meaningful boundary inputs. A broad NON_EMPTY rule can remove the exact data a validation test intends to send.
Use @JsonProperty(required = true) cautiously. Mapper metadata does not replace server-side validation, and behavior varies by construction style. Tests should assert the API's required-field contract by intentionally omitting the field.
For unknown response fields, tolerant deserialization helps clients survive additive changes, but schema or direct JSON assertions can still detect prohibited changes. Decide whether the test is checking client compatibility or strict provider output. Those are different objectives.
6. Configure dates and time zones deliberately
Time defects often look like serialization defects. A contract might require RFC 3339 instants such as 2026-07-13T08:15:30Z, local dates such as 2026-07-13, or offsets that preserve source context. Pick the correct Java type and register Java time support.
import com.fasterxml.jackson.annotation.JsonFormat;
import java.time.Instant;
import java.time.LocalDate;
record ScheduleJobRequest(
String jobType,
Instant runAt,
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
LocalDate businessDate
) {}
With jackson-datatype-jsr310 registered, Jackson serializes modern Java time types. Confirm the exact format with a focused mapper test before relying on an end-to-end assertion. Do not use the machine's default time zone as an implicit API setting. CI runners in different regions will expose the mistake.
Use a fixed Clock in test data builders when current time matters. Generate Instant now = clock.instant() once and derive related values from it. Calling Instant.now() in several assertions creates tiny races and unclear failure windows.
Assert time semantically. Parse the returned value and compare instants or allowable precision. If the API promises millisecond precision, verify that promise. Do not require nanoseconds that the database or specification never guarantees.
DST scenarios need region-aware inputs and an API contract that explains nonexistent or duplicated local times. An Instant avoids ambiguity for execution moments, while a local appointment may legitimately need a zone identifier. Serialization cannot solve an underspecified domain model.
7. Select and customize the Jackson mapper
When several mapper implementations are present, specify the intended one. REST Assured provides ObjectMapperType overloads. This keeps a request from switching to Gson because a dependency changed.
import io.restassured.mapper.ObjectMapperType;
given()
.baseUri(baseUrl)
.contentType("application/json")
.body(order, ObjectMapperType.JACKSON_2)
.when()
.post("/api/orders")
.then()
.statusCode(201);
For suite-wide Jackson 2 behavior, create one configured mapper and supply it through REST Assured object-mapper configuration.
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import io.restassured.config.ObjectMapperConfig;
import io.restassured.config.RestAssuredConfig;
ObjectMapper mapper = new ObjectMapper()
.findAndRegisterModules()
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
RestAssuredConfig mapperConfig = RestAssuredConfig.config()
.objectMapperConfig(ObjectMapperConfig.objectMapperConfig()
.jackson2ObjectMapperFactory((type, charset) -> mapper));
Apply mapperConfig to a request spec rather than mutating global state. The factory can return a shared immutable-after-configuration ObjectMapper, which is safe for concurrent reads and writes. Do not reconfigure it while tests run.
Tolerating unknown response properties is useful for additive compatibility. It should not make strict contract tests disappear. Assert critical JSON fields and use schema or consumer contract checks where unexpected output matters. Mapper configuration serves the client representation, not every testing goal.
8. Deserialize responses into typed POJOs
Validate status, content type, and critical shape before deserialization. Otherwise a proxy HTML error or problem response can produce a mapper exception that hides the real HTTP failure.
import java.time.Instant;
record UserResponse(
String id,
String name,
String email,
String status,
Instant createdAt
) {}
UserResponse created =
given()
.config(mapperConfig)
.baseUri(baseUrl)
.contentType("application/json")
.body(new CreateUserRequest("Maya Rao", "maya@example.test", "reviewer"))
.when()
.post("/api/users")
.then()
.statusCode(201)
.contentType("application/json")
.body("id", not(blankOrNullString()))
.extract()
.as(UserResponse.class);
org.junit.jupiter.api.Assertions.assertEquals("maya@example.test", created.email());
A typed response makes related assertions and later workflow steps easy, but keep the original Response when mapper failures need raw safe diagnostics. Avoid turning every simple field check into deserialization ceremony. REST Assured JSON path is often clearer for one or two assertions.
Request and response types should differ when server-owned fields exist. Reusing UserResponse as a create body can send IDs and status accidentally. It also obscures the consumer's allowed input.
When the API returns a documented problem type for errors, deserialize it into a separate ProblemResponse only after asserting the error media type and status. Never attempt to map both success and error bodies into one giant object with mostly nullable fields.
9. Deserialize generic collections with TypeRef
Java erases generic type parameters at runtime. Extracting List<UserResponse> through List.class produces untyped maps rather than user objects. REST Assured's TypeRef retains the generic signature for deserialization.
import io.restassured.common.mapper.TypeRef;
import java.util.List;
List<UserResponse> users =
given()
.config(mapperConfig)
.baseUri(baseUrl)
.when()
.get("/api/users?state=active")
.then()
.statusCode(200)
.contentType("application/json")
.extract()
.as(new TypeRef<List<UserResponse>>() {});
org.junit.jupiter.api.Assertions.assertTrue(
users.stream().allMatch(user -> "ACTIVE".equals(user.status()))
);
For a paginated wrapper, model the wrapper itself: PageResponse<UserResponse>. A TypeRef<PageResponse<UserResponse>> preserves both levels. This is clearer than reaching through nested maps and casting values at runtime.
Do not use typed deserialization to weaken assertions. The stream check above proves every returned item is active, but an empty list also passes. If the fixture guarantees at least one active user, assert non-emptiness separately. If emptiness is valid, test it intentionally.
Generic domain types can become overengineered. Use them when several endpoints share a real pagination contract. Do not create a universal response wrapper that hides differing cursor, total, and link semantics.
10. Verify serialized JSON before sending it
When payload mapping is framework infrastructure, add focused mapper tests. They run faster than network tests and show exactly what annotations and configuration produce. Serialize to a JSON tree and assert semantic fields rather than formatting order.
import com.fasterxml.jackson.databind.JsonNode;
CreateCustomerRequest payload = new CreateCustomerRequest(
"Maya Rao",
"maya@example.test",
new AddressRequest("18 Test Lane", "Pune", "411001", "IN")
);
JsonNode json = mapper.valueToTree(payload);
org.junit.jupiter.api.Assertions.assertEquals(
"411001",
json.at("/address/postal_code").asText()
);
org.junit.jupiter.api.Assertions.assertFalse(json.has("id"));
A mapper test catches a renamed property or changed inclusion rule before a hundred API scenarios fail. Add golden JSON sparingly for highly controlled contracts. Property order and whitespace usually are not API behavior, so textual equality creates brittle tests.
Round-trip tests, serialize then deserialize and compare, prove mapper symmetry for the model. They do not prove the API accepts the payload or that an external producer uses the same representation. A mapper can make the same wrong choice in both directions. Combine focused mapper checks with server contract assertions.
Log serialized JSON only with synthetic data and redaction. A mapper test that prints the entire object on failure can still expose a password field introduced later. Prefer targeted assertion messages.
11. Use POJOs in negative and contract testing
Typed models excel at valid and boundary-valid input. They can obstruct cases that violate JSON syntax or types. Choose the payload representation based on the defect class.
| Test objective | Recommended body | Reason |
|---|---|---|
| Valid create request | Typed request record | Compile-time structure |
| Required field absent | Type with nullable field or JSON tree | Express omission deliberately |
| Explicit JSON null | JSON tree or three-state model | Distinguish null from absence |
| Unknown enum string | Map or JSON tree | Bypass Java enum restriction |
| Number sent as object | Raw JSON or tree | Deliberate type violation |
| Malformed JSON | Raw string | Mapper would always create valid syntax |
| Duplicate JSON keys | Raw string | Most tree models collapse duplicates |
String malformed = "{\"name\":\"Maya\",\"email\": }";
given()
.baseUri(baseUrl)
.contentType("application/json")
.body(malformed)
.when()
.post("/api/users")
.then()
.statusCode(400)
.body("code", equalTo("MALFORMED_JSON"));
Do not add invalid states to a production-like request type merely to satisfy tests. A test fixture representation may be intentionally looser. Name it accordingly and keep the valid domain model strong.
Schema validation complements serialization tests by checking response shape. Business assertions still matter because a schema cannot know whether the returned owner or total is correct. See OpenAPI schema testing for contract-level techniques.
12. REST Assured POJO serialization best practices
Keep wire models small, immutable, and named for the operation. A create request, update request, list item, and detail response can be separate types even when they share fields. This duplication can clarify authority and optionality better than one universal model with many nulls.
Centralize mapper construction and freeze it before parallel execution. Pin compatible mapper modules. Add focused tests for naming, inclusion, enums, dates, decimals, and unknown fields. Review mapper changes like API changes because they can alter every request.
Validate the response before mapping, and keep direct HTTP assertions for status, media type, headers, and critical business values. Deserialization is a convenience, not proof of correctness.
Prefer semantic comparisons. The server may normalize email case, trim input, calculate totals, assign defaults, or omit write-only fields. Asserting that the response equals the request object can be wrong even when the service is correct. Verify documented transformations explicitly.
Use the API contract testing with Pact guide when consumer-provider compatibility across deployments is the primary concern. POJO mapping tests protect Java representation, while contract tests protect interactions between independently released systems.
Interview Questions and Answers
Q: How does REST Assured serialize a POJO?
REST Assured sees an object body, uses the request content type to select a representation, and delegates to a supported mapper on the classpath. I set JSON explicitly and pin the mapper to avoid classpath-dependent behavior.
Q: Why separate request and response POJOs?
The client and server own different fields. A response may include ID, status, timestamps, links, and audit data that must not be sent during create. Separate types make allowed input and expected output explicit.
Q: How do you deserialize List<User>?
I validate the response, then call .extract().as(new TypeRef<List<User>>() {}). The TypeRef retains the generic element type that Java erasure would otherwise remove.
Q: How do you represent absent versus null in PATCH?
I first confirm the API semantics. If both states matter, a type with simple nullable fields is insufficient when nulls are omitted. I use a three-state wrapper or a JSON tree so absence and explicit null remain distinguishable.
Q: When should you avoid a POJO body?
I avoid it when the test must send malformed JSON, duplicate keys, an intentionally wrong type, or an unknown enum blocked by the Java model. Raw JSON or a tree expresses those negative cases honestly.
Q: How do you handle dates in serialization tests?
I use the correct java.time type, register the mapper's time module, disable accidental numeric timestamps when the contract requires strings, and use a fixed clock. Assertions compare parsed semantics and promised precision.
Q: Is round-trip serialization enough to test an API model?
No. It proves the same mapper can read what it wrote. It does not prove the provider accepts the format or that another implementation interprets it the same way. I combine mapper tests with HTTP and contract assertions.
Common Mistakes
- Calling
.body(pojo)without an explicit request content type. - Letting whichever mapper appears first on the classpath control JSON.
- Reusing a response type as a create request and sending server-owned fields.
- Globally omitting empty values that negative tests intend to send.
- Treating null and absent as equivalent in partial updates.
- Using
doublefor exact money values. - Depending on the CI machine's default time zone.
- Deserializing an HTML or problem response before validating status and media type.
- Extracting a generic list as
List.classand casting maps later. - Comparing raw JSON text when property order and whitespace are irrelevant.
- Assuming a round-trip mapper test proves provider compatibility.
- Forcing malformed-input scenarios through a type-safe POJO.
Conclusion
REST Assured POJO serialization removes string-building noise and makes stable API payloads readable, but mapper defaults are part of the test contract. Set the content type, pin and configure one mapper line, model client-owned input separately from server-owned output, and decide null, date, enum, and decimal behavior explicitly.
Start with one create request record and one response record. Add a focused mapper test for wire names and omitted fields, then validate the HTTP response before deserializing it. That small discipline prevents classpath changes and convenient abstractions from silently changing what your API tests send.
Interview Questions and Answers
What controls POJO serialization in REST Assured?
The request body type, content type, available mapper libraries, explicit `ObjectMapperType`, and REST Assured object-mapper configuration all matter. I set the media type and mapper explicitly so dependency changes cannot silently switch representation.
How would you design models for create and update operations?
I create operation-specific immutable types containing only client-controlled fields. Update models reflect PATCH null and absence semantics. Response models contain server-owned fields and are not reused as request bodies.
Why validate before deserialization?
A gateway HTML page or problem response should fail on its true status and media type, not as an unrelated mapper exception. Validation localizes the defect and preserves useful HTTP evidence before typed extraction.
How do you prevent decimal assertion errors?
I model exact amounts with `BigDecimal` and assert according to the API's numeric or textual contract. I do not rely on binary floating-point equality or an incidental JSON scale.
How do you test mapper configuration?
I serialize representative types to a JSON tree and assert field names, inclusion, enum values, dates, and absence of server-owned fields. I add deserialization tests for unknown fields and generics, then keep provider HTTP tests separate.
What is the purpose of REST Assured TypeRef?
It preserves generic type information for the object mapper. Without it, extracting a parameterized collection as a raw class loses the element type and typically returns maps instead of domain objects.
How do you test invalid JSON types with a strongly typed model?
I do not weaken the valid model. I use a JSON tree, map, or raw string for the negative request so I can intentionally send the wrong type, while keeping production-like request builders type-safe.
Frequently Asked Questions
How do I serialize a POJO in REST Assured?
Add a supported object mapper, set the request content type to JSON, and pass the object to `.body(pojo)`. REST Assured delegates serialization to the selected mapper before sending the request.
Does REST Assured include Jackson automatically?
Object mapper availability depends on the dependency graph and REST Assured version. Add and manage the mapper line your project intends to use instead of depending on an accidental transitive library.
Can REST Assured serialize Java records?
Yes, current REST Assured with a current Jackson mapper can serialize and deserialize Java records. Use annotations when Java component names differ from JSON property names.
How do I deserialize a REST Assured response into a POJO?
After validating status and media type, use `.extract().as(YourResponse.class)`. Apply the same reviewed mapper configuration used by the suite, especially for dates and naming.
How do I deserialize a generic list in REST Assured?
Use `.as(new TypeRef<List<YourType>>() {})` so the mapper receives the element type despite Java type erasure. Extracting as raw `List.class` commonly produces maps.
Should request and response POJOs be the same class?
Usually not when the server owns IDs, timestamps, status, links, or audit fields. Separate operation-specific types clarify what the client may send and what the provider returns.
When should I use raw JSON instead of a POJO?
Use raw JSON when malformed syntax, duplicate keys, or an intentionally wrong JSON type is the subject. A mapper will normalize or prevent those invalid states.
Related Guides
- REST Assured file upload: A Practical Guide (2026)
- REST Assured given when then: A Practical Guide (2026)
- REST Assured JSON schema validation: A Practical Guide (2026)
- REST Assured logging filters: A Practical Guide (2026)
- REST Assured OAuth2 authentication: A Practical Guide (2026)
- REST Assured request and response spec: A Practical Guide (2026)