Sping Boot API

Spring Boot web services using JPA

https://github.com/gerardfp/SpringBootMovieAPI

Database server

Instal·la Docker a la màquina host:

root@host curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh

Inicia un contenidor Docker amb la base de dades PostreSQL:

@host docker run -dp 5432:5432 -ePOSTGRES_PASSWORD=abcd -ePOSTGRES_USER=user -ePOSTGRES_DB=db -v:/var/lib/postgresql postgres

Si ja tens un postgres en marxa al teu host, es possible que et done el següent error:

docker: Error response from daemon: [...]: Bind for 0.0.0.0:5432 failed: port is already allocated.

La solució és, o bé parar el postgres que tens en marxa, o bé utilitzar un nou port per a aquesta instància:

docker run -dp 9999:5432 -ePOSTGRES_PASSWORD=abcd -ePOSTGRES_USER=user -ePOSTGRES_DB=db -v:/var/lib/postgresql postgres

Si canvies el port, s'haurà de posar el mateix a la configuració de l'aplicació

Spring initializr

Accedeix a spring initializr per a generar un projecte Spring Boot

  1. Selecciona les següents opcions

    • Project: Gradle - Groovy
    • Language: Java
    • Spring Boot: 3.0.1
    • Project Metadata:
      • Group: com.example
      • Artifact: SpringBootMovieAPI
      • Name: SpringBootMovieAPI
      • Package name: com.example.SpringBootMovieAPI
      • Packaging: JAR
      • Java: 17
  2. Afegeix les dependències:

    • Spring Web
    • Spring Data JPA
    • PostgreSQL Driver
  3. Genera el projecte i descomprimeix-lo.
  4. Obre'l amb IntelliJ

Configura l'accés de l'aplicació a la base de dades:

src/main/resources/application.properties spring.datasource.url=jdbc:postgresql://localhost:5432/db spring.datasource.username=user spring.datasource.password=abcd spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect

Database migrations

La llibreria Flyway permet gestionar els canvis en l'esquema de la base de dades. És a dir, crear/eliminar/modifcar taules, dades, etc...

Afegeix Flyway al projecte:

build.gradle plugins { ... id 'org.flywaydb.flyway' version '9.8.1' } flyway { configFiles = ['src/main/resources/application.properties'] } dependencies { ... implementation 'org.flywaydb:flyway-core:9.8.1' }

Configura els paràmetres d'accés de la llibreria Flyway a la base de dades:

src/main/resources/application.properties # ... flyway.url=jdbc:postgresql://localhost:5432/db flyway.schemas=public flyway.user=user flyway.password=abcd spring.flyway.baseline_on_migrate=true

Crea una primera versió de l'esquema de la base de dades. Les migracions de l'esquema de la base de dades es defineixen creant arxius al directori resources/db/migration/. El nom d'aquests arxius ha de seguir una nomenclatura específica (veure: migrations#naming)

Crea l'arxiu resources/db/migration/V1__createdatabase.sql:

