Construir API RESTful con Spring Boot, Oracle 11g y Liquibase desde cero

Introducción

Este blog es muy similar al anterior, pero ahora vamos a utilizar Oracle y Liquibase para las migraciones. Como antes, vamos a construir una api que pueda hacer selects, inserts, updates y deletes de la base de datos del acervo de tesis de una universidad.

Antes de comenzar me gustaría aclarar que el término migración en nuestro contexto, no tiene nada que ver con resolver la necesidad de cambiar un gestor de base de datos por otro o actualizar la versión del gestor actual, sino que se refiere a versionar la base, así como Git nos permite versionar el código, el tener migraciones nos permitirá conocer, revertir o implementar un cambio en la base de datos de una manera más fácil y sin el riesgo de que “se te olvide” o que hagas un rollback medio extraño o a medias, esto no debería pasar porque absolutamente todos los cambios que ha sufrido la base están documentados. Creeme, mientras más grande es un proyecto o mientras más personas trabajan en él, necesitamos flujos de trabajo y metodologías que nos ayuden a que la administración sea fácil y eficiente, de igual forma que garanticen que nuestro producto o servicio es de la calidad esperada.

Otro ejemplo, supongamos que tú eres un estudiante de ingeniería recién egresado y en tu primer trabajo te asignan la emocionante tarea de rediseñar un sistema, hacerle ingeniería inversa y saber qué rayos estaba pensando un programador cuando implementó esas líneas de código que parecen hechas por un servicio social pero que el dueño de la empresa dice y jura que las escribió el mismísimo gurú de Java mexicano pero que cuando tú las lees piensas: “mmm… esto está feo, quien lo escribió alucinó” y que al pasar las semanas o meses ya hasta sientes que el leer ese código te ha hecho una lobotomía a ti y a todo tu equipo de desarrollo … si quieres ahorrarte o ahorrarle a quien sea que en el futuro estudie uno de tus sistemas, entonces las migraciones de la base de datos son algo que quieres escribir.

Finalmente, supongamos que para la empresa en la que desarrollamos software existen 4 ambientes, el primero es desarrollo, el segundo es pruebas, el tercero es calidad y el último es producción. Por alguna razón extraña y obra de la casualidad, un día se descubrió un bug mortal que evadia impuestos y borraba usuarios sin dejar rastro… Entonces se descubrió el error, se reparó y se hicieron algunos cambios en la base, los cambios empezaron en el ambiente de desarrollo de uno de los programadores, pero esas mejoras necesitan estar en producción y en todos los ambientes de desarrollo, ¿o no?, es por esto que, al momento de hacer un despliegue de la aplicación se despliega también la nueva versión de la base, mientras que el equipo de desarrollo puede saber que ocurrió un cambio en la base y ejecutar las migraciones. Es más, si una persona del equipo de desarrollo por vacaciones o enfermedad se ausenta, no pasa nada, aunque nadie le diga que ocurrió un cambio y sin que ésta persona pregunte, sabrá cuándo y cómo fueron esos cambios en la base.

¿Ya, te convencí de que las migraciones son buenas para nuestros proyectos?

Análisis y diseño

El siguiente diagrama de clases modela nuestro acervo de tesis:

Diagrama de clases

Lo que equivaldría al siguiente diagrama entidad-relación:

Diagrama entidad-relación

Quiero explicarte el por qué de este diagrama E-R
Existen diferentes tipos de relaciones en los modelos (Entities),pero para este caso vamos a usar Single Table para Tesista y Asesor y usaremos Join Table para tesis-asesor y tesis-tesista. Persona será una superclase de la que heredarán Asesor y Tesista pero que no tendrá su propia tabla.

Generar el proyecto base

Para poder generar el proyecto base vamos a utilizar la página https://start.spring.io con los siguientes parámetros:

Inicializador del proyecto

Necesitarás descargar y descomprimir el zip que genera el inicializador e importar el proyecto como un proyecto Gradle.

Lo primero que haremos será agregar los datos de conexión en el archivo

