` (the cast is left alone) apart from an empty `` (the cast is
+cleared).
+
+## The OpenAPI schema
+
+The schema is served as JSON at `/v3/api-docs` — it is the schema, not part of the API —
+and Swagger UI renders it at `/swagger-ui.html`.
+
+A snapshot of it is committed as [`openapi.json`](openapi.json), so the exact contract
+can be read, diffed and reviewed without starting the application. It is what a client
+has to go on, including the `xml` metadata: which properties are attributes, which
+element names differ from the property names, and that `cast` is a wrapped list.
+
+**`openapi.json` is generated. Never edit it by hand.** Regenerate it whenever the
+endpoints, the DTOs or their annotations change: start the application, then run this
+from the repository root and commit the result along with the change that caused it.
+
+```powershell
+python -c "import json,urllib.request; d=json.load(urllib.request.urlopen('http://localhost:8080/v3/api-docs')); open('openapi.json','w',newline='\n',encoding='utf-8').write(json.dumps(d,indent=2,ensure_ascii=False)+'\n')"
+```
+
+It writes two-space indentation and LF endings, so a regeneration that changed nothing
+produces an empty diff. Piping `curl` into `python` on Windows PowerShell does not work
+here — the pipeline prepends a BOM that `json.load` rejects — which is why the command
+downloads the document itself.
+
+## Creating a movie
+
+```bash
+curl -i -X POST http://localhost:8080/movies \
+ -H 'Content-Type: application/xml' \
+ -d 'The MatrixSCIFI8.7Lana WachowskiKeanu Reeves'
+```
+
+```
+HTTP/1.1 201
+Location: http://localhost:8080/movies/1
+Content-Type: application/xml
+
+...
+```
+
+Errors use a single document shape:
+
+```xml
+
+ movie 999 not found
+
+```
+
+## Rules worth knowing
+
+- No two movies may share a title and a year (title compared ignoring case and
+ surrounding whitespace) — `409`.
+- `billing` values must be unique inside a cast — `400`.
+- A movie rated above 9.0 must list at least one actor — `400`.
+- `nationality`, when given, is exactly two upper case letters — `400`.
+- Statistics skip genres with no movies, and average only the movies that carry a
+ rating.
diff --git a/jdk_21_maven/cs/rest/movies-xml/openapi.json b/jdk_21_maven/cs/rest/movies-xml/openapi.json
new file mode 100644
index 000000000..197a0fb24
--- /dev/null
+++ b/jdk_21_maven/cs/rest/movies-xml/openapi.json
@@ -0,0 +1,776 @@
+{
+ "openapi": "3.0.1",
+ "info": {
+ "title": "movies-xml",
+ "description": "An XML-only movie catalogue. Every request and response body is application/xml.",
+ "version": "1.0.0"
+ },
+ "servers": [
+ {
+ "url": "http://localhost:8080",
+ "description": "Generated server url"
+ }
+ ],
+ "paths": {
+ "/movies/{id}": {
+ "get": {
+ "tags": [
+ "movie-controller"
+ ],
+ "summary": "Fetch one movie",
+ "operationId": "byId",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The movie",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/MovieDto"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "No movie with that id",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ },
+ "405": {
+ "description": "Method not supported on this path",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ },
+ "406": {
+ "description": "Client asked for a type other than application/xml",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ }
+ }
+ },
+ "put": {
+ "tags": [
+ "movie-controller"
+ ],
+ "summary": "Replace a movie in full",
+ "operationId": "replace",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/MovieDto"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "The stored movie",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/MovieDto"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Malformed or invalid document",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "No movie with that id",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Title and year already taken",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ },
+ "415": {
+ "description": "Body is not application/xml",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ },
+ "405": {
+ "description": "Method not supported on this path",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ },
+ "406": {
+ "description": "Client asked for a type other than application/xml",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "movie-controller"
+ ],
+ "summary": "Delete a movie",
+ "operationId": "delete",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "Deleted"
+ },
+ "404": {
+ "description": "No movie with that id",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ },
+ "405": {
+ "description": "Method not supported on this path",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ },
+ "406": {
+ "description": "Client asked for a type other than application/xml",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ }
+ }
+ },
+ "patch": {
+ "tags": [
+ "movie-controller"
+ ],
+ "summary": "Update only the fields present in the document",
+ "operationId": "patch",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/MovieDto"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "The stored movie",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/MovieDto"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Malformed or invalid document",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "No movie with that id",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Title and year already taken",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ },
+ "415": {
+ "description": "Body is not application/xml",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ },
+ "405": {
+ "description": "Method not supported on this path",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ },
+ "406": {
+ "description": "Client asked for a type other than application/xml",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/movies": {
+ "get": {
+ "tags": [
+ "movie-controller"
+ ],
+ "summary": "List movies, optionally filtered and sorted",
+ "operationId": "search",
+ "parameters": [
+ {
+ "name": "genre",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "ACTION",
+ "COMEDY",
+ "DRAMA",
+ "HORROR",
+ "SCIFI"
+ ]
+ }
+ },
+ {
+ "name": "titleContains",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "minYear",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "int32"
+ }
+ },
+ {
+ "name": "maxYear",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "int32"
+ }
+ },
+ {
+ "name": "minRating",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "number",
+ "format": "double"
+ }
+ },
+ {
+ "name": "sort",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Matching movies, possibly none",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/MoviesDto"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid query parameters",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ },
+ "405": {
+ "description": "Method not supported on this path",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ },
+ "406": {
+ "description": "Client asked for a type other than application/xml",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "movie-controller"
+ ],
+ "summary": "Create a movie",
+ "operationId": "create",
+ "requestBody": {
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/MovieDto"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "description": "Created",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/MovieDto"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Malformed or invalid document",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Title and year already taken",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ },
+ "415": {
+ "description": "Body is not application/xml",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ },
+ "405": {
+ "description": "Method not supported on this path",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ },
+ "406": {
+ "description": "Client asked for a type other than application/xml",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/movies/stats": {
+ "get": {
+ "tags": [
+ "movie-controller"
+ ],
+ "summary": "Per genre counts and average ratings",
+ "operationId": "stats",
+ "responses": {
+ "200": {
+ "description": "Catalogue statistics",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/StatsDto"
+ }
+ }
+ }
+ },
+ "405": {
+ "description": "Method not supported on this path",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ },
+ "406": {
+ "description": "Client asked for a type other than application/xml",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorDto"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "schemas": {
+ "ActorDto": {
+ "required": [
+ "name"
+ ],
+ "type": "object",
+ "properties": {
+ "billing": {
+ "maximum": 99,
+ "minimum": 1,
+ "type": "integer",
+ "format": "int32",
+ "xml": {
+ "name": "billing",
+ "attribute": true
+ }
+ },
+ "name": {
+ "maxLength": 120,
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "xml": {
+ "name": "actor"
+ }
+ },
+ "DirectorDto": {
+ "required": [
+ "name"
+ ],
+ "type": "object",
+ "properties": {
+ "nationality": {
+ "pattern": "^[A-Z]{2}$",
+ "type": "string",
+ "xml": {
+ "name": "nationality",
+ "attribute": true
+ }
+ },
+ "name": {
+ "maxLength": 120,
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "xml": {
+ "name": "director"
+ }
+ },
+ "MovieDto": {
+ "required": [
+ "director",
+ "genre",
+ "title",
+ "year"
+ ],
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "format": "int64",
+ "xml": {
+ "name": "id",
+ "attribute": true
+ }
+ },
+ "year": {
+ "maximum": 2100,
+ "minimum": 1888,
+ "type": "integer",
+ "format": "int32",
+ "xml": {
+ "name": "year",
+ "attribute": true
+ }
+ },
+ "title": {
+ "maxLength": 200,
+ "minLength": 1,
+ "type": "string"
+ },
+ "genre": {
+ "type": "string",
+ "enum": [
+ "ACTION",
+ "COMEDY",
+ "DRAMA",
+ "HORROR",
+ "SCIFI"
+ ]
+ },
+ "rating": {
+ "maximum": 10.0,
+ "exclusiveMaximum": false,
+ "minimum": 0.0,
+ "exclusiveMinimum": false,
+ "type": "number",
+ "format": "double"
+ },
+ "director": {
+ "$ref": "#/components/schemas/DirectorDto"
+ },
+ "cast": {
+ "maxItems": 10,
+ "minItems": 0,
+ "type": "array",
+ "xml": {
+ "name": "cast",
+ "wrapped": true
+ },
+ "items": {
+ "$ref": "#/components/schemas/ActorDto"
+ }
+ }
+ },
+ "xml": {
+ "name": "movie"
+ }
+ },
+ "ErrorDto": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "integer",
+ "format": "int32",
+ "xml": {
+ "name": "status",
+ "attribute": true
+ }
+ },
+ "message": {
+ "type": "string"
+ }
+ },
+ "xml": {
+ "name": "error"
+ }
+ },
+ "MoviesDto": {
+ "type": "object",
+ "properties": {
+ "movies": {
+ "type": "array",
+ "xml": {
+ "name": "movie",
+ "wrapped": false
+ },
+ "items": {
+ "$ref": "#/components/schemas/MovieDto"
+ }
+ }
+ },
+ "xml": {
+ "name": "movies"
+ }
+ },
+ "GenreStatsDto": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "xml": {
+ "name": "name",
+ "attribute": true
+ }
+ },
+ "count": {
+ "type": "integer",
+ "format": "int64",
+ "xml": {
+ "name": "count",
+ "attribute": true
+ }
+ },
+ "averageRating": {
+ "type": "number",
+ "format": "double"
+ }
+ },
+ "xml": {
+ "name": "genre"
+ }
+ },
+ "StatsDto": {
+ "type": "object",
+ "properties": {
+ "total": {
+ "type": "integer",
+ "format": "int64",
+ "xml": {
+ "name": "total",
+ "attribute": true
+ }
+ },
+ "genres": {
+ "type": "array",
+ "xml": {
+ "name": "genre",
+ "wrapped": false
+ },
+ "items": {
+ "$ref": "#/components/schemas/GenreStatsDto"
+ }
+ }
+ },
+ "xml": {
+ "name": "stats"
+ }
+ }
+ }
+ }
+}
diff --git a/jdk_21_maven/cs/rest/movies-xml/pom.xml b/jdk_21_maven/cs/rest/movies-xml/pom.xml
new file mode 100644
index 000000000..9808b7d87
--- /dev/null
+++ b/jdk_21_maven/cs/rest/movies-xml/pom.xml
@@ -0,0 +1,81 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.5.16
+
+
+
+ org.movies
+ movies-xml
+ 1.0.0
+ movies-xml
+ XML-only REST API used as a case study for REST API fuzzing research
+
+
+ 21
+ UTF-8
+ 2.8.17
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ com.fasterxml.jackson.dataformat
+ jackson-dataformat-xml
+
+
+ org.springdoc
+ springdoc-openapi-starter-webmvc-ui
+ ${springdoc.version}
+
+
+ com.h2database
+ h2
+ runtime
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ repackage
+ repackage
+
+ movies-xml
+ sut
+
+
+
+
+
+
+
diff --git a/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/MoviesXmlApplication.java b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/MoviesXmlApplication.java
new file mode 100644
index 000000000..c5b249adb
--- /dev/null
+++ b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/MoviesXmlApplication.java
@@ -0,0 +1,12 @@
+package org.movies.xml;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class MoviesXmlApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(MoviesXmlApplication.class, args);
+ }
+}
diff --git a/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/config/OpenApiConfig.java b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/config/OpenApiConfig.java
new file mode 100644
index 000000000..7871780b4
--- /dev/null
+++ b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/config/OpenApiConfig.java
@@ -0,0 +1,152 @@
+package org.movies.xml.config;
+
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.Operation;
+import io.swagger.v3.oas.models.PathItem;
+import io.swagger.v3.oas.models.info.Info;
+import io.swagger.v3.oas.models.media.Content;
+import io.swagger.v3.oas.models.media.Schema;
+import io.swagger.v3.oas.models.media.XML;
+import io.swagger.v3.oas.models.responses.ApiResponse;
+import io.swagger.v3.oas.models.responses.ApiResponses;
+import org.springdoc.core.customizers.OpenApiCustomizer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.Map;
+
+/**
+ * The published schema has to describe the XML shape exactly, because that schema is
+ * the only thing a client has to go on. Jackson's XML annotations do not surface in
+ * the generated document by themselves, so the xml metadata is attached here.
+ */
+@Configuration
+public class OpenApiConfig {
+
+ @Bean
+ public OpenAPI moviesXmlOpenApi() {
+ return new OpenAPI().info(new Info()
+ .title("movies-xml")
+ .version("1.0.0")
+ .description("An XML-only movie catalogue. Every request and response body is application/xml."));
+ }
+
+ /**
+ * Two failures are produced by the exception handler rather than by any endpoint, so no
+ * {@code @ApiResponse} on the controller would ever mention them, and every operation
+ * can hit both. Declaring them here keeps the published schema honest without repeating
+ * the same annotation on all seven endpoints, and keeps it that way when an endpoint
+ * is added.
+ *
+ * The handler also turns anything unexpected into a 500, and that one is deliberately
+ * left undeclared: this is a fuzzing target, and a 500 is a fault to be reported, not a
+ * documented answer a tool should accept as expected behaviour.
+ */
+ @Bean
+ public OpenApiCustomizer handlerWideResponsesCustomizer() {
+ return openApi -> {
+ if (openApi.getPaths() == null) {
+ return;
+ }
+ for (PathItem path : openApi.getPaths().values()) {
+ for (Operation operation : path.readOperations()) {
+ ApiResponses responses = operation.getResponses();
+ if (responses == null) {
+ continue;
+ }
+ errorResponse(responses, "405", "Method not supported on this path");
+ errorResponse(responses, "406", "Client asked for a type other than application/xml");
+ }
+ }
+ };
+ }
+
+ /** Adds one error document response, leaving an explicitly annotated one alone. */
+ private static void errorResponse(ApiResponses responses, String code, String description) {
+ if (responses.containsKey(code)) {
+ return;
+ }
+ responses.addApiResponse(code, new ApiResponse()
+ .description(description)
+ .content(new Content().addMediaType("application/xml",
+ new io.swagger.v3.oas.models.media.MediaType()
+ .schema(new Schema<>().$ref("#/components/schemas/ErrorDto")))));
+ }
+
+ @Bean
+ public OpenApiCustomizer xmlMetadataCustomizer() {
+ return openApi -> {
+ if (openApi.getComponents() == null || openApi.getComponents().getSchemas() == null) {
+ return;
+ }
+ Map schemas = openApi.getComponents().getSchemas();
+
+ named(schemas, "MovieDto", "movie", schema -> {
+ attribute(schema, "id");
+ attribute(schema, "year");
+ wrappedArray(schema, "cast", "cast", "actor");
+ });
+ named(schemas, "DirectorDto", "director", schema -> attribute(schema, "nationality"));
+ named(schemas, "ActorDto", "actor", schema -> attribute(schema, "billing"));
+ named(schemas, "MoviesDto", "movies", schema -> unwrappedArray(schema, "movies", "movie"));
+ named(schemas, "StatsDto", "stats", schema -> {
+ attribute(schema, "total");
+ unwrappedArray(schema, "genres", "genre");
+ });
+ named(schemas, "GenreStatsDto", "genre", schema -> {
+ attribute(schema, "name");
+ attribute(schema, "count");
+ });
+ named(schemas, "ErrorDto", "error", schema -> attribute(schema, "status"));
+ };
+ }
+
+ /** Names the root element of a schema and then lets the caller decorate its properties. */
+ private static void named(Map schemas,
+ String schemaName,
+ String elementName,
+ java.util.function.Consumer> decorate) {
+ Schema> schema = schemas.get(schemaName);
+ if (schema == null) {
+ return;
+ }
+ schema.setXml(new XML().name(elementName));
+ decorate.accept(schema);
+ }
+
+ private static void attribute(Schema> parent, String property) {
+ Schema> target = property(parent, property);
+ if (target != null) {
+ target.setXml(new XML().name(property).attribute(true));
+ }
+ }
+
+ /** An array rendered as {@code }. */
+ private static void wrappedArray(Schema> parent, String property, String wrapperName, String itemName) {
+ Schema> array = property(parent, property);
+ if (array == null) {
+ return;
+ }
+ array.setXml(new XML().name(wrapperName).wrapped(true));
+ if (array.getItems() != null) {
+ array.getItems().setXml(new XML().name(itemName));
+ }
+ }
+
+ /** An array whose items are direct children of the enclosing element. */
+ private static void unwrappedArray(Schema> parent, String property, String itemName) {
+ Schema> array = property(parent, property);
+ if (array == null) {
+ return;
+ }
+ array.setXml(new XML().name(itemName).wrapped(false));
+ if (array.getItems() != null) {
+ array.getItems().setXml(new XML().name(itemName));
+ }
+ }
+
+ private static Schema> property(Schema> parent, String name) {
+ Map properties = parent.getProperties();
+ return (properties == null) ? null : properties.get(name);
+ }
+}
diff --git a/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/config/WebConfig.java b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/config/WebConfig.java
new file mode 100644
index 000000000..a374e5035
--- /dev/null
+++ b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/config/WebConfig.java
@@ -0,0 +1,30 @@
+package org.movies.xml.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.MediaType;
+import org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * Content negotiation hardening: XML is the default representation and a query
+ * parameter can never ask for anything else.
+ *
+ * The message converters are left alone on purpose. Removing the JSON converter
+ * would also break the OpenAPI schema that springdoc serves at /v3/api-docs, which
+ * has to stay JSON; the explicit consumes/produces on every endpoint already keep
+ * the API itself XML-only.
+ *
+ *
For the same reason JSON trails XML in the default content types: a client that
+ * states no preference gets XML from every API endpoint, while /v3/api-docs, whose
+ * only producible type is JSON, still answers instead of failing with 406.
+ */
+@Configuration
+public class WebConfig implements WebMvcConfigurer {
+
+ @Override
+ public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
+ configurer.favorParameter(false)
+ .ignoreAcceptHeader(false)
+ .defaultContentType(MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON);
+ }
+}
diff --git a/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/controller/ApiExceptionHandler.java b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/controller/ApiExceptionHandler.java
new file mode 100644
index 000000000..fe5b9ea2b
--- /dev/null
+++ b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/controller/ApiExceptionHandler.java
@@ -0,0 +1,159 @@
+package org.movies.xml.controller;
+
+import com.fasterxml.jackson.core.JacksonException;
+import com.fasterxml.jackson.databind.JsonMappingException;
+import org.movies.xml.dto.ErrorDto;
+import org.movies.xml.service.DuplicateMovieException;
+import org.movies.xml.service.InvalidMovieException;
+import org.movies.xml.service.MovieNotFoundException;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.validation.FieldError;
+import org.springframework.validation.ObjectError;
+import org.springframework.web.HttpMediaTypeNotAcceptableException;
+import org.springframework.web.HttpMediaTypeNotSupportedException;
+import org.springframework.web.HttpRequestMethodNotSupportedException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.MissingPathVariableException;
+import org.springframework.web.bind.MissingServletRequestParameterException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
+import org.springframework.web.servlet.resource.NoResourceFoundException;
+
+/**
+ * Turns every failure into the one error document this API knows how to speak.
+ * There is deliberately no JSON fallback here either.
+ */
+@RestControllerAdvice
+public class ApiExceptionHandler {
+
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public ResponseEntity onBeanValidation(MethodArgumentNotValidException e) {
+ return error(HttpStatus.BAD_REQUEST, firstViolation(e));
+ }
+
+ @ExceptionHandler(HttpMessageNotReadableException.class)
+ public ResponseEntity onUnreadableBody(HttpMessageNotReadableException e) {
+ return error(HttpStatus.BAD_REQUEST, unreadableBodyMessage(e));
+ }
+
+ @ExceptionHandler(InvalidMovieException.class)
+ public ResponseEntity onInvalidMovie(InvalidMovieException e) {
+ return error(HttpStatus.BAD_REQUEST, e.getMessage());
+ }
+
+ @ExceptionHandler({MethodArgumentTypeMismatchException.class, MissingServletRequestParameterException.class})
+ public ResponseEntity onBadParameter(Exception e) {
+ if (e instanceof MethodArgumentTypeMismatchException mismatch) {
+ // An id that is not even a number names no stored movie, so it gets the same
+ // 404 as a well formed id that is absent from the database. Only query
+ // parameters, which are part of the request rather than of the path, are a 400.
+ if (mismatch.getParameter().hasParameterAnnotation(PathVariable.class)) {
+ return error(HttpStatus.NOT_FOUND, MovieNotFoundException.message(mismatch.getValue()));
+ }
+ return error(HttpStatus.BAD_REQUEST,
+ mismatch.getName() + " has an unacceptable value: " + mismatch.getValue());
+ }
+ return error(HttpStatus.BAD_REQUEST, e.getMessage());
+ }
+
+ @ExceptionHandler(MovieNotFoundException.class)
+ public ResponseEntity onNotFound(MovieNotFoundException e) {
+ return error(HttpStatus.NOT_FOUND, e.getMessage());
+ }
+
+ /**
+ * A blank path segment, as in /movies/%20. Spring calls this a server error because it
+ * normally means the mapping and the method disagree, but here it is the client naming
+ * a movie that cannot exist, which is the same 404 as any other unknown id.
+ */
+ @ExceptionHandler(MissingPathVariableException.class)
+ public ResponseEntity onBlankPathVariable() {
+ return error(HttpStatus.NOT_FOUND, "a blank id names no movie");
+ }
+
+ @ExceptionHandler(NoResourceFoundException.class)
+ public ResponseEntity onUnknownResource(NoResourceFoundException e) {
+ return error(HttpStatus.NOT_FOUND, "no resource at " + e.getResourcePath());
+ }
+
+ @ExceptionHandler(DuplicateMovieException.class)
+ public ResponseEntity onDuplicate(DuplicateMovieException e) {
+ return error(HttpStatus.CONFLICT, e.getMessage());
+ }
+
+ @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
+ public ResponseEntity onMethodNotAllowed(HttpRequestMethodNotSupportedException e) {
+ return error(HttpStatus.METHOD_NOT_ALLOWED, e.getMessage());
+ }
+
+ @ExceptionHandler(HttpMediaTypeNotSupportedException.class)
+ public ResponseEntity onUnsupportedMediaType(HttpMediaTypeNotSupportedException e) {
+ return error(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "this API only accepts application/xml");
+ }
+
+ @ExceptionHandler(HttpMediaTypeNotAcceptableException.class)
+ public ResponseEntity onNotAcceptable(HttpMediaTypeNotAcceptableException e) {
+ return error(HttpStatus.NOT_ACCEPTABLE, "this API only produces application/xml");
+ }
+
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity onUnexpected(Exception e) {
+ return error(HttpStatus.INTERNAL_SERVER_ERROR, "unexpected failure: " + e.getClass().getSimpleName());
+ }
+
+ /**
+ * Messages from StrictXmlRequestBodyAdvice pass through. Jackson's are replaced: they name
+ * internal classes and change between versions, which makes tests asserting on them brittle.
+ */
+ private static String unreadableBodyMessage(HttpMessageNotReadableException e) {
+ if (!(e.getCause() instanceof JacksonException)) {
+ return e.getMessage();
+ }
+ String field = mappedField(e.getCause());
+ if (field == null) {
+ return "the document could not be read as a movie";
+ }
+ return field + " does not carry a value of the expected type";
+ }
+
+ /** Dotted path of the offending field, or null when Jackson did not report one. */
+ private static String mappedField(Throwable cause) {
+ if (!(cause instanceof JsonMappingException mapping)) {
+ return null;
+ }
+ StringBuilder path = new StringBuilder();
+ for (JsonMappingException.Reference reference : mapping.getPath()) {
+ String name = reference.getFieldName();
+ if (name == null) {
+ continue;
+ }
+ if (path.length() > 0) {
+ path.append('.');
+ }
+ path.append(name);
+ }
+ return path.length() == 0 ? null : path.toString();
+ }
+
+ private static String firstViolation(MethodArgumentNotValidException e) {
+ ObjectError first = e.getBindingResult().getAllErrors().stream().findFirst().orElse(null);
+ if (first == null) {
+ return "the document is not valid";
+ }
+ if (first instanceof FieldError fieldError) {
+ return fieldError.getField() + " " + fieldError.getDefaultMessage();
+ }
+ return first.getDefaultMessage();
+ }
+
+ private static ResponseEntity error(HttpStatus status, String message) {
+ return ResponseEntity.status(status)
+ .contentType(MediaType.APPLICATION_XML)
+ .body(new ErrorDto(status.value(), message));
+ }
+}
diff --git a/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/controller/MovieController.java b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/controller/MovieController.java
new file mode 100644
index 000000000..aca66bf53
--- /dev/null
+++ b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/controller/MovieController.java
@@ -0,0 +1,153 @@
+package org.movies.xml.controller;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import jakarta.validation.Valid;
+import org.movies.xml.domain.Genre;
+import org.movies.xml.dto.ErrorDto;
+import org.movies.xml.dto.MovieDto;
+import org.movies.xml.dto.MoviesDto;
+import org.movies.xml.dto.StatsDto;
+import org.movies.xml.service.MovieService;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
+
+import java.net.URI;
+import java.util.List;
+
+/**
+ * The whole API. Every endpoint speaks XML and nothing else.
+ */
+@RestController
+@RequestMapping(path = "/movies", produces = MediaType.APPLICATION_XML_VALUE)
+public class MovieController {
+
+ private static final String XML = MediaType.APPLICATION_XML_VALUE;
+
+ private final MovieService service;
+
+ public MovieController(MovieService service) {
+ this.service = service;
+ }
+
+ @Operation(summary = "Create a movie")
+ @ApiResponses({
+ @ApiResponse(responseCode = "201", description = "Created",
+ content = @Content(mediaType = XML, schema = @Schema(implementation = MovieDto.class))),
+ @ApiResponse(responseCode = "400", description = "Malformed or invalid document",
+ content = @Content(mediaType = XML, schema = @Schema(implementation = ErrorDto.class))),
+ @ApiResponse(responseCode = "409", description = "Title and year already taken",
+ content = @Content(mediaType = XML, schema = @Schema(implementation = ErrorDto.class))),
+ @ApiResponse(responseCode = "415", description = "Body is not application/xml",
+ content = @Content(mediaType = XML, schema = @Schema(implementation = ErrorDto.class)))
+ })
+ @PostMapping(consumes = XML, produces = XML)
+ public ResponseEntity create(@Valid @RequestBody MovieDto request) {
+ MovieDto created = service.create(request);
+ URI location = ServletUriComponentsBuilder.fromCurrentRequest()
+ .path("/{id}")
+ .buildAndExpand(created.getId())
+ .toUri();
+ return ResponseEntity.created(location).contentType(MediaType.APPLICATION_XML).body(created);
+ }
+
+ @Operation(summary = "List movies, optionally filtered and sorted")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "Matching movies, possibly none",
+ content = @Content(mediaType = XML, schema = @Schema(implementation = MoviesDto.class))),
+ @ApiResponse(responseCode = "400", description = "Invalid query parameters",
+ content = @Content(mediaType = XML, schema = @Schema(implementation = ErrorDto.class)))
+ })
+ @GetMapping(produces = XML)
+ public MoviesDto search(@RequestParam(required = false) Genre genre,
+ @RequestParam(required = false) String titleContains,
+ @RequestParam(required = false) Integer minYear,
+ @RequestParam(required = false) Integer maxYear,
+ @RequestParam(required = false) Double minRating,
+ @RequestParam(required = false) String sort) {
+ List movies = service.search(genre, titleContains, minYear, maxYear, minRating, sort);
+ return new MoviesDto(movies);
+ }
+
+ @Operation(summary = "Per genre counts and average ratings")
+ @ApiResponse(responseCode = "200", description = "Catalogue statistics",
+ content = @Content(mediaType = XML, schema = @Schema(implementation = StatsDto.class)))
+ @GetMapping(path = "/stats", produces = XML)
+ public StatsDto stats() {
+ return service.stats();
+ }
+
+ @Operation(summary = "Fetch one movie")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "The movie",
+ content = @Content(mediaType = XML, schema = @Schema(implementation = MovieDto.class))),
+ @ApiResponse(responseCode = "404", description = "No movie with that id",
+ content = @Content(mediaType = XML, schema = @Schema(implementation = ErrorDto.class)))
+ })
+ @GetMapping(path = "/{id}", produces = XML)
+ public MovieDto byId(@PathVariable Long id) {
+ return service.findById(id);
+ }
+
+ @Operation(summary = "Replace a movie in full")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "The stored movie",
+ content = @Content(mediaType = XML, schema = @Schema(implementation = MovieDto.class))),
+ @ApiResponse(responseCode = "400", description = "Malformed or invalid document",
+ content = @Content(mediaType = XML, schema = @Schema(implementation = ErrorDto.class))),
+ @ApiResponse(responseCode = "404", description = "No movie with that id",
+ content = @Content(mediaType = XML, schema = @Schema(implementation = ErrorDto.class))),
+ @ApiResponse(responseCode = "409", description = "Title and year already taken",
+ content = @Content(mediaType = XML, schema = @Schema(implementation = ErrorDto.class))),
+ @ApiResponse(responseCode = "415", description = "Body is not application/xml",
+ content = @Content(mediaType = XML, schema = @Schema(implementation = ErrorDto.class)))
+ })
+ @PutMapping(path = "/{id}", consumes = XML, produces = XML)
+ public MovieDto replace(@PathVariable Long id, @Valid @RequestBody MovieDto request) {
+ return service.replace(id, request);
+ }
+
+ @Operation(summary = "Update only the fields present in the document")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "The stored movie",
+ content = @Content(mediaType = XML, schema = @Schema(implementation = MovieDto.class))),
+ @ApiResponse(responseCode = "400", description = "Malformed or invalid document",
+ content = @Content(mediaType = XML, schema = @Schema(implementation = ErrorDto.class))),
+ @ApiResponse(responseCode = "404", description = "No movie with that id",
+ content = @Content(mediaType = XML, schema = @Schema(implementation = ErrorDto.class))),
+ @ApiResponse(responseCode = "409", description = "Title and year already taken",
+ content = @Content(mediaType = XML, schema = @Schema(implementation = ErrorDto.class))),
+ @ApiResponse(responseCode = "415", description = "Body is not application/xml",
+ content = @Content(mediaType = XML, schema = @Schema(implementation = ErrorDto.class)))
+ })
+ @PatchMapping(path = "/{id}", consumes = XML, produces = XML)
+ public MovieDto patch(@PathVariable Long id, @RequestBody MovieDto request) {
+ return service.patch(id, request);
+ }
+
+ @Operation(summary = "Delete a movie")
+ @ApiResponses({
+ @ApiResponse(responseCode = "204", description = "Deleted", content = @Content),
+ @ApiResponse(responseCode = "404", description = "No movie with that id",
+ content = @Content(mediaType = XML, schema = @Schema(implementation = ErrorDto.class)))
+ })
+ @DeleteMapping(path = "/{id}", produces = XML)
+ public ResponseEntity delete(@PathVariable Long id) {
+ service.delete(id);
+ return ResponseEntity.noContent().build();
+ }
+}
diff --git a/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/controller/StrictXmlRequestBodyAdvice.java b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/controller/StrictXmlRequestBodyAdvice.java
new file mode 100644
index 000000000..14f5d5318
--- /dev/null
+++ b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/controller/StrictXmlRequestBodyAdvice.java
@@ -0,0 +1,292 @@
+package org.movies.xml.controller;
+
+import org.movies.xml.dto.MovieDto;
+import org.springframework.core.MethodParameter;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpInputMessage;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdvice;
+
+import javax.xml.stream.XMLInputFactory;
+import javax.xml.stream.XMLStreamConstants;
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamReader;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Type;
+import java.util.ArrayDeque;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Enforces the single accepted shape of an incoming movie document before Jackson
+ * gets to bind it.
+ *
+ * Jackson makes no distinction between an attribute and an element on the way in,
+ * so on its own it would happily accept a document that spells id or year as elements,
+ * which is exactly the mistake this case study needs to catch. This advice walks the
+ * raw document first and rejects anything that is not the one documented form.
+ *
+ *
As a side effect it records which names were literally present, which is what
+ * lets PATCH tell an absent cast apart from an empty one.
+ */
+@ControllerAdvice
+public class StrictXmlRequestBodyAdvice implements RequestBodyAdvice {
+
+ /** What every known element is allowed to carry. */
+ private record ElementSpec(Set attributes, Set children, Set repeatableChildren) {
+
+ static ElementSpec leaf() {
+ return new ElementSpec(Set.of(), Set.of(), Set.of());
+ }
+ }
+
+ private static final String ROOT = "movie";
+
+ private static final Map SHAPE = shape();
+
+ private static Map shape() {
+ Map shape = new HashMap<>();
+ shape.put("movie", new ElementSpec(
+ Set.of("id", "year"),
+ Set.of("title", "genre", "rating", "director", "cast"),
+ Set.of()));
+ shape.put("director", new ElementSpec(
+ Set.of("nationality"),
+ Set.of("name"),
+ Set.of()));
+ shape.put("cast", new ElementSpec(
+ Set.of(),
+ Set.of("actor"),
+ Set.of("actor")));
+ shape.put("actor", new ElementSpec(
+ Set.of("billing"),
+ Set.of("name"),
+ Set.of()));
+ for (String leaf : Arrays.asList("title", "genre", "rating", "name")) {
+ shape.put(leaf, ElementSpec.leaf());
+ }
+ return Collections.unmodifiableMap(shape);
+ }
+
+ private static XMLInputFactory newFactory() {
+ XMLInputFactory factory = XMLInputFactory.newInstance();
+ factory.setProperty(XMLInputFactory.SUPPORT_DTD, Boolean.FALSE);
+ factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, Boolean.FALSE);
+ factory.setProperty(XMLInputFactory.IS_COALESCING, Boolean.TRUE);
+ return factory;
+ }
+
+ @Override
+ public boolean supports(MethodParameter methodParameter,
+ Type targetType,
+ Class extends HttpMessageConverter>> converterType) {
+ return MovieDto.class.equals(targetType);
+ }
+
+ @Override
+ public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage,
+ MethodParameter parameter,
+ Type targetType,
+ Class extends HttpMessageConverter>> converterType) throws IOException {
+ byte[] body = inputMessage.getBody().readAllBytes();
+ BufferedInputMessage buffered = new BufferedInputMessage(inputMessage.getHeaders(), body);
+ buffered.setPresentNames(check(buffered));
+ return buffered;
+ }
+
+ @Override
+ public Object afterBodyRead(Object body,
+ HttpInputMessage inputMessage,
+ MethodParameter parameter,
+ Type targetType,
+ Class extends HttpMessageConverter>> converterType) {
+ if (body instanceof MovieDto dto && inputMessage instanceof BufferedInputMessage buffered) {
+ dto.setPresentNames(buffered.getPresentNames());
+ }
+ return body;
+ }
+
+ @Override
+ public Object handleEmptyBody(Object body,
+ HttpInputMessage inputMessage,
+ MethodParameter parameter,
+ Type targetType,
+ Class extends HttpMessageConverter>> converterType) {
+ return body;
+ }
+
+ /**
+ * Walks the document and returns the names present at movie level, both attributes
+ * and child elements.
+ *
+ * @throws HttpMessageNotReadableException on malformed XML or on any deviation from
+ * the accepted shape
+ */
+ private Set check(BufferedInputMessage message) {
+ Set present = new LinkedHashSet<>();
+ Deque path = new ArrayDeque<>();
+ Map> seenChildren = new HashMap<>();
+ boolean rootSeen = false;
+
+ XMLStreamReader reader = null;
+ try (InputStream in = message.getBody()) {
+ reader = newFactory().createXMLStreamReader(in);
+ while (reader.hasNext()) {
+ int event = reader.next();
+ if (event == XMLStreamConstants.START_ELEMENT) {
+ String name = localName(message, reader);
+ if (path.isEmpty()) {
+ if (rootSeen || !ROOT.equals(name)) {
+ throw shapeError(message,
+ "the root element must be <" + ROOT + ">, found <" + name + ">");
+ }
+ rootSeen = true;
+ } else {
+ String parent = path.peek();
+ ElementSpec parentSpec = SHAPE.get(parent);
+ if (parentSpec.children().isEmpty()) {
+ throw shapeError(message,
+ "<" + parent + "> must not contain child elements, found <" + name + ">");
+ }
+ if (!parentSpec.children().contains(name)) {
+ throw shapeError(message, "<" + name + "> is not a valid child of <" + parent + ">");
+ }
+ Set seen = seenChildren.computeIfAbsent(key(path), k -> new HashSet<>());
+ if (!seen.add(name) && !parentSpec.repeatableChildren().contains(name)) {
+ throw shapeError(message,
+ "<" + name + "> must appear at most once inside <" + parent + ">");
+ }
+ if (path.size() == 1) {
+ present.add(name);
+ }
+ }
+ checkAttributes(message, reader, name, present, path.isEmpty());
+ path.push(name);
+ } else if (event == XMLStreamConstants.END_ELEMENT) {
+ seenChildren.remove(key(path));
+ path.pop();
+ } else if (event == XMLStreamConstants.CHARACTERS || event == XMLStreamConstants.CDATA) {
+ String parent = path.peek();
+ if (parent != null
+ && !SHAPE.get(parent).children().isEmpty()
+ && !reader.getText().isBlank()) {
+ throw shapeError(message, "<" + parent + "> must not carry text");
+ }
+ }
+ }
+ if (!rootSeen) {
+ throw shapeError(message, "the document is empty");
+ }
+ } catch (XMLStreamException e) {
+ throw new HttpMessageNotReadableException("malformed XML: " + rootCause(e), e, message);
+ } catch (IOException e) {
+ throw new HttpMessageNotReadableException("could not read the request body", e, message);
+ } finally {
+ closeQuietly(reader);
+ }
+ return present;
+ }
+
+ private void checkAttributes(BufferedInputMessage message,
+ XMLStreamReader reader,
+ String element,
+ Set present,
+ boolean isRoot) {
+ ElementSpec spec = SHAPE.get(element);
+ for (int i = 0; i < reader.getAttributeCount(); i++) {
+ String namespace = reader.getAttributeNamespace(i);
+ String name = reader.getAttributeLocalName(i);
+ if (namespace != null && !namespace.isEmpty()) {
+ throw shapeError(message,
+ "namespaced attributes are not accepted, found " + namespace + ":" + name);
+ }
+ if (!spec.attributes().contains(name)) {
+ throw shapeError(message, name + " is not a valid attribute of <" + element + ">");
+ }
+ if (isRoot) {
+ present.add(name);
+ }
+ }
+ }
+
+ private static String localName(BufferedInputMessage message, XMLStreamReader reader) {
+ String namespace = reader.getNamespaceURI();
+ if (namespace != null && !namespace.isEmpty()) {
+ throw shapeError(message, "namespaced elements are not accepted, found " + namespace);
+ }
+ return reader.getLocalName();
+ }
+
+ private static String key(Deque path) {
+ return String.join("/", path);
+ }
+
+ private static HttpMessageNotReadableException shapeError(BufferedInputMessage message, String detail) {
+ return new HttpMessageNotReadableException(detail, null, message);
+ }
+
+ private static String rootCause(Throwable e) {
+ Throwable cause = e;
+ while (cause.getCause() != null) {
+ cause = cause.getCause();
+ }
+ String message = cause.getMessage();
+ return (message == null) ? cause.getClass().getSimpleName() : message.replace('\n', ' ').trim();
+ }
+
+ private static void closeQuietly(XMLStreamReader reader) {
+ if (reader == null) {
+ return;
+ }
+ try {
+ reader.close();
+ } catch (XMLStreamException ignored) {
+ // nothing useful to do while unwinding
+ }
+ }
+
+ /**
+ * Lets the buffered bytes be read a second time, and carries the present names across.
+ * Spring hands this same instance to afterBodyRead, so no ThreadLocal is needed: nothing
+ * clears one when the converter throws in between.
+ */
+ private static final class BufferedInputMessage implements HttpInputMessage {
+
+ private final HttpHeaders headers;
+ private final byte[] body;
+ private Set presentNames = Collections.emptySet();
+
+ BufferedInputMessage(HttpHeaders headers, byte[] body) {
+ this.headers = headers;
+ this.body = body;
+ }
+
+ Set getPresentNames() {
+ return presentNames;
+ }
+
+ void setPresentNames(Set presentNames) {
+ this.presentNames = presentNames;
+ }
+
+ @Override
+ public InputStream getBody() {
+ return new ByteArrayInputStream(body);
+ }
+
+ @Override
+ public HttpHeaders getHeaders() {
+ return headers;
+ }
+ }
+}
diff --git a/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/domain/Actor.java b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/domain/Actor.java
new file mode 100644
index 000000000..505b48da5
--- /dev/null
+++ b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/domain/Actor.java
@@ -0,0 +1,38 @@
+package org.movies.xml.domain;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Embeddable;
+
+@Embeddable
+public class Actor {
+
+ @Column(name = "actor_name", nullable = false, length = 120)
+ private String name;
+
+ @Column(name = "billing")
+ private Integer billing;
+
+ protected Actor() {
+ }
+
+ public Actor(String name, Integer billing) {
+ this.name = name;
+ this.billing = billing;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public Integer getBilling() {
+ return billing;
+ }
+
+ public void setBilling(Integer billing) {
+ this.billing = billing;
+ }
+}
diff --git a/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/domain/Genre.java b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/domain/Genre.java
new file mode 100644
index 000000000..e20bf64d0
--- /dev/null
+++ b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/domain/Genre.java
@@ -0,0 +1,9 @@
+package org.movies.xml.domain;
+
+public enum Genre {
+ ACTION,
+ COMEDY,
+ DRAMA,
+ HORROR,
+ SCIFI
+}
diff --git a/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/domain/Movie.java b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/domain/Movie.java
new file mode 100644
index 000000000..c0e45caa5
--- /dev/null
+++ b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/domain/Movie.java
@@ -0,0 +1,115 @@
+package org.movies.xml.domain;
+
+import jakarta.persistence.CollectionTable;
+import jakarta.persistence.Column;
+import jakarta.persistence.ElementCollection;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.OrderBy;
+import jakarta.persistence.Table;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Entity
+@Table(name = "movie")
+public class Movie {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "title", nullable = false, length = 200)
+ private String title;
+
+ @Column(name = "release_year", nullable = false)
+ private Integer year;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "genre", nullable = false, length = 20)
+ private Genre genre;
+
+ @Column(name = "rating")
+ private Double rating;
+
+ @Column(name = "director_name", nullable = false, length = 120)
+ private String directorName;
+
+ @Column(name = "director_nationality", length = 2)
+ private String directorNationality;
+
+ @ElementCollection(fetch = FetchType.EAGER)
+ @CollectionTable(name = "movie_cast", joinColumns = @JoinColumn(name = "movie_id"))
+ @OrderBy("billing")
+ private List cast = new ArrayList<>();
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public void setTitle(String title) {
+ this.title = title;
+ }
+
+ public Integer getYear() {
+ return year;
+ }
+
+ public void setYear(Integer year) {
+ this.year = year;
+ }
+
+ public Genre getGenre() {
+ return genre;
+ }
+
+ public void setGenre(Genre genre) {
+ this.genre = genre;
+ }
+
+ public Double getRating() {
+ return rating;
+ }
+
+ public void setRating(Double rating) {
+ this.rating = rating;
+ }
+
+ public String getDirectorName() {
+ return directorName;
+ }
+
+ public void setDirectorName(String directorName) {
+ this.directorName = directorName;
+ }
+
+ public String getDirectorNationality() {
+ return directorNationality;
+ }
+
+ public void setDirectorNationality(String directorNationality) {
+ this.directorNationality = directorNationality;
+ }
+
+ public List getCast() {
+ return cast;
+ }
+
+ public void setCast(List cast) {
+ this.cast = (cast == null) ? new ArrayList<>() : cast;
+ }
+}
diff --git a/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/ActorDto.java b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/ActorDto.java
new file mode 100644
index 000000000..9e3330704
--- /dev/null
+++ b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/ActorDto.java
@@ -0,0 +1,43 @@
+package org.movies.xml.dto;
+
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+/**
+ * A single entry of a movie cast: an actor element carrying billing as an attribute.
+ */
+@JacksonXmlRootElement(localName = "actor")
+@JsonPropertyOrder({"billing", "name"})
+public class ActorDto {
+
+ @JacksonXmlProperty(isAttribute = true, localName = "billing")
+ @Min(1)
+ @Max(99)
+ private Integer billing;
+
+ @JacksonXmlProperty(localName = "name")
+ @NotBlank
+ @Size(min = 1, max = 120)
+ private String name;
+
+ public Integer getBilling() {
+ return billing;
+ }
+
+ public void setBilling(Integer billing) {
+ this.billing = billing;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+}
diff --git a/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/DirectorDto.java b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/DirectorDto.java
new file mode 100644
index 000000000..a5272c853
--- /dev/null
+++ b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/DirectorDto.java
@@ -0,0 +1,41 @@
+package org.movies.xml.dto;
+
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+
+/**
+ * Director of a movie: a nested element whose nationality travels as an attribute.
+ */
+@JacksonXmlRootElement(localName = "director")
+@JsonPropertyOrder({"nationality", "name"})
+public class DirectorDto {
+
+ @JacksonXmlProperty(isAttribute = true, localName = "nationality")
+ @Pattern(regexp = "^[A-Z]{2}$")
+ private String nationality;
+
+ @JacksonXmlProperty(localName = "name")
+ @NotBlank
+ @Size(min = 1, max = 120)
+ private String name;
+
+ public String getNationality() {
+ return nationality;
+ }
+
+ public void setNationality(String nationality) {
+ this.nationality = nationality;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+}
diff --git a/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/ErrorDto.java b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/ErrorDto.java
new file mode 100644
index 000000000..bfd67209e
--- /dev/null
+++ b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/ErrorDto.java
@@ -0,0 +1,44 @@
+package org.movies.xml.dto;
+
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
+
+/**
+ * Every error this API produces: an error root element with the status as an
+ * attribute and a message element.
+ */
+@JacksonXmlRootElement(localName = "error")
+@JsonPropertyOrder({"status", "message"})
+public class ErrorDto {
+
+ @JacksonXmlProperty(isAttribute = true, localName = "status")
+ private int status;
+
+ @JacksonXmlProperty(localName = "message")
+ private String message;
+
+ public ErrorDto() {
+ }
+
+ public ErrorDto(int status, String message) {
+ this.status = status;
+ this.message = message;
+ }
+
+ public int getStatus() {
+ return status;
+ }
+
+ public void setStatus(int status) {
+ this.status = status;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public void setMessage(String message) {
+ this.message = message;
+ }
+}
diff --git a/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/GenreStatsDto.java b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/GenreStatsDto.java
new file mode 100644
index 000000000..4eead49a4
--- /dev/null
+++ b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/GenreStatsDto.java
@@ -0,0 +1,56 @@
+package org.movies.xml.dto;
+
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
+
+/**
+ * Per-genre figures: name and count as attributes, averageRating as a nested element
+ * that is omitted when no movie of the genre carries a rating.
+ */
+@JacksonXmlRootElement(localName = "genre")
+@JsonPropertyOrder({"name", "count", "averageRating"})
+public class GenreStatsDto {
+
+ @JacksonXmlProperty(isAttribute = true, localName = "name")
+ private String name;
+
+ @JacksonXmlProperty(isAttribute = true, localName = "count")
+ private long count;
+
+ @JacksonXmlProperty(localName = "averageRating")
+ private Double averageRating;
+
+ public GenreStatsDto() {
+ }
+
+ public GenreStatsDto(String name, long count, Double averageRating) {
+ this.name = name;
+ this.count = count;
+ this.averageRating = averageRating;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public long getCount() {
+ return count;
+ }
+
+ public void setCount(long count) {
+ this.count = count;
+ }
+
+ public Double getAverageRating() {
+ return averageRating;
+ }
+
+ public void setAverageRating(Double averageRating) {
+ this.averageRating = averageRating;
+ }
+}
diff --git a/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/MovieDto.java b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/MovieDto.java
new file mode 100644
index 000000000..902372e9d
--- /dev/null
+++ b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/MovieDto.java
@@ -0,0 +1,157 @@
+package org.movies.xml.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.DecimalMax;
+import jakarta.validation.constraints.DecimalMin;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import org.movies.xml.domain.Genre;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * The one and only accepted representation of a movie. Root element is "movie",
+ * id and year are attributes, the director is a nested element with a nationality
+ * attribute, and the cast is a wrapped list of actor elements.
+ */
+@JacksonXmlRootElement(localName = "movie")
+@JsonPropertyOrder({"id", "year", "title", "genre", "rating", "director", "cast"})
+public class MovieDto {
+
+ @JacksonXmlProperty(isAttribute = true, localName = "id")
+ private Long id;
+
+ @JacksonXmlProperty(isAttribute = true, localName = "year")
+ @NotNull
+ @Min(1888)
+ @Max(2100)
+ private Integer year;
+
+ @JacksonXmlProperty(localName = "title")
+ @NotBlank
+ @Size(min = 1, max = 200)
+ private String title;
+
+ @JacksonXmlProperty(localName = "genre")
+ @NotNull
+ private Genre genre;
+
+ @JacksonXmlProperty(localName = "rating")
+ @DecimalMin("0.0")
+ @DecimalMax("10.0")
+ private Double rating;
+
+ @JacksonXmlProperty(localName = "director")
+ @NotNull
+ @Valid
+ private DirectorDto director;
+
+ @JacksonXmlElementWrapper(localName = "cast")
+ @JacksonXmlProperty(localName = "actor")
+ @Size(max = 10)
+ @Valid
+ private List cast;
+
+ /**
+ * Names literally present in the received document, filled in by the strict XML
+ * reader. It is what lets PATCH tell an absent field apart from one sent empty,
+ * and it is never part of the wire format.
+ */
+ @JsonIgnore
+ @Schema(hidden = true)
+ private Set presentNames = Collections.emptySet();
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public Integer getYear() {
+ return year;
+ }
+
+ public void setYear(Integer year) {
+ this.year = year;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public void setTitle(String title) {
+ this.title = title;
+ }
+
+ public Genre getGenre() {
+ return genre;
+ }
+
+ public void setGenre(Genre genre) {
+ this.genre = genre;
+ }
+
+ public Double getRating() {
+ return rating;
+ }
+
+ public void setRating(Double rating) {
+ this.rating = rating;
+ }
+
+ public DirectorDto getDirector() {
+ return director;
+ }
+
+ public void setDirector(DirectorDto director) {
+ this.director = director;
+ }
+
+ public List getCast() {
+ return cast;
+ }
+
+ public void setCast(List cast) {
+ this.cast = cast;
+ }
+
+ @JsonIgnore
+ public Set getPresentNames() {
+ return presentNames;
+ }
+
+ public void setPresentNames(Set presentNames) {
+ this.presentNames = (presentNames == null) ? Collections.emptySet() : presentNames;
+ }
+
+ @JsonIgnore
+ public boolean isPresent(String name) {
+ return presentNames.contains(name);
+ }
+
+ /**
+ * The cast exactly as received: an empty cast element yields an empty list,
+ * an absent one yields null.
+ */
+ @JsonIgnore
+ public List getCastAsReceived() {
+ if (!isPresent("cast")) {
+ return null;
+ }
+ return (cast == null) ? new ArrayList<>() : cast;
+ }
+}
diff --git a/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/MoviesDto.java b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/MoviesDto.java
new file mode 100644
index 000000000..f1ecd811a
--- /dev/null
+++ b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/MoviesDto.java
@@ -0,0 +1,34 @@
+package org.movies.xml.dto;
+
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Collection wrapper: a movies root element holding movie elements.
+ */
+@JacksonXmlRootElement(localName = "movies")
+public class MoviesDto {
+
+ @JacksonXmlElementWrapper(useWrapping = false)
+ @JacksonXmlProperty(localName = "movie")
+ private List movies = new ArrayList<>();
+
+ public MoviesDto() {
+ }
+
+ public MoviesDto(List movies) {
+ setMovies(movies);
+ }
+
+ public List getMovies() {
+ return movies;
+ }
+
+ public void setMovies(List movies) {
+ this.movies = (movies == null) ? new ArrayList<>() : movies;
+ }
+}
diff --git a/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/StatsDto.java b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/StatsDto.java
new file mode 100644
index 000000000..ffa470bc4
--- /dev/null
+++ b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/StatsDto.java
@@ -0,0 +1,49 @@
+package org.movies.xml.dto;
+
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Catalogue statistics: a stats root element with a total attribute and one genre
+ * element per genre that actually has movies.
+ */
+@JacksonXmlRootElement(localName = "stats")
+@JsonPropertyOrder({"total", "genres"})
+public class StatsDto {
+
+ @JacksonXmlProperty(isAttribute = true, localName = "total")
+ private long total;
+
+ @JacksonXmlElementWrapper(useWrapping = false)
+ @JacksonXmlProperty(localName = "genre")
+ private List genres = new ArrayList<>();
+
+ public StatsDto() {
+ }
+
+ public StatsDto(long total, List genres) {
+ this.total = total;
+ setGenres(genres);
+ }
+
+ public long getTotal() {
+ return total;
+ }
+
+ public void setTotal(long total) {
+ this.total = total;
+ }
+
+ public List getGenres() {
+ return genres;
+ }
+
+ public void setGenres(List genres) {
+ this.genres = (genres == null) ? new ArrayList<>() : genres;
+ }
+}
diff --git a/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/repository/MovieRepository.java b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/repository/MovieRepository.java
new file mode 100644
index 000000000..ecb2a21cb
--- /dev/null
+++ b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/repository/MovieRepository.java
@@ -0,0 +1,7 @@
+package org.movies.xml.repository;
+
+import org.movies.xml.domain.Movie;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface MovieRepository extends JpaRepository {
+}
diff --git a/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/service/DuplicateMovieException.java b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/service/DuplicateMovieException.java
new file mode 100644
index 000000000..5c8b13bf9
--- /dev/null
+++ b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/service/DuplicateMovieException.java
@@ -0,0 +1,11 @@
+package org.movies.xml.service;
+
+/**
+ * Thrown when a title/year pair is already taken. Rendered as 409.
+ */
+public class DuplicateMovieException extends RuntimeException {
+
+ public DuplicateMovieException(String title, Integer year) {
+ super("a movie titled '" + title + "' from " + year + " already exists");
+ }
+}
diff --git a/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/service/InvalidMovieException.java b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/service/InvalidMovieException.java
new file mode 100644
index 000000000..dca2392f2
--- /dev/null
+++ b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/service/InvalidMovieException.java
@@ -0,0 +1,12 @@
+package org.movies.xml.service;
+
+/**
+ * Thrown when a request breaks a business rule that bean validation cannot express
+ * on its own, or when a PATCH field is invalid. Rendered as 400.
+ */
+public class InvalidMovieException extends RuntimeException {
+
+ public InvalidMovieException(String message) {
+ super(message);
+ }
+}
diff --git a/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/service/MovieNotFoundException.java b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/service/MovieNotFoundException.java
new file mode 100644
index 000000000..64a85a38f
--- /dev/null
+++ b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/service/MovieNotFoundException.java
@@ -0,0 +1,19 @@
+package org.movies.xml.service;
+
+/**
+ * Thrown when an id does not match any stored movie. Rendered as 404.
+ */
+public class MovieNotFoundException extends RuntimeException {
+
+ public MovieNotFoundException(Long id) {
+ super(message(id));
+ }
+
+ /**
+ * The one wording for "no such movie", shared with the handler that turns an id
+ * which is not even a number into the same 404.
+ */
+ public static String message(Object id) {
+ return "movie " + id + " not found";
+ }
+}
diff --git a/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/service/MovieService.java b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/service/MovieService.java
new file mode 100644
index 000000000..637ab2ef1
--- /dev/null
+++ b/jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/service/MovieService.java
@@ -0,0 +1,377 @@
+package org.movies.xml.service;
+
+import org.movies.xml.domain.Actor;
+import org.movies.xml.domain.Genre;
+import org.movies.xml.domain.Movie;
+import org.movies.xml.dto.ActorDto;
+import org.movies.xml.dto.DirectorDto;
+import org.movies.xml.dto.GenreStatsDto;
+import org.movies.xml.dto.MovieDto;
+import org.movies.xml.dto.StatsDto;
+import org.movies.xml.repository.MovieRepository;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+/**
+ * All the branching lives here: the controllers only translate HTTP into calls on
+ * this service and back.
+ */
+@Service
+@Transactional
+public class MovieService {
+
+ private static final Pattern NATIONALITY = Pattern.compile("^[A-Z]{2}$");
+ private static final Pattern WHITESPACE_RUN = Pattern.compile("\\s+");
+
+ /** Above this rating a movie is expected to name at least one actor. */
+ private static final double CAST_REQUIRED_ABOVE_RATING = 9.0;
+
+ private static final int MAX_CAST_SIZE = 10;
+
+ private final MovieRepository repository;
+
+ public MovieService(MovieRepository repository) {
+ this.repository = repository;
+ }
+
+ // ------------------------------------------------------------------ reads
+
+ @Transactional(readOnly = true)
+ public List search(Genre genre,
+ String titleContains,
+ Integer minYear,
+ Integer maxYear,
+ Double minRating,
+ String sort) {
+
+ if (minYear != null && maxYear != null && minYear > maxYear) {
+ throw new InvalidMovieException("minYear must not be greater than maxYear");
+ }
+ Comparator comparator = comparatorFor(sort);
+
+ List matches = new ArrayList<>();
+ for (Movie movie : repository.findAll()) {
+ if (genre != null && movie.getGenre() != genre) {
+ continue;
+ }
+ if (titleContains != null && !containsIgnoreCase(movie.getTitle(), titleContains)) {
+ continue;
+ }
+ if (minYear != null && movie.getYear() < minYear) {
+ continue;
+ }
+ if (maxYear != null && movie.getYear() > maxYear) {
+ continue;
+ }
+ if (minRating != null && (movie.getRating() == null || movie.getRating() < minRating)) {
+ continue;
+ }
+ matches.add(movie);
+ }
+ if (comparator != null) {
+ matches.sort(comparator);
+ }
+
+ List result = new ArrayList<>(matches.size());
+ for (Movie movie : matches) {
+ result.add(toDto(movie));
+ }
+ return result;
+ }
+
+ @Transactional(readOnly = true)
+ public MovieDto findById(Long id) {
+ return toDto(load(id));
+ }
+
+ @Transactional(readOnly = true)
+ public StatsDto stats() {
+ List all = repository.findAll();
+
+ List perGenre = new ArrayList<>();
+ for (Genre genre : Genre.values()) {
+ long count = 0;
+ double sum = 0.0;
+ int rated = 0;
+ for (Movie movie : all) {
+ if (movie.getGenre() != genre) {
+ continue;
+ }
+ count++;
+ if (movie.getRating() != null) {
+ sum += movie.getRating();
+ rated++;
+ }
+ }
+ if (count == 0) {
+ continue;
+ }
+ Double average = (rated == 0) ? null : round2(sum / rated);
+ perGenre.add(new GenreStatsDto(genre.name(), count, average));
+ }
+ return new StatsDto(all.size(), perGenre);
+ }
+
+ // ----------------------------------------------------------------- writes
+
+ public MovieDto create(MovieDto request) {
+ String title = request.getTitle();
+ Integer year = request.getYear();
+
+ checkCast(request.getCast());
+ checkRatingAgainstCast(request.getRating(), request.getCast());
+ checkDuplicate(title, year, null);
+
+ Movie movie = new Movie();
+ movie.setTitle(title);
+ movie.setYear(year);
+ movie.setGenre(request.getGenre());
+ movie.setRating(request.getRating());
+ movie.setDirectorName(request.getDirector().getName());
+ movie.setDirectorNationality(request.getDirector().getNationality());
+ movie.setCast(toActors(request.getCast()));
+
+ return toDto(repository.save(movie));
+ }
+
+ public MovieDto replace(Long id, MovieDto request) {
+ Movie movie = load(id);
+
+ checkCast(request.getCast());
+ checkRatingAgainstCast(request.getRating(), request.getCast());
+ checkDuplicate(request.getTitle(), request.getYear(), id);
+
+ movie.setTitle(request.getTitle());
+ movie.setYear(request.getYear());
+ movie.setGenre(request.getGenre());
+ movie.setRating(request.getRating());
+ movie.setDirectorName(request.getDirector().getName());
+ movie.setDirectorNationality(request.getDirector().getNationality());
+ movie.setCast(toActors(request.getCast()));
+
+ return toDto(repository.save(movie));
+ }
+
+ /**
+ * Applies only the fields that were literally present in the received document.
+ * Each field is its own branch, and an empty cast element clears the cast while
+ * an absent one leaves it untouched.
+ */
+ public MovieDto patch(Long id, MovieDto request) {
+ Movie movie = load(id);
+
+ String title = movie.getTitle();
+ Integer year = movie.getYear();
+
+ if (request.isPresent("title")) {
+ String value = request.getTitle();
+ if (value == null || value.trim().isEmpty() || value.length() > 200) {
+ throw new InvalidMovieException("title must be between 1 and 200 characters");
+ }
+ title = value;
+ }
+ if (request.isPresent("year")) {
+ Integer value = request.getYear();
+ if (value == null || value < 1888 || value > 2100) {
+ throw new InvalidMovieException("year must be between 1888 and 2100");
+ }
+ year = value;
+ }
+ if (request.isPresent("title") || request.isPresent("year")) {
+ checkDuplicate(title, year, id);
+ movie.setTitle(title);
+ movie.setYear(year);
+ }
+ if (request.isPresent("genre")) {
+ if (request.getGenre() == null) {
+ throw new InvalidMovieException("genre must be one of " + java.util.Arrays.toString(Genre.values()));
+ }
+ movie.setGenre(request.getGenre());
+ }
+ if (request.isPresent("rating")) {
+ Double value = request.getRating();
+ if (value != null && (value < 0.0 || value > 10.0)) {
+ throw new InvalidMovieException("rating must be between 0.0 and 10.0");
+ }
+ movie.setRating(value);
+ }
+ if (request.isPresent("director")) {
+ DirectorDto director = request.getDirector();
+ if (director == null) {
+ throw new InvalidMovieException("director must carry a name");
+ }
+ if (director.getName() != null) {
+ String name = director.getName();
+ if (name.trim().isEmpty() || name.length() > 120) {
+ throw new InvalidMovieException("director name must be between 1 and 120 characters");
+ }
+ movie.setDirectorName(name);
+ }
+ if (director.getNationality() != null) {
+ checkNationality(director.getNationality());
+ movie.setDirectorNationality(director.getNationality());
+ }
+ }
+
+ List cast = request.getCastAsReceived();
+ if (cast != null) {
+ checkCast(cast);
+ movie.setCast(toActors(cast));
+ }
+
+ // The rating/cast rule is evaluated on the merged state, not on the patch alone.
+ checkRatingAgainstCast(movie.getRating(), toActorDtos(movie.getCast()));
+
+ return toDto(repository.save(movie));
+ }
+
+ public void delete(Long id) {
+ repository.delete(load(id));
+ }
+
+ // ------------------------------------------------------------ b. rules
+
+ private void checkDuplicate(String title, Integer year, Long excludedId) {
+ String normalized = normalize(title);
+ for (Movie other : repository.findAll()) {
+ if (excludedId != null && excludedId.equals(other.getId())) {
+ continue;
+ }
+ if (normalize(other.getTitle()).equals(normalized) && other.getYear().equals(year)) {
+ throw new DuplicateMovieException(title, year);
+ }
+ }
+ }
+
+ private void checkCast(List cast) {
+ if (cast == null) {
+ return;
+ }
+ if (cast.size() > MAX_CAST_SIZE) {
+ throw new InvalidMovieException("cast must not hold more than " + MAX_CAST_SIZE + " actors");
+ }
+ Set seen = new HashSet<>();
+ for (ActorDto actor : cast) {
+ if (actor == null || actor.getName() == null || actor.getName().trim().isEmpty()) {
+ throw new InvalidMovieException("every actor must carry a name");
+ }
+ if (actor.getName().length() > 120) {
+ throw new InvalidMovieException("actor name must be between 1 and 120 characters");
+ }
+ Integer billing = actor.getBilling();
+ if (billing == null) {
+ continue;
+ }
+ if (billing < 1 || billing > 99) {
+ throw new InvalidMovieException("billing must be between 1 and 99");
+ }
+ if (!seen.add(billing)) {
+ throw new InvalidMovieException("billing values must be unique within a cast");
+ }
+ }
+ }
+
+ private void checkRatingAgainstCast(Double rating, List cast) {
+ if (rating != null && rating > CAST_REQUIRED_ABOVE_RATING && (cast == null || cast.isEmpty())) {
+ throw new InvalidMovieException(
+ "a movie rated above " + CAST_REQUIRED_ABOVE_RATING + " must list at least one actor");
+ }
+ }
+
+ /**
+ * Only PATCH reaches this. Create and replace take a {@code @Valid} body, so the
+ * {@code @Pattern} on DirectorDto.nationality has already rejected the same values by
+ * the time they get here; PATCH deliberately skips bean validation, because a document
+ * that carries one field cannot satisfy constraints on the fields it leaves out.
+ */
+ private void checkNationality(String nationality) {
+ if (nationality != null && !NATIONALITY.matcher(nationality).matches()) {
+ throw new InvalidMovieException("nationality must be exactly two upper case letters");
+ }
+ }
+
+ // -------------------------------------------------------------- helpers
+
+ private Movie load(Long id) {
+ return repository.findById(id).orElseThrow(() -> new MovieNotFoundException(id));
+ }
+
+ private Comparator comparatorFor(String sort) {
+ if (sort == null) {
+ return null;
+ }
+ switch (sort) {
+ case "title":
+ return Comparator.comparing(m -> m.getTitle().toLowerCase(Locale.ROOT));
+ case "year":
+ return Comparator.comparing(Movie::getYear);
+ case "rating":
+ return Comparator.comparing(Movie::getRating, Comparator.nullsLast(Comparator.naturalOrder()));
+ default:
+ throw new InvalidMovieException("sort must be one of title, year, rating");
+ }
+ }
+
+ private static boolean containsIgnoreCase(String haystack, String needle) {
+ return haystack.toLowerCase(Locale.ROOT).contains(needle.toLowerCase(Locale.ROOT));
+ }
+
+ private static String normalize(String title) {
+ if (title == null) {
+ return "";
+ }
+ return WHITESPACE_RUN.matcher(title.trim()).replaceAll(" ").toLowerCase(Locale.ROOT);
+ }
+
+ private static Double round2(double value) {
+ return Math.round(value * 100.0) / 100.0;
+ }
+
+ private static List toActors(List cast) {
+ List actors = new ArrayList<>();
+ if (cast != null) {
+ for (ActorDto dto : cast) {
+ actors.add(new Actor(dto.getName(), dto.getBilling()));
+ }
+ }
+ return actors;
+ }
+
+ private static List toActorDtos(List cast) {
+ List dtos = new ArrayList<>();
+ if (cast != null) {
+ for (Actor actor : cast) {
+ ActorDto dto = new ActorDto();
+ dto.setName(actor.getName());
+ dto.setBilling(actor.getBilling());
+ dtos.add(dto);
+ }
+ }
+ return dtos;
+ }
+
+ private static MovieDto toDto(Movie movie) {
+ MovieDto dto = new MovieDto();
+ dto.setId(movie.getId());
+ dto.setTitle(movie.getTitle());
+ dto.setYear(movie.getYear());
+ dto.setGenre(movie.getGenre());
+ dto.setRating(movie.getRating());
+
+ DirectorDto director = new DirectorDto();
+ director.setName(movie.getDirectorName());
+ director.setNationality(movie.getDirectorNationality());
+ dto.setDirector(director);
+
+ dto.setCast(toActorDtos(movie.getCast()));
+ return dto;
+ }
+}
diff --git a/jdk_21_maven/cs/rest/movies-xml/src/main/resources/application.properties b/jdk_21_maven/cs/rest/movies-xml/src/main/resources/application.properties
new file mode 100644
index 000000000..32e32cc66
--- /dev/null
+++ b/jdk_21_maven/cs/rest/movies-xml/src/main/resources/application.properties
@@ -0,0 +1,14 @@
+spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1
+spring.datasource.driver-class-name=org.h2.Driver
+spring.datasource.username=sa
+spring.datasource.password=
+spring.jpa.hibernate.ddl-auto=create-drop
+spring.h2.console.enabled=false
+spring.jackson.default-property-inclusion=non_null
+
+# The API speaks XML only; these keep the generated schema from ever mentioning JSON.
+springdoc.default-consumes-media-type=application/xml
+springdoc.default-produces-media-type=application/xml
+
+# springdoc emits 3.1 by default; EMB stores every schema as Swagger 2.0 or OpenAPI 3.0.
+springdoc.api-docs.version=openapi_3_0
diff --git a/jdk_21_maven/cs/rest/movies-xml/src/test/java/org/movies/xml/MovieControllerTest.java b/jdk_21_maven/cs/rest/movies-xml/src/test/java/org/movies/xml/MovieControllerTest.java
new file mode 100644
index 000000000..8dfd163da
--- /dev/null
+++ b/jdk_21_maven/cs/rest/movies-xml/src/test/java/org/movies/xml/MovieControllerTest.java
@@ -0,0 +1,692 @@
+package org.movies.xml;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Iterator;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.xpath;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@Transactional
+class MovieControllerTest {
+
+ private static final String MATRIX = """
+
+ The Matrix
+ SCIFI
+ 8.7
+ Lana Wachowski
+
+ Keanu Reeves
+ Laurence Fishburne
+
+
+ """;
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper jsonMapper;
+
+ // ------------------------------------------------------------- creation
+
+ @Test
+ void validXmlCreatesTheMovie() throws Exception {
+ mockMvc.perform(post("/movies").contentType(MediaType.APPLICATION_XML).content(MATRIX))
+ .andExpect(status().isCreated())
+ .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_XML))
+ .andExpect(header().exists("Location"))
+ .andExpect(xpath("/movie/@id").exists())
+ .andExpect(xpath("/movie/@year").string("1999"))
+ .andExpect(xpath("/movie/title").string("The Matrix"))
+ .andExpect(xpath("/movie/director/@nationality").string("US"))
+ .andExpect(xpath("/movie/cast/actor[1]/@billing").string("1"))
+ .andExpect(xpath("/movie/cast/actor[2]/name").string("Laurence Fishburne"));
+ }
+
+ @Test
+ void locationHeaderPointsAtTheCreatedMovie() throws Exception {
+ String location = create(MATRIX);
+
+ mockMvc.perform(get(location))
+ .andExpect(status().isOk())
+ .andExpect(xpath("/movie/title").string("The Matrix"));
+ }
+
+ @Test
+ void jsonBodyIsRejectedWithUnsupportedMediaType() throws Exception {
+ mockMvc.perform(post("/movies")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"title\":\"The Matrix\",\"year\":1999,\"genre\":\"SCIFI\"}"))
+ .andExpect(status().isUnsupportedMediaType());
+ }
+
+ @Test
+ void malformedXmlIsRejected() throws Exception {
+ mockMvc.perform(post("/movies").contentType(MediaType.APPLICATION_XML)
+ .content("The Matrix"))
+ .andExpect(status().isBadRequest())
+ .andExpect(xpath("/error/@status").string("400"))
+ .andExpect(xpath("/error/message").exists());
+ }
+
+ @Test
+ void idAndYearSpelledAsElementsAreRejected() throws Exception {
+ mockMvc.perform(post("/movies").contentType(MediaType.APPLICATION_XML).content("""
+
+ 7
+ 1999
+ The Matrix
+ SCIFI
+ Lana Wachowski
+
+ """))
+ .andExpect(status().isBadRequest())
+ .andExpect(xpath("/error/@status").string("400"));
+ }
+
+ @Test
+ void unwrappedCastIsRejected() throws Exception {
+ mockMvc.perform(post("/movies").contentType(MediaType.APPLICATION_XML).content("""
+
+ The Matrix
+ SCIFI
+ Lana Wachowski
+ Keanu Reeves
+
+ """))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ void nationalitySpelledAsAnElementIsRejected() throws Exception {
+ mockMvc.perform(post("/movies").contentType(MediaType.APPLICATION_XML).content("""
+
+ The Matrix
+ SCIFI
+ Lana WachowskiUS
+
+ """))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ void duplicateTitleAndYearAreRejected() throws Exception {
+ create(MATRIX);
+
+ mockMvc.perform(post("/movies").contentType(MediaType.APPLICATION_XML).content("""
+
+ the MATRIX
+ ACTION
+ Someone Else
+
+ """))
+ .andExpect(status().isConflict())
+ .andExpect(xpath("/error/@status").string("409"));
+ }
+
+ @Test
+ void sameTitleInAnotherYearIsAccepted() throws Exception {
+ create(MATRIX);
+
+ mockMvc.perform(post("/movies").contentType(MediaType.APPLICATION_XML).content("""
+
+ The Matrix
+ SCIFI
+ Lana Wachowski
+
+ """))
+ .andExpect(status().isCreated());
+ }
+
+ // ------------------------------------------------------- business rules
+
+ @Test
+ void repeatedBillingIsRejected() throws Exception {
+ mockMvc.perform(post("/movies").contentType(MediaType.APPLICATION_XML).content("""
+
+ Twice Billed
+ DRAMA
+ Someone
+
+ A
+ B
+
+
+ """))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ void aHighRatingWithoutCastIsRejected() throws Exception {
+ mockMvc.perform(post("/movies").contentType(MediaType.APPLICATION_XML).content("""
+
+ Acclaimed But Empty
+ DRAMA
+ 9.5
+ Someone
+
+ """))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ void aHighRatingWithCastIsAccepted() throws Exception {
+ mockMvc.perform(post("/movies").contentType(MediaType.APPLICATION_XML).content("""
+
+ Acclaimed
+ DRAMA
+ 9.5
+ Someone
+ A
+
+ """))
+ .andExpect(status().isCreated());
+ }
+
+ @Test
+ void aLowercaseNationalityIsRejected() throws Exception {
+ mockMvc.perform(post("/movies").contentType(MediaType.APPLICATION_XML).content("""
+
+ Bad Nationality
+ DRAMA
+ Someone
+
+ """))
+ .andExpect(status().isBadRequest());
+ }
+
+ /**
+ * Create and replace reject it through the {@code @Pattern} on the DTO, patch through
+ * the service. All three must keep saying 400, whichever side of that line the check
+ * happens to live on.
+ */
+ @Test
+ void aLowercaseNationalityIsRejectedOnEveryWrite() throws Exception {
+ String location = create(MATRIX);
+ String badDirector = """
+
+ Bad Nationality
+ DRAMA
+ Someone
+
+ """;
+
+ mockMvc.perform(put(location).contentType(MediaType.APPLICATION_XML).content(badDirector))
+ .andExpect(status().isBadRequest())
+ .andExpect(xpath("/error/@status").string("400"));
+
+ mockMvc.perform(patch(location).contentType(MediaType.APPLICATION_XML)
+ .content("Someone"))
+ .andExpect(status().isBadRequest())
+ .andExpect(xpath("/error/message").string("nationality must be exactly two upper case letters"));
+ }
+
+ @Test
+ void anOutOfRangeYearIsRejected() throws Exception {
+ mockMvc.perform(post("/movies").contentType(MediaType.APPLICATION_XML).content("""
+
+ Too Old
+ DRAMA
+ Someone
+
+ """))
+ .andExpect(status().isBadRequest())
+ .andExpect(xpath("/error/message").exists());
+ }
+
+ @Test
+ void anUnknownGenreIsRejected() throws Exception {
+ mockMvc.perform(post("/movies").contentType(MediaType.APPLICATION_XML).content("""
+
+ Unknown Genre
+ MUSICAL
+ Someone
+
+ """))
+ .andExpect(status().isBadRequest());
+ }
+
+ // ---------------------------------------------------------------- reads
+
+ @Test
+ void anEmptyCatalogueIsAnEmptyMoviesElement() throws Exception {
+ mockMvc.perform(get("/movies"))
+ .andExpect(status().isOk())
+ .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_XML))
+ .andExpect(xpath("/movies/movie").doesNotExist());
+ }
+
+ @Test
+ void anUnknownIdIsReportedAsAnXmlError() throws Exception {
+ mockMvc.perform(get("/movies/424242"))
+ .andExpect(status().isNotFound())
+ .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_XML))
+ .andExpect(xpath("/error/@status").string("404"))
+ .andExpect(xpath("/error/message").exists());
+ }
+
+ @Test
+ void searchFiltersAndSorts() throws Exception {
+ create(MATRIX);
+ create("""
+
+ Amelie
+ COMEDY
+ 7.2
+ Jean-Pierre Jeunet
+
+ """);
+ create("""
+
+ Unrated Drama
+ DRAMA
+ Nobody
+
+ """);
+
+ mockMvc.perform(get("/movies").param("genre", "SCIFI"))
+ .andExpect(status().isOk())
+ .andExpect(xpath("/movies/movie").nodeCount(1))
+ .andExpect(xpath("/movies/movie[1]/title").string("The Matrix"));
+
+ mockMvc.perform(get("/movies").param("titleContains", "mat"))
+ .andExpect(xpath("/movies/movie").nodeCount(1));
+
+ mockMvc.perform(get("/movies").param("minYear", "2000").param("maxYear", "2005"))
+ .andExpect(xpath("/movies/movie").nodeCount(1))
+ .andExpect(xpath("/movies/movie[1]/title").string("Amelie"));
+
+ mockMvc.perform(get("/movies").param("minRating", "7.0"))
+ .andExpect(xpath("/movies/movie").nodeCount(2));
+
+ mockMvc.perform(get("/movies").param("sort", "title"))
+ .andExpect(xpath("/movies/movie[1]/title").string("Amelie"));
+
+ mockMvc.perform(get("/movies").param("sort", "year"))
+ .andExpect(xpath("/movies/movie[1]/title").string("The Matrix"));
+ }
+
+ @Test
+ void invalidSearchParametersAreRejected() throws Exception {
+ mockMvc.perform(get("/movies").param("genre", "MUSICAL"))
+ .andExpect(status().isBadRequest());
+
+ mockMvc.perform(get("/movies").param("minYear", "2000").param("maxYear", "1990"))
+ .andExpect(status().isBadRequest());
+
+ mockMvc.perform(get("/movies").param("sort", "director"))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ void statsSkipEmptyGenresAndAverageOnlyRatedMovies() throws Exception {
+ create(MATRIX);
+ create("""
+
+ Unrated Drama
+ DRAMA
+ Nobody
+
+ """);
+
+ mockMvc.perform(get("/movies/stats"))
+ .andExpect(status().isOk())
+ .andExpect(xpath("/stats/@total").string("2"))
+ .andExpect(xpath("/stats/genre").nodeCount(2))
+ .andExpect(xpath("/stats/genre[@name='SCIFI']/averageRating").string("8.7"))
+ .andExpect(xpath("/stats/genre[@name='DRAMA']/averageRating").doesNotExist())
+ .andExpect(xpath("/stats/genre[@name='HORROR']").doesNotExist());
+ }
+
+ // -------------------------------------------------------------- updates
+
+ @Test
+ void putReplacesEveryField() throws Exception {
+ String location = create(MATRIX);
+
+ mockMvc.perform(put(location).contentType(MediaType.APPLICATION_XML).content("""
+
+ The Matrix Reloaded
+ ACTION
+ Lilly Wachowski
+
+ """))
+ .andExpect(status().isOk())
+ .andExpect(xpath("/movie/@year").string("2003"))
+ .andExpect(xpath("/movie/title").string("The Matrix Reloaded"))
+ .andExpect(xpath("/movie/genre").string("ACTION"))
+ .andExpect(xpath("/movie/rating").doesNotExist())
+ .andExpect(xpath("/movie/cast/actor").doesNotExist());
+ }
+
+ @Test
+ void putOnAnUnknownIdIsNotFound() throws Exception {
+ mockMvc.perform(put("/movies/424242").contentType(MediaType.APPLICATION_XML).content(MATRIX))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ void patchTouchesOnlyTheTitle() throws Exception {
+ String location = create(MATRIX);
+
+ mockMvc.perform(patch(location).contentType(MediaType.APPLICATION_XML)
+ .content("Matrix, The"))
+ .andExpect(status().isOk())
+ .andExpect(xpath("/movie/title").string("Matrix, The"))
+ .andExpect(xpath("/movie/@year").string("1999"))
+ .andExpect(xpath("/movie/genre").string("SCIFI"))
+ .andExpect(xpath("/movie/rating").string("8.7"))
+ .andExpect(xpath("/movie/cast/actor").nodeCount(2));
+ }
+
+ @Test
+ void patchTouchesOnlyTheYear() throws Exception {
+ String location = create(MATRIX);
+
+ mockMvc.perform(patch(location).contentType(MediaType.APPLICATION_XML)
+ .content(""))
+ .andExpect(status().isOk())
+ .andExpect(xpath("/movie/@year").string("2000"))
+ .andExpect(xpath("/movie/title").string("The Matrix"));
+ }
+
+ @Test
+ void patchTouchesOnlyTheGenre() throws Exception {
+ String location = create(MATRIX);
+
+ mockMvc.perform(patch(location).contentType(MediaType.APPLICATION_XML)
+ .content("ACTION"))
+ .andExpect(status().isOk())
+ .andExpect(xpath("/movie/genre").string("ACTION"))
+ .andExpect(xpath("/movie/rating").string("8.7"));
+ }
+
+ @Test
+ void patchTouchesOnlyTheRating() throws Exception {
+ String location = create(MATRIX);
+
+ mockMvc.perform(patch(location).contentType(MediaType.APPLICATION_XML)
+ .content("9.9"))
+ .andExpect(status().isOk())
+ .andExpect(xpath("/movie/rating").string("9.9"))
+ .andExpect(xpath("/movie/title").string("The Matrix"));
+ }
+
+ @Test
+ void patchTouchesOnlyTheDirector() throws Exception {
+ String location = create(MATRIX);
+
+ mockMvc.perform(patch(location).contentType(MediaType.APPLICATION_XML)
+ .content("Lilly Wachowski"))
+ .andExpect(status().isOk())
+ .andExpect(xpath("/movie/director/name").string("Lilly Wachowski"))
+ .andExpect(xpath("/movie/director/@nationality").string("AU"))
+ .andExpect(xpath("/movie/title").string("The Matrix"));
+ }
+
+ @Test
+ void patchWithAnEmptyCastClearsIt() throws Exception {
+ String location = create(MATRIX);
+
+ mockMvc.perform(patch(location).contentType(MediaType.APPLICATION_XML)
+ .content(""))
+ .andExpect(status().isOk())
+ .andExpect(xpath("/movie/cast/actor").doesNotExist())
+ .andExpect(xpath("/movie/title").string("The Matrix"));
+ }
+
+ @Test
+ void patchWithoutACastLeavesItIntact() throws Exception {
+ String location = create(MATRIX);
+
+ mockMvc.perform(patch(location).contentType(MediaType.APPLICATION_XML)
+ .content("ACTION"))
+ .andExpect(status().isOk())
+ .andExpect(xpath("/movie/cast/actor").nodeCount(2));
+ }
+
+ @Test
+ void patchReplacesTheWholeCast() throws Exception {
+ String location = create(MATRIX);
+
+ mockMvc.perform(patch(location).contentType(MediaType.APPLICATION_XML)
+ .content("Carrie-Anne Moss"))
+ .andExpect(status().isOk())
+ .andExpect(xpath("/movie/cast/actor").nodeCount(1))
+ .andExpect(xpath("/movie/cast/actor[1]/name").string("Carrie-Anne Moss"));
+ }
+
+ @Test
+ void patchThatWouldLeaveAHighRatingWithoutCastIsRejected() throws Exception {
+ String location = create("""
+
+ Acclaimed
+ DRAMA
+ 9.5
+ Someone
+ A
+
+ """);
+
+ mockMvc.perform(patch(location).contentType(MediaType.APPLICATION_XML)
+ .content(""))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ void patchIntoADuplicateIsRejected() throws Exception {
+ create(MATRIX);
+ String location = create("""
+
+ The Matrix Reloaded
+ ACTION
+ Lana Wachowski
+
+ """);
+
+ mockMvc.perform(patch(location).contentType(MediaType.APPLICATION_XML)
+ .content("The Matrix"))
+ .andExpect(status().isConflict());
+ }
+
+ @Test
+ void patchOnAnUnknownIdIsNotFound() throws Exception {
+ mockMvc.perform(patch("/movies/424242").contentType(MediaType.APPLICATION_XML)
+ .content("ACTION"))
+ .andExpect(status().isNotFound());
+ }
+
+ // ------------------------------------------------------------- deletion
+
+ @Test
+ void deleteRemovesTheMovie() throws Exception {
+ String location = create(MATRIX);
+
+ mockMvc.perform(delete(location)).andExpect(status().isNoContent());
+ mockMvc.perform(get(location)).andExpect(status().isNotFound());
+ }
+
+ @Test
+ void deleteOnAnUnknownIdIsNotFound() throws Exception {
+ mockMvc.perform(delete("/movies/424242"))
+ .andExpect(status().isNotFound())
+ .andExpect(xpath("/error/@status").string("404"));
+ }
+
+ // ------------------------------------------------------ ids that are no ids
+
+ @Test
+ void anIdThatIsNotANumberIsNotFound() throws Exception {
+ mockMvc.perform(get("/movies/abc"))
+ .andExpect(status().isNotFound())
+ .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_XML))
+ .andExpect(xpath("/error/@status").string("404"))
+ .andExpect(xpath("/error/message").string("movie abc not found"));
+ }
+
+ @Test
+ void anIdTooLargeForALongIsNotFound() throws Exception {
+ mockMvc.perform(get("/movies/99999999999999999999"))
+ .andExpect(status().isNotFound())
+ .andExpect(xpath("/error/@status").string("404"));
+ }
+
+ @Test
+ void aBlankIdIsNotFound() throws Exception {
+ mockMvc.perform(get("/movies/{id}", " "))
+ .andExpect(status().isNotFound())
+ .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_XML))
+ .andExpect(xpath("/error/@status").string("404"));
+ }
+
+ @Test
+ void everyMethodOnAnIdThatIsNotANumberIsNotFound() throws Exception {
+ mockMvc.perform(put("/movies/abc").contentType(MediaType.APPLICATION_XML).content(MATRIX))
+ .andExpect(status().isNotFound());
+ mockMvc.perform(patch("/movies/abc").contentType(MediaType.APPLICATION_XML)
+ .content("8.0"))
+ .andExpect(status().isNotFound());
+ mockMvc.perform(delete("/movies/abc"))
+ .andExpect(status().isNotFound());
+ }
+
+ /** The id is part of the path, a query parameter is not: only the first became a 404. */
+ @Test
+ void aQueryParameterOfTheWrongTypeIsStillBadRequest() throws Exception {
+ mockMvc.perform(get("/movies").param("minYear", "abc"))
+ .andExpect(status().isBadRequest())
+ .andExpect(xpath("/error/@status").string("400"));
+ }
+
+ // -------------------------------------------- failures the handler owns
+
+ @Test
+ void anUnsupportedMethodIsAnXmlError() throws Exception {
+ mockMvc.perform(delete("/movies"))
+ .andExpect(status().isMethodNotAllowed())
+ .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_XML))
+ .andExpect(xpath("/error/@status").string("405"));
+ }
+
+ @Test
+ void askingForJsonIsNotAcceptable() throws Exception {
+ mockMvc.perform(get("/movies").accept(MediaType.APPLICATION_JSON))
+ .andExpect(status().isNotAcceptable())
+ .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_XML))
+ .andExpect(xpath("/error/@status").string("406"));
+ }
+
+ // ---------------------------------------------------------- the schema
+
+ @Test
+ void theOpenApiSchemaIsJsonAndNeverOffersJsonBodies() throws Exception {
+ MvcResult result = mockMvc.perform(get("/v3/api-docs"))
+ .andExpect(status().isOk())
+ .andReturn();
+
+ JsonNode document = jsonMapper.readTree(result.getResponse().getContentAsString());
+
+ JsonNode paths = document.path("paths");
+ assertThat(paths.isMissingNode()).isFalse();
+ paths.forEach(path -> path.forEach(operation -> {
+ assertOnlyXml(operation.path("requestBody").path("content"));
+ operation.path("responses").forEach(response -> assertOnlyXml(response.path("content")));
+ }));
+
+ JsonNode movie = document.path("components").path("schemas").path("MovieDto");
+ assertThat(movie.path("xml").path("name").asText()).isEqualTo("movie");
+ assertThat(movie.path("properties").path("id").path("xml").path("attribute").asBoolean()).isTrue();
+ assertThat(movie.path("properties").path("year").path("xml").path("attribute").asBoolean()).isTrue();
+ assertThat(movie.path("properties").path("cast").path("xml").path("wrapped").asBoolean()).isTrue();
+
+ // In OpenAPI 3.0 an "xml" block cannot sit next to a $ref, so the element name lives
+ // on the referenced schema, which is where a client resolving the reference reads it.
+ assertThat(movie.path("properties").path("cast").path("items").path("$ref").asText())
+ .isEqualTo("#/components/schemas/ActorDto");
+
+ JsonNode actor = document.path("components").path("schemas").path("ActorDto");
+ assertThat(actor.path("xml").path("name").asText()).isEqualTo("actor");
+ assertThat(actor.path("properties").path("billing").path("xml").path("attribute").asBoolean()).isTrue();
+
+ JsonNode director = document.path("components").path("schemas").path("DirectorDto");
+ assertThat(director.path("xml").path("name").asText()).isEqualTo("director");
+ assertThat(director.path("properties").path("nationality").path("xml").path("attribute").asBoolean()).isTrue();
+ }
+
+ /**
+ * The two failures every operation can hit are produced by the handler, so nothing in
+ * a controller would ever mention them. This is the guard that keeps the published
+ * schema from drifting away from that again when an endpoint is added.
+ *
+ * 500 is not among them on purpose: a fault found by a fuzzer should not be sitting
+ * in the schema as an expected answer.
+ */
+ @Test
+ void everyOperationDeclaresTheFailuresTheHandlerCanProduce() throws Exception {
+ MvcResult result = mockMvc.perform(get("/v3/api-docs"))
+ .andExpect(status().isOk())
+ .andReturn();
+
+ JsonNode paths = jsonMapper.readTree(result.getResponse().getContentAsString()).path("paths");
+ assertThat(paths.isMissingNode()).isFalse();
+
+ AtomicInteger checked = new AtomicInteger();
+ paths.fields().forEachRemaining(path -> path.getValue().fields().forEachRemaining(operation -> {
+ JsonNode responses = operation.getValue().path("responses");
+ for (String code : new String[]{"405", "406"}) {
+ assertThat(responses.has(code))
+ .as("%s %s declares %s", operation.getKey().toUpperCase(), path.getKey(), code)
+ .isTrue();
+ }
+ assertThat(responses.has("500"))
+ .as("%s %s leaves 500 undeclared", operation.getKey().toUpperCase(), path.getKey())
+ .isFalse();
+ checked.incrementAndGet();
+ }));
+
+ // Without this the assertions above would also hold for an empty document.
+ assertThat(checked.get()).as("operations checked").isGreaterThanOrEqualTo(7);
+ }
+
+ private static void assertOnlyXml(JsonNode content) {
+ if (content.isMissingNode()) {
+ return;
+ }
+ Iterator> mediaTypes = content.fields();
+ while (mediaTypes.hasNext()) {
+ assertThat(mediaTypes.next().getKey()).isEqualTo(MediaType.APPLICATION_XML_VALUE);
+ }
+ }
+
+ // --------------------------------------------------------------- tools
+
+ private String create(String xml) throws Exception {
+ MvcResult result = mockMvc.perform(post("/movies").contentType(MediaType.APPLICATION_XML).content(xml))
+ .andExpect(status().isCreated())
+ .andReturn();
+ return result.getResponse().getHeader("Location");
+ }
+}