From 75280a410212095095b5410124db0005b3c63cd1 Mon Sep 17 00:00:00 2001 From: Omur Sahin Date: Sat, 19 Sep 2026 10:04:47 +0300 Subject: [PATCH] source code --- jdk_21_maven/cs/rest/movies-xml/.gitignore | 1 + jdk_21_maven/cs/rest/movies-xml/LICENSE | 165 ++++ jdk_21_maven/cs/rest/movies-xml/README.md | 142 ++++ jdk_21_maven/cs/rest/movies-xml/openapi.json | 776 ++++++++++++++++++ jdk_21_maven/cs/rest/movies-xml/pom.xml | 81 ++ .../org/movies/xml/MoviesXmlApplication.java | 12 + .../org/movies/xml/config/OpenApiConfig.java | 152 ++++ .../java/org/movies/xml/config/WebConfig.java | 30 + .../xml/controller/ApiExceptionHandler.java | 159 ++++ .../xml/controller/MovieController.java | 153 ++++ .../StrictXmlRequestBodyAdvice.java | 292 +++++++ .../java/org/movies/xml/domain/Actor.java | 38 + .../java/org/movies/xml/domain/Genre.java | 9 + .../java/org/movies/xml/domain/Movie.java | 115 +++ .../java/org/movies/xml/dto/ActorDto.java | 43 + .../java/org/movies/xml/dto/DirectorDto.java | 41 + .../java/org/movies/xml/dto/ErrorDto.java | 44 + .../org/movies/xml/dto/GenreStatsDto.java | 56 ++ .../java/org/movies/xml/dto/MovieDto.java | 157 ++++ .../java/org/movies/xml/dto/MoviesDto.java | 34 + .../java/org/movies/xml/dto/StatsDto.java | 49 ++ .../xml/repository/MovieRepository.java | 7 + .../xml/service/DuplicateMovieException.java | 11 + .../xml/service/InvalidMovieException.java | 12 + .../xml/service/MovieNotFoundException.java | 19 + .../org/movies/xml/service/MovieService.java | 377 +++++++++ .../src/main/resources/application.properties | 14 + .../org/movies/xml/MovieControllerTest.java | 692 ++++++++++++++++ 28 files changed, 3681 insertions(+) create mode 100644 jdk_21_maven/cs/rest/movies-xml/.gitignore create mode 100644 jdk_21_maven/cs/rest/movies-xml/LICENSE create mode 100644 jdk_21_maven/cs/rest/movies-xml/README.md create mode 100644 jdk_21_maven/cs/rest/movies-xml/openapi.json create mode 100644 jdk_21_maven/cs/rest/movies-xml/pom.xml create mode 100644 jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/MoviesXmlApplication.java create mode 100644 jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/config/OpenApiConfig.java create mode 100644 jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/config/WebConfig.java create mode 100644 jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/controller/ApiExceptionHandler.java create mode 100644 jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/controller/MovieController.java create mode 100644 jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/controller/StrictXmlRequestBodyAdvice.java create mode 100644 jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/domain/Actor.java create mode 100644 jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/domain/Genre.java create mode 100644 jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/domain/Movie.java create mode 100644 jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/ActorDto.java create mode 100644 jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/DirectorDto.java create mode 100644 jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/ErrorDto.java create mode 100644 jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/GenreStatsDto.java create mode 100644 jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/MovieDto.java create mode 100644 jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/MoviesDto.java create mode 100644 jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/dto/StatsDto.java create mode 100644 jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/repository/MovieRepository.java create mode 100644 jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/service/DuplicateMovieException.java create mode 100644 jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/service/InvalidMovieException.java create mode 100644 jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/service/MovieNotFoundException.java create mode 100644 jdk_21_maven/cs/rest/movies-xml/src/main/java/org/movies/xml/service/MovieService.java create mode 100644 jdk_21_maven/cs/rest/movies-xml/src/main/resources/application.properties create mode 100644 jdk_21_maven/cs/rest/movies-xml/src/test/java/org/movies/xml/MovieControllerTest.java diff --git a/jdk_21_maven/cs/rest/movies-xml/.gitignore b/jdk_21_maven/cs/rest/movies-xml/.gitignore new file mode 100644 index 000000000..2f7896d1d --- /dev/null +++ b/jdk_21_maven/cs/rest/movies-xml/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/jdk_21_maven/cs/rest/movies-xml/LICENSE b/jdk_21_maven/cs/rest/movies-xml/LICENSE new file mode 100644 index 000000000..65c5ca88a --- /dev/null +++ b/jdk_21_maven/cs/rest/movies-xml/LICENSE @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/jdk_21_maven/cs/rest/movies-xml/README.md b/jdk_21_maven/cs/rest/movies-xml/README.md new file mode 100644 index 000000000..de27df0d0 --- /dev/null +++ b/jdk_21_maven/cs/rest/movies-xml/README.md @@ -0,0 +1,142 @@ +# movies-xml + +image + +A movie catalogue REST API whose request and response bodies are **exclusively +`application/xml`**. There is no JSON path anywhere in the API: a write sent as +`application/json` is answered with `415`, and errors come back as XML too. + +The project is a case study for research on REST API fuzzing. What is being measured is +whether a fuzzer can build XML request bodies that honour the `xml` metadata of an +OpenAPI schema — attributes instead of elements, a root element whose name differs from +the class name, and a wrapped list. The database therefore starts **empty**: nothing is +preloaded, so every row either arrives through a well formed XML request or through a +direct SQL insert. + +The one accepted shape of a movie document is: + +```xml + + The Matrix + SCIFI + 8.7 + + Lana Wachowski + + + Keanu Reeves + Laurence Fishburne + + +``` + +`id` and `year` are attributes, `nationality` is an attribute of the nested ``, +and the cast is a `` wrapper around `` elements. Nothing else is accepted: +spelling `id` or `year` as elements, or hanging `` straight off ``, is a +`400`. On creation a supplied `id` is ignored and the server assigns its own. + +## Build and run + +Java 21 and Maven are required. + +```bash +mvn clean package +java -jar target/movies-xml-sut.jar +``` + +The app listens on port 8080 and stores everything in an in-memory H2 database that is +created at startup and dropped at shutdown. The H2 console is disabled. + +## Endpoints + +Seven, all under `/movies`: + +| Method | Path | Responses | +|---|---|---| +| `POST` | `/movies` | 201 + `Location`, 400, 409, 415 | +| `GET` | `/movies` | 200 (``), 400 | +| `GET` | `/movies/{id}` | 200, 404 | +| `PUT` | `/movies/{id}` | 200, 400, 404, 409, 415 | +| `PATCH` | `/movies/{id}` | 200, 400, 404, 409, 415 | +| `DELETE` | `/movies/{id}` | 204, 404 | +| `GET` | `/movies/stats` | 200 (``) | + +Two more answers are not tied to any one endpoint, because they come from the exception +handler rather than from a controller: `405` for a method the path does not support and +`406` for a client that asks for anything other than `application/xml`. Every endpoint can +produce both, and the schema says so on every operation. + +The handler also turns anything unexpected into a `500`, and that one is deliberately left +out of the schema. This is a fuzzing target: a `500` is a fault worth reporting, not an +answer a tool should be told to expect. No request is known to produce one. + +Anything in the `{id}` position that is not a stored movie is a `404`, including an id that +is not a number at all (`/movies/abc`), one too large for a 64 bit integer, and a blank one +(`/movies/%20`). The `400` on `GET /movies` is for the query parameters, which are part of +the request rather than of the path. The one gap is a URI Tomcat refuses to parse — a null +byte or an encoded traversal, as in `/movies/%00` — which is rejected with an HTML `400` +before the application runs at all. + +`GET /movies` takes `genre`, `titleContains`, `minYear`, `maxYear`, `minRating` and +`sort` (`title`, `year` or `rating`); they are optional and combinable. + +`PATCH` applies only the fields the document actually carries, and it tells an absent +`` (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> converterType) { + return MovieDto.class.equals(targetType); + } + + @Override + public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, + MethodParameter parameter, + Type targetType, + Class> 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> 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> 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</movie>")) + .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(""" + <movie> + <id>7</id> + <year>1999</year> + <title>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"); + } +}