acervo-oracle/src/main/resources/application.properties

    # configuración de Oracle
    spring.datasource.url=jdbc:oracle:thin:@localhost:1521:DESARROLLO
    spring.datasource.username=admin
    spring.datasource.password=Zanah0riA
    spring.datasource.driver-class-name=oracle.jdbc.driver.OracleDriver

    # Hibernate ddl auto (create, create-drop, validate, update)
    spring.jpa.hibernate.ddl-auto = validate

Utilizamos ddl-auto = validate para que hibernate NO haga modificaciones a la base en cuanto reconozca cambios en los Entities, si quisieramos que actualizara automáticamente la base de datos entonces usariamos ddl-auto = update pero este último parámetro NO DEBERÍA USARSE EN AMBIENTES DE PRODUCCIÓN, en producción es recomendable utilizar validate y dejar la tarea a un gestor de migraciones como Liquibase o Flyway.

Escribir nuestro archivo de migraciones

en src/main/resources creamos un paquete db.changelog y dentro de este, un archivo db.changelog-master.yaml con el siguiente contenido:

databaseChangeLog:
  - changeSet:
      id: 0
      author: paklei
      changes:
        - createTable:
            tableName: tesistas
            columns:
              - column:
                  name: id
                  type: int
                  constraints:
                    primaryKey: true
                    nullable: false
              - column:
                  name: numero_cuenta
                  type: varchar(255)
                  constraints:
                    nullable: false
              - column:
                  name: nombre
                  type: varchar(255)
                  constraints:
                    nullable: false
              - column:
                  name: apellido_paterno
                  type: varchar(255)
                  constraints:
                    nullable: false
              - column:
                  name: apellido_materno
                  type: varchar(255)
                  constraints:
                    nullable: false
        - createSequence:
            sequenceName: sec_id_tesistas
            incrementBy: 1
            maxValue: 9999999999
            minValue: 1
            startValue: 1
            ordered: true
            cycle: false

        //  createTable asesores
        //  createSequence asesores

        //  createTable tesis
        //  createSequence tesis

Liquibase tiene implementadas funciones para crear ids auto incrementales, pero hasta este momento no están soportados para oracle, es por esto que lo resolvemos con secuencias.

Agregamos algunas librerias…

Vamos a utilizar un ojdbc que descargaremos de la página oficial de oracle (ojdbc6.jar) o en este link, después necesitaremos crear una nueva carpeta en la raiz del proyecto llamada libs y copiarlo a esta carpeta.

En el archivo build.gradle necesitaremos agregar lo siguiente

dependencies {
    compile('org.springframework.boot:spring-boot-starter-data-jpa')
    compile('org.springframework.boot:spring-boot-starter-web')
    compile files('libs/ojdbc6.jar') // agrega nuestro ojdbc
    compile('org.liquibase:liquibase-core') // agrega liquibase
    runtime('org.springframework.boot:spring-boot-devtools')
    testCompile('org.springframework.boot:spring-boot-starter-test')
    
}

Ahora vamos a crear los modelos y los controladores. Necesitaremos crear 4 nuevos paquetes

mx.uaemex.fi.acervooracle.modelo
mx.uaemex.fi.acervooracle.exception
mx.uaemex.fi.acervooracle.controlador
mx.uaemex.fi.acervooracle.repositorio

Dentro del paquete exception vamos a crear una nueva clase llamada ResourceNotFoundException de la siguiente manera:

package mx.uaemex.fi.acervooracle.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(value = HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {
    private String tabla;
    private String columna;
    private Object valor;

    public ResourceNotFoundException( String tabla, String columna, Object valor) {
        super(String.format("%s inexistente con %s : '%s'", tabla, columna, valor));
        this.tabla = tabla;
        this.columna = columna;
        this.valor = valor;
    }

    public String getTabla() {
        return tabla;
    }

    public String getColumna() {
        return columna;
    }

    public Object getValor() {
        return valor;
    }
}

Con esta clase podremos enviar un response http con un estado 404, usando la anotación @ResponseStatus, y un body con un mensaje parecido a tesis inexistente con título: ‘hola mundo’.

Escribir nuestros modelos

Ahora crearemos nuestros Entities, que nos permiten manejar la capa de persistencia. En nuestro diagrama de clases tenemos una clase Persona, de ella heredan Tesista y Asesor.

@MappedSuperclass
public class Persona {
    private String nombre;
    private String apellidoPaterno;
    private String apellidoMaterno;

    //getters y setters
}

La anotación @MappedSuperclass indica que esta clase será padre de otras clases de tipo Entity, significa también que no tiene relacionada una tabla en la base de forma directa en la cual persista datos sino que lo hace en tablas de las clases hijas.

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@Table(name = "tesistas")
public class Tesista extends Persona implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator="sec_id_tesistas")
    @SequenceGenerator(name="sec_id_tesistas", sequenceName = "sec_id_tesistas", allocationSize=1)
    private Integer id;

    private String numeroCuenta;

    @OneToMany(mappedBy = "tesista", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Tesis> tesis = new ArrayList<>();

    //getters y setters

Para nuestros Entities, usamos una estrategia SEQUENCE, y nuestro generador tiene asignado un nombre que podría ser distinto al nombre de la secuencia, pero por cuestiones prácticas le asignamos el mismo en este caso, además ALLOCATIONSIZE tiene que tener un valor 1 para así almacenar sólo un valor generado por la secuencia a la vez, independientemente de si la secuencia tiene incrementos de 2 en 2, 10 en 10,etc. AllocationSize siempre deberá de valer 1 si no quieres que tus ids sean generados con valores aparentemente ‘random’. Continuemos…

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@Table(name = "asesores")
public class Asesor extends Persona implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator="sec_id_asesores")
    @SequenceGenerator(name="sec_id_asesores", sequenceName = "sec_id_asesores", allocationSize=1)
    private Integer id;

    private String claveAsesor;

    private String area;

    @OneToMany(mappedBy = "asesor", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Tesis> tesis = new ArrayList<>();

    //getters y setters
@Entity
public class Tesis implements Serializable {
    
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator="sec_id_tesis")
    @SequenceGenerator(name="sec_id_tesis", sequenceName = "sec_id_tesis", allocationSize=1)
    private Integer id;

    private String titulo;

    private Date fecha;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "asesor_id")
    @JsonIgnore
    private Asesor asesor;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "tesista_id")
    @JsonIgnore
    private Tesista tesista;

    //getters y setters

Existen diferentes tipos de herencia o relación (MappedSuperclass, Single Table, Joined Table o Table Per Class) con las que podemos definir a nuestros modelos, dependiendo de lo que necesitemos podemos elegir uno u otro. Pero para el ejemplo de nuestro acervo de tesis la anotación que necesitamos es Single Table para Tesista y Asesor y Joined Table para Tesis.

Imaginemos que tenemos 2 tesis en nuestra base de datos, 3 asesores y 2 tesistas. La anotación @JsonIgnore evita un mortal Harakiri de nuestra api, sin ella, al hacer una petición GET api/tesis o /api/tesis/1 se generaría un ciclo infiniiiito ya que una tesis, tiene un asesor, un asesor puede dirigir una o más tesis y esas tesis tiene un asesor …. bueno ya me entendiste.

Ahora, la anotación @ManyToOne en el Entity Tesis para sus atributos tesista y asesor, tienen el nombre de la columna de esa tabla que hace referencia a la tabla tesistas o asesores. El Entity Tesista y Asesor tienen una anotación @OneToMany las cuales tienen un parámetro mappedBy = “asesor” o “mappedBy = “tesista” que es el atributo en el Entity Tesis.

Escribir los repositorios

Ahora tenemos que escribir nuestros repositorios, estos nos permiten tener la implementación de los métodos save, findOne, findAll, count, delete y de esta forma persistir cambios que sufren nuestros modelos.