resources/db/migration/V1__createdatabase.sql CREATE TABLE IF NOT EXISTS movie ( movieid uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, title text, synopsis text, imageurl text); CREATE TABLE IF NOT EXISTS actor ( actorid uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, name text, imageurl text); CREATE TABLE IF NOT EXISTS genre ( genreid uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, label text); CREATE TABLE IF NOT EXISTS movie_actor ( movieid uuid REFERENCES movie(movieid) ON DELETE CASCADE, actorid uuid REFERENCES actor(actorid) ON DELETE CASCADE, PRIMARY KEY (movieid, actorid)); CREATE TABLE IF NOT EXISTS movie_genre ( movieid uuid REFERENCES movie(movieid) ON DELETE CASCADE, genreid uuid REFERENCES genre(genreid) ON DELETE CASCADE, PRIMARY KEY (movieid, genreid)); INSERT INTO movie(title, synopsis, imageurl) VALUES ('Movie One','This is the One Movie','movie1.jpg'), ('Movie Two','The Two Movie is the next','movie2.jpg'), ('Movie Three','The Trilogy','movie3.jpg'), ('Movie Four','Four movies is too much','movie4.jpg'); INSERT INTO actor(name, imageurl) VALUES ('Actor One','actor1.jpg'), ('Actor Two','actor2.jpg'), ('Actor Three','actor3.jpg'), ('Actor Four','actor4.jpg'), ('Actor Five','actor5.jpg'); INSERT INTO genre(label) VALUES ('Genre One'), ('Genre Two'), ('Genre Three'); INSERT INTO movie_actor VALUES ((SELECT movieid FROM movie WHERE title='Movie One'),(SELECT actorid FROM actor WHERE name='Actor One')), ((SELECT movieid FROM movie WHERE title='Movie One'),(SELECT actorid FROM actor WHERE name='Actor Two')), ((SELECT movieid FROM movie WHERE title='Movie Two'),(SELECT actorid FROM actor WHERE name='Actor Three')), ((SELECT movieid FROM movie WHERE title='Movie Two'),(SELECT actorid FROM actor WHERE name='Actor Four')), ((SELECT movieid FROM movie WHERE title='Movie Three'),(SELECT actorid FROM actor WHERE name='Actor Four')), ((SELECT movieid FROM movie WHERE title='Movie Three'),(SELECT actorid FROM actor WHERE name='Actor Five')), ((SELECT movieid FROM movie WHERE title='Movie Four'),(SELECT actorid FROM actor WHERE name='Actor One')), ((SELECT movieid FROM movie WHERE title='Movie Four'),(SELECT actorid FROM actor WHERE name='Actor Four')); INSERT INTO movie_genre VALUES ((SELECT movieid FROM movie WHERE title='Movie One'),(SELECT genreid FROM genre WHERE label='Genre One')), ((SELECT movieid FROM movie WHERE title='Movie One'),(SELECT genreid FROM genre WHERE label='Genre Two')), ((SELECT movieid FROM movie WHERE title='Movie Two'),(SELECT genreid FROM genre WHERE label='Genre One')), ((SELECT movieid FROM movie WHERE title='Movie Three'),(SELECT genreid FROM genre WHERE label='Genre One')), ((SELECT movieid FROM movie WHERE title='Movie Three'),(SELECT genreid FROM genre WHERE label='Genre Two')), ((SELECT movieid FROM movie WHERE title='Movie Three'),(SELECT genreid FROM genre WHERE label='Genre Three'));

Spring boot API

L'arquitectura bàsica de la nostra ApiHttp amb Spring Boot serà aquesta:

Model

Les classes Model serveixen per a crear objectes amb les dades i així poder transportar-les d'un component a un altre.

Entity

Començarem creant la classe Movie que ens servirà per a transportar les dades d'una pel·lícula:

src/main/java/com/example/SpringBootMovieAPI/domain/model/Movie.java package com.example.SpringBootMovieAPI.domain.model; import jakarta.persistence.*; import java.util.UUID; @Entity @Table(name = "movie") public class Movie { @Id @GeneratedValue(strategy = GenerationType.AUTO) public UUID movieid; public String title; public String imageurl; }

Repository

src/main/java/com/example/SpringBootMovieAPI/repository/MovieRepository.java package com.example.SpringBootMovieAPI.repository; import com.example.SpringBootMovieAPI.domain.model.Movie; import org.springframework.data.jpa.repository.JpaRepository; import java.util.UUID; public interface MovieRepository extends JpaRepository<Movie, UUID> { }

Controller