@Repository
public interface TesistaRepositorio extends JpaRepository<Tesista, Integer>{
}
@Repository
public interface TesisRepositorio extends JpaRepository<Tesis, Integer>{
    Page<Tesis> findByTesistaId(Integer tesistaId, Pageable pageable);
    Page<Tesis> findByAsesorId(Integer asesorId, Pageable pageable);
}
@Repository
public interface AsesorRepositorio extends JpaRepository<Asesor, Integer> {
}

Los métodos findByTesistaId y findByAsesorId los usaremos para que la api pueda tener búsquedas un poco más específicas, así podremos encontrar una tesis por su id, las tesis de un tesista y las tesis de un asesor.

Ahora se va a poner bueno, la mejor parte de todas Los controladores!

Escribir los controladores

Aquí es cuando nuestro proyecto empieza a verse como una API de verdad, en los controladores tendremos nuestras rutas y nuestra lógica del negocio. Primero trataré el controlador de Tesis ya que es el más rico en contenido y complejidad.

@RestController
@RequestMapping("/api")
public class TesisControlador {
    private Tesis tesis;
    
    @Autowired
    TesisRepositorio tesisRepositorio;
    
    @Autowired
    TesistaRepositorio tesistaRepositorio;
    
    @Autowired
    AsesorRepositorio asesorRepositorio;

    @GetMapping("/tesis")
    public Page<Tesis> getTesis(Pageable pageable) {
        return tesisRepositorio.findAll(pageable);
    }

    @GetMapping("/tesis/{id}")
    public Tesis getTesisById(@PathVariable(value = "id") Integer tesisId) {
        return tesisRepositorio.findById(tesisId)
                .orElseThrow(() -> new ResourceNotFoundException("Tesis", "id", tesisId));
    }
    
    @GetMapping("/tesista/{tesistaId}/tesis")
    public Page<Tesis> getTesisByTesistaId(@PathVariable (value = "tesistaId") Integer tesistaId, Pageable pageable) {
        return tesisRepositorio.findByTesistaId(tesistaId, pageable);
        }
    
    @GetMapping("/asesor/{asesorId}/tesis")
    public Page<Tesis> getTesisByAsesorId(@PathVariable (value = "asesorId") Integer asesorId, Pageable pageable) {
        return tesisRepositorio.findByAsesorId(asesorId, pageable);
        }
    
    @PostMapping("tesista/{tesistaId}/asesor/{asesorId}/tesis")
    public Tesis createTesis(@PathVariable (value = "tesistaId") Integer tesistaId,@PathVariable (value = "asesorId") Integer asesorId,
                                 @Valid @RequestBody Tesis tesis) {
        this.tesis = tesis;
        tesistaRepositorio.findById(tesistaId).map(tesista -> {
            this.tesis.setTesista(tesista);
            return this.tesis;
        }).orElseThrow(() -> new ResourceNotFoundException("Tesista ","id",tesistaId));
        
        asesorRepositorio.findById(asesorId).map(asesor -> {
            this.tesis.setAsesor(asesor);
            return this.tesis;
        }).orElseThrow(() -> new ResourceNotFoundException("Asesor ","id",asesorId));
        
        return tesisRepositorio.save(tesis);
    }

    @PutMapping("/tesista/{tesistaId}/asesor/{asesorId}/tesis/{tesisId}")
    public Tesis updateTesis(@PathVariable (value = "tesistaId") Integer tesistaId,
                                 @PathVariable (value = "asesorId") Integer asesorId,
                                 @PathVariable (value = "tesisId") Integer tesisId,
                                 @Valid @RequestBody Tesis tesisRequest) {
        if(!tesistaRepositorio.existsById(tesistaId)) {
            throw new ResourceNotFoundException("Tesista ","id",tesistaId);
        }
        
        if(!asesorRepositorio.existsById(asesorId)) {
            throw new ResourceNotFoundException("Asesor ","id",asesorId);
        }

        return tesisRepositorio.findById(tesisId).map(tesis -> {
            tesis.setTitulo(tesisRequest.getTitulo());
            tesis.setFecha(tesisRequest.getFecha());
            return tesisRepositorio.save(tesis);
        }).orElseThrow(() -> new ResourceNotFoundException("Tesis ", "id", tesisId));
    }

    @DeleteMapping("/tesis/{id}")
    public ResponseEntity<?> deleteTesis(@PathVariable(value = "id") Integer tesisId) {
        Tesis tesis = tesisRepositorio.findById(tesisId)
                .orElseThrow(() -> new ResourceNotFoundException("Tesis", "id", tesisId));

        tesisRepositorio.delete(tesis);

        return ResponseEntity.ok().build();
    }

Como puedes darte cuenta, existe una relación entre los métodos HTTP y las operaciones del CRUD. Una petición GET están asociada a un query SELECT, una petición POST está asociada a un query INSERT, una petición PUT a un UPDATE y una petición Delete con ese mismo query. Esto no tiene por qué ser siempre así, por ejemplo, podría darse el caso de que una API que únicamente consulta información de la base tenga mapeados métodos POST y no implemente ningún GET porque no se desea tener visibles en la URL los parámetros de entrada. Esto dependerá de las necesidades del proyecto, pero en general funciona de la manera antes mencionada.

La anotación @RequestMapping(“/api”) agregará como un prefijo a todos los métodos de este controlador esa ruta, de esta forma tendremos las siguientes rutas para nuestro controlador de tesis:

Ruta Método Funcionalidad
/api/tesis GET Consultar todas las tesis paginadas
/api/tesis/1 GET Consultar la tesis con id 1
/api/tesista/1/tesis GET Consultar las tesis del tesista con id 1
/api/asesor/1/tesis GET Consultar las tesis del asesor con id 1
/api/tesista/1/asesor/2/tesis POST Registrar la tesis enviada con tesistaId = 1 y asesorId = 2
/api/tesista/1/asesor/2/tesis/1 PUT Actualizar la tesis enviada con tesisId=1, tesistaId = 1 y asesorId = 2
/api/tesis/2 DELETE Eliminar la tesis con 2

El ruta del método PUT podría ser más corta pero para efectos de validación en el controlador se escribió de esta manera.

A continuación el controlador de asesores

@RestController
@RequestMapping("/api")
public class AsesorControlador {
    @Autowired
    AsesorRepositorio asesorRepositorio;
    
    @Autowired
    TesisRepositorio tesisRepositorio;

    @GetMapping("/asesor")
    public List<Asesor> getAsesores() {
        return asesorRepositorio.findAll();
    }

    @PostMapping("/asesor")
    public Asesor createAsesor(@Valid @RequestBody Asesor asesor) {
        return asesorRepositorio.save(asesor);
    }

    @GetMapping("/asesor/{id}")
    public Asesor getAsesorById(@PathVariable(value = "id") Integer asesorId) {
        return asesorRepositorio.findById(asesorId)
                .orElseThrow(() -> new ResourceNotFoundException("Asesor", "id", asesorId));
    }

    @PutMapping("/asesor/{id}")
    public Asesor updateAsesor(@PathVariable(value = "id") Integer asesorId,
                                            @Valid @RequestBody Asesor nuevoAsesor) {

        Asesor asesor = asesorRepositorio.findById(asesorId)
                .orElseThrow(() -> new ResourceNotFoundException("Asesor", "id", asesorId));

        asesor.setClaveAsesor(nuevoAsesor.getClaveAsesor());
        asesor.setArea(nuevoAsesor.getArea());
        asesor.setNombre(nuevoAsesor.getNombre());
        asesor.setApellidoPaterno(nuevoAsesor.getApellidoPaterno());
        asesor.setApellidoMaterno(nuevoAsesor.getApellidoMaterno());

        Asesor asesorActualizado = asesorRepositorio.save(asesor);
        return asesorActualizado;
    }