src/main/java/com/example/SpringBootMovieAPI/controller/MovieController.java package com.example.SpringBootMovieAPI.controller; import com.example.SpringBootMovieAPI.domain.model.Movie; import com.example.SpringBootMovieAPI.repository.MovieRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; import java.util.List; import java.util.UUID; @RestController @RequestMapping("/movies") public class MovieController { @Autowired private MovieRepository movieRepository; @GetMapping("/") public List<Movie> findAllMovies() { return movieRepository.findAll(); } @PostMapping("/") public Movie createMovie(@RequestBody Movie movie) { return movieRepository.save(movie); } @GetMapping("/id/{id}") public Movie findById(@PathVariable UUID id) { return movieRepository.findById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Movie '%s' not found".formatted(id))); } }

Prova l'aplicació:

Get all movies:

curl localhost:8080/movies/

Get movie by id:

curl localhost:8080/movies/id/{id}

Create movie:

curl -X POST -H "Content-Type: application/json" -d '{"title":"Movie Five", "imageurl":"movie5.jpg"}' http://localhost:8080/movies/

New --json option, jq, jo: https://daniel.haxx.se/blog/2022/02/02/curl-dash-dash-json/

curl --json '{"title":"Movie Five", "imageurl":"movie5.jpg"}' http://localhost:8080/movies curl -s localhost:8080/movies/ | jq jo title="Movie Five" imageurl=movie5.jpg | curl --json @- http://localhost:8080/movies/ | jq

File uploads

Afegirem una migració de la base de dades per a crear una taula que emmagatzemi els arxius que carregin (upload)

Crea aquest arxiu de migració:

src/main/resources/db/migration/V2__filetable.sql CREATE TABLE file ( fileid UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, contenttype TEXT, data bytea);

Creem el model:

src/main/java/com/example/SpringBootMovieAPI/domain/model/File.java package com.example.SpringBootMovieAPI.domain.model; import jakarta.persistence.*; import java.util.UUID; @Entity @Table(name = "file") public class File { @Id @GeneratedValue(strategy = GenerationType.AUTO) public UUID fileid; public String contenttype; public byte[] data; public File(){} public File(String contenttype, byte[] data) { this.contenttype = contenttype; this.data = data; } }

Creem el repository:

src/main/java/com/example/SpringBootMovieAPI/repository/FileRepository.java package com.example.SpringBootMovieAPI.repository; import com.example.SpringBootMovieAPI.domain.model.File; import org.springframework.data.jpa.repository.JpaRepository; import java.util.UUID; public interface FileRepository extends JpaRepository<File, UUID> { }

I per últim el controlador:

src/main/java/com/example/SpringBootMovieAPI/controller/FileController.java package com.example.SpringBootMovieAPI.controller; import com.example.SpringBootMovieAPI.domain.model.File; import com.example.SpringBootMovieAPI.repository.FileRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; import java.io.IOException; import java.util.List; import java.util.UUID; import java.util.stream.Collectors; @RestController @RequestMapping("/files") public class FileController { @Autowired FileRepository fileRepository; @GetMapping("/") public List<File> getAll() { return fileRepository.findAll(); } @PostMapping("/") public String upload(@RequestParam("file") MultipartFile uploadedFile) throws IOException { return fileRepository.save(new File(uploadedFile.getContentType(), uploadedFile.getBytes())).fileid.toString(); } @GetMapping("/id/{id}") public File getFileById(@PathVariable UUID id) { return fileRepository.findById(id) .orElseThrow(()-> new ResponseStatusException(HttpStatus.NOT_FOUND, "File '%s' not found".formatted(id))); } @GetMapping("/{id}") public byte[] getFile(@PathVariable UUID id) { return fileRepository.findById(id).map(f -> f.data) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "File '%s' not found".formatted(id))); } /* web upload */ @GetMapping("/web") public String webView() { return "<form method='POST' action='/files/web' enctype='multipart/form-data' style='display:flex;'><input id='file' type='file' name='file' style='display:none' onchange='preview.src=window.URL.createObjectURL(event.target.files[0])'><label for='file' style='border:1px dashed #999'><img id='preview' style='width:6em;max-height:6em;object-fit:contain;border:none'></label><input type='submit' style='background:#0096f7;color: white;border: 0;border-radius: 3px;padding: 8px;' value='Upload'></form><div style='display:flex;flex-wrap:wrap;gap:1em;'>" + fileRepository.findAll().stream().map(file -> "<img src='/files/"+file.fileid+"' style='width:12em;height:12em;object-fit:contain'>").collect(Collectors.joining()) + "</div>";} @PostMapping("/web") public String webUpload(@RequestParam("file") MultipartFile uploadedFile) throws IOException { upload(uploadedFile); return webView(); } }

Prova l'endpoint /files/:

Llista els arxius:

curl localhost:8080/files/

Puja un arxiu:

curl -F file=@foto.jpg localhost:8080/files/

Obté les dades d'un arxiu pel seu id:

curl localhost:8080/files/id/{id}

Obté el contingut d'un arxiu pel seu id:

curl localhost:8080/files/{id}

Alternativament, utilitza la mini interfície web: http://localhost:8080/files/web

File Size Limit:

src/main/resources/application.properties spring.servlet.multipart.max-file-size=100MB spring.servlet.multipart.max-request-size=100MB

Autenticació i autorització

JDBC Authentication

Crea aquest arxiu de migració:

src/main/resources/db/migration/V3__usertable.sql CREATE TABLE users( username varchar(50) NOT NULL PRIMARY KEY, password varchar(500) NOT NULL, enabled boolean NOT NULL ); CREATE TABLE authorities ( username varchar(50) NOT NULL PRIMARY KEY REFERENCES users(username), authority varchar(50) NOT NULL ); -- afegim un usuari de prova: user/password INSERT INTO users VALUES ('user', '{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW', true); INSERT INTO authorities VALUES ('user', 'ROLE_USER');

Afegeix la llibreria spring-boot-starter-security:

build.gradle dependencies { ... implementation 'org.springframework.boot:spring-boot-starter-security' }

Afegim la configuració de seguretat:

src/main/java/com/example/SpringBootMovieAPI/config/SecurityConfiguration.java package com.example.SpringBootMovieAPI.config; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.provisioning.JdbcUserDetailsManager; import org.springframework.security.provisioning.UserDetailsManager; import org.springframework.security.web.SecurityFilterChain; import javax.sql.DataSource; import static org.springframework.security.config.Customizer.withDefaults; @Configuration public class SecurityConfiguration { @Autowired private DataSource dataSource; @Bean public BCryptPasswordEncoder getPasswordEncoder() { return new BCryptPasswordEncoder(); } @Bean public UserDetailsManager users(DataSource dataSource) { return new JdbcUserDetailsManager(dataSource); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http.authorizeHttpRequests((authz) -> authz .requestMatchers("/users/register/").permitAll() // .requestMatchers("/h2-console/").permitAll() .anyRequest().permitAll() ) .httpBasic(withDefaults()) .csrf().disable() .headers().frameOptions().disable().and() // http://localhost:8080/h2-console .build(); } }

Registre d'usuaris

Creem el model User:

src/main/java/com/example/SpringBootMovieAPI/domain/model/User.java package com.example.SpringBootMovieAPI.domain.model; import jakarta.persistence.*; import java.util.Set; @Entity @Table(name="users") public class User { @Id public String username; public String password; public boolean enabled; @OneToMany(mappedBy="username") public Set<Authority> authorities; }

Creem el model Authority:

src/main/java/com/example/SpringBootMovieAPI/domain/model/Authority.java package com.example.SpringBootMovieAPI.domain.model; import jakarta.persistence.*; @Entity @Table(name="authorities") public class Authority { @Id public String username; public String authority; }

Creem el DTO UserRegisterRequest per a rebre les dades que ens enviarà l'usuari:

src/main/java/com/example/SpringBootMovieAPI/domain/dto/UserRegisterRequest.java public class UserRegisterRequest { public String username; public String password; }

També necessitarem un controlador per atendre les peticions de registre:

src/main/java/com/example/SpringBootMovieAPI/controller/UserController.java package com.example.SpringBootMovieAPI.controller; import com.example.SpringBootMovieAPI.domain.dto.UserRegisterRequest; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.userdetails.User; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.provisioning.UserDetailsManager; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/users") public class UserController { @Autowired private BCryptPasswordEncoder passwordEncoder; @Autowired private UserDetailsManager userDetailsManager; @PostMapping("/register/") public String register(@RequestBody UserRegisterRequest userRegisterRequest) { if (userDetailsManager.userExists(userRegisterRequest.username)) return "ERROR: usuario existente"; userDetailsManager.createUser(User.builder() .username(userRegisterRequest.username) .password(passwordEncoder.encode(userRegisterRequest.password)) .roles("USER") .build() ); return "OK"; } } curl -X POST -H "Content-Type: application/json" -d '{"username":"gerard", "password":"gerard123"}' http://localhost:8080/users/register/

Per últim caldrà decidir a quins endpoints caldrà estar autenticat per a accedir i quins no. Per exemple, podem donar accés al registre però requerir autenticació per a tot lo demés:

src/main/java/com/example/SpringBootMovieAPI/SecurityConfig.java .requestMatchers("/users/register/").permitAll() .anyRequest().authenticated() curl --user gerard:gerard123 localhost:8080/movies/

Maneig d'errors

spring.mvc.problemdetails.enabled=true

Per a que els mètodes REST retornin respostes de forma adequada podem utilitzar la classe ResponseEntity.

Aquesta classe té uns mètodes builder que ens permeten establir el HttpStatus, les capçaleres HTTP i el cos de la resposta.

Haurem d'implementar els mètdoes Mapping dels controlladors de forma que retornin un objecte de classe ResponseEntity<?>

Per exemple, en el següent codi retornem l'status 200 (OK) i afegim al cos de la resposta l'objecte movie que tot just s'ha creat.

@PostMapping public ResponseEntity<?> createMovie(@RequestBody Movie movie, Authentication authentication) { Movie movie = movieRepository.save(movie); return ResponseEntity.ok().body(movie); }

L'objecte movie que hem posat al body() es serialitzarà a dades JSON així:

{ "movieid": "c4806e2b-2e19-4e32-a7cd-8ead8b32350e", "title": "Movie Title", "imageurl": "/url/to/image" }

Projections

Una projecció és quan al "select" d'una consulta posem només un subconjunt de camps.

Per a fer-ho amb un JpaRepository primer definirem en un interface quins són els camps que volem seleccionar:

public interface ProjectionMovie { UUID getMovieid(); String getTitle(); Set<ProjectionActor> getActors(); }

Veiem que en lloc de definir els camps, hem de definir getters seguint l'estàndard JavaBeans.

Després al Repository podem fer que les consultes retornin objectes conforme a aquests interfaces:

public interface MovieRepository extends JpaRepository<Movie, UUID> { List<ProjectionMovie> findBy(); }

Açò funcionarà per a les consultes que JPA derivades del nom: Query Methods

Relacions

@ManyToMany

En una relació ManyToMany entre dues entitats hem d'escollir primer una de les dos entitats com la "propietària" de la relació i l'altra com a "no-propietària".

A l'entitat "pripietària" definirem les anotacions @ManyToMany i @JoinTable:

@Entity @Table(name = "movie") public class Movie { @Id @GeneratedValue(strategy = GenerationType.AUTO) public UUID movieid; public String title; public String imageurl; @ManyToMany @JoinTable(name = "movie_actor", joinColumns = @JoinColumn(name = "movieid"), inverseJoinColumns = @JoinColumn(name = "actorid")) public Set<Actor> actors; }

A l'entitat "no-propietària" definirem l'anotació @ManyToMany fent referència al camp de l'entitat "propietària" que defineix la relació:

@Entity @Table(name = "actor") public class Actor { @Id @GeneratedValue(strategy = GenerationType.AUTO) public UUID actorid; public String name; public String imageurl; @ManyToMany(mappedBy = "actors") public Set<Movie> movies; }

Com la relació és bidireccional, quan la llibreria Jackson faci la serialització a JSON, es produeix una dependència circular (recursió infinita). Podem tallar aquesta recursió amb l'anotació @JsonIgnoreProperties:

@Entity @Table(name = "movie") public class Movie { @Id @GeneratedValue(strategy = GenerationType.AUTO) public UUID movieid; public String title; public String imageurl; @ManyToMany @JoinTable(name = "movie_actor", joinColumns = @JoinColumn(name = "movieid"), inverseJoinColumns = @JoinColumn(name = "actorid")) @JsonIgnoreProperties("movies") public Set<Actor> actors; } @Entity @Table(name = "actor") public class Actor { @Id @GeneratedValue(strategy = GenerationType.AUTO) public UUID actorid; public String name; public String imageurl; @ManyToMany(mappedBy = "actors") @JsonIgnoreProperties("actors") public Set<Movie> movies; }