    @DeleteMapping("/asesor/{id}")
    public ResponseEntity<?> deleteAsesor(@PathVariable(value = "id") Integer asesorId) {
        Asesor asesor = asesorRepositorio.findById(asesorId)
                .orElseThrow(() -> new ResourceNotFoundException("Asesor", "id", asesorId));

        asesorRepositorio.delete(asesor);

        return ResponseEntity.ok().build();
    }

Ahora el controlador de tesistas, es básicamente el mismo que el controlador anterior.

@RestController
@RequestMapping("/api")
public class TesistaControlador {
    @Autowired
    TesistaRepositorio tesistaRepositorio;
    
    @Autowired
    TesisRepositorio tesisRepositorio;

    @GetMapping("/tesista")
    public List<Tesista> getTesistas() {
        return tesistaRepositorio.findAll();
    }

    @PostMapping("/tesista")
    public Tesista createTesista(@Valid @RequestBody Tesista tesista) {
        return tesistaRepositorio.save(tesista);
    }

    @GetMapping("/tesista/{id}")
    public Tesista getTesistaById(@PathVariable(value = "id") Integer tesistaId) {
        return tesistaRepositorio.findById(tesistaId)
                .orElseThrow(() -> new ResourceNotFoundException("Tesista", "id", tesistaId));
    }

    @PutMapping("/tesista/{id}")
    public Tesista updateTesista(@PathVariable(value = "id") Integer tesistaId,
                                            @Valid @RequestBody Tesista nuevoTesista) {

        Tesista tesista = tesistaRepositorio.findById(tesistaId)
                .orElseThrow(() -> new ResourceNotFoundException("Tesista", "id", tesistaId));

        tesista.setNumeroCuenta(nuevoTesista.getNumeroCuenta());
        tesista.setNombre(nuevoTesista.getNombre());
        tesista.setApellidoPaterno(nuevoTesista.getApellidoPaterno());
        tesista.setApellidoMaterno(nuevoTesista.getApellidoMaterno());

        Tesista tesistaActualizado = tesistaRepositorio.save(tesista);
        return tesistaActualizado;
    }

    @DeleteMapping("/tesista/{id}")
    public ResponseEntity<?> deleteTesista(@PathVariable(value = "id") Integer tesistaId) {
        Tesista tesista = tesistaRepositorio.findById(tesistaId)
                .orElseThrow(() -> new ResourceNotFoundException("Tesista", "id", tesistaId));

        tesistaRepositorio.delete(tesista);

        return ResponseEntity.ok().build();
    }

Iniciamos el servidor

En la terminal ejecutamos el comando Gradle clean build bootRun para que sean borrados assets y clases generados previamente (si existieran), build crea los assets y las clases que serán despachados por el servidor tomcat, bootRun levanta el servidor Tomcat en el puerto 8080 o algún otro puerto si modificamos nuestro application.properties, y despacha nuestra aplicación.

Tenemos que leer en la terminal algo parecido a lo siguiente:

...
2018-07-13 11:15:06.072  INFO 23076 --- [  restartedMain] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
2018-07-13 11:15:06.075  INFO 23076 --- [  restartedMain] m.u.f.a.AcervoOracleApplication          : Started AcervoOracleApplication in 6.526 seconds (JVM running for 6.947)
2018-07-13 11:15:44.331  INFO 23076 --- [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring FrameworkServlet 'dispatcherServlet'
2018-07-13 11:15:44.331  INFO 23076 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : FrameworkServlet 'dispatcherServlet': initialization started
2018-07-13 11:15:44.343  INFO 23076 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : FrameworkServlet 'dispatcherServlet': initialization completed in 12 ms

Una vez que podamos leer estos mensajes sabremos que nuestra API está lista para recibir y responder a las peticiones que programamos en nuestros controladores. Vamos a probar.

Probando nuestra API

Finalmente, utilizamos postman para probar nuestra API. Para hacer más breve esta sección, previamente registré algunos asesores,tesistas y una tesis.


Ejecutamos un POST de tesistas. GET tesis Podemos continuar probando los métodos GET, PUT y DELETE.


El código completo de este tutorial está disponible en GitHub a través de la siguiente liga: https://github.com/chidocodigo/acervo-oracle

Gracias. Comenta y comparte.

comments powered by Disqus