6. gyakorlat

CRUD REST API végpontok

Feladat

Készítsünk egy egyszerű REST végpontot, mely visszaadja az összes létező Contact-ot az adatbázisból!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package hu.suaf.contacts.rest;

import hu.suaf.contacts.model.Contact;
import hu.suaf.contacts.service.ContactService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping(path = "/api/contact", produces = "application/json")
public class ContactRestController {
    private ContactService contactService;

    public ContactRestController(ContactService contactService) {
        this.contactService = contactService;
    }

    @GetMapping
    public Iterable<Contact> contacts(){
        return contactService.getContacts();
    }
}

Az első fontos lépés, hogy a REST API-ban résztevevő osztályra a @RestController annotációt elhelyezzük! Ez az annotáció kettős jelentőséggel bír. Egyrészt ugyanúgy stereotype annotáció, tehát automatikusan regisztrálja a Spring egy beanként és így bárhol hozzáférhető lesz az alkalmazásukban (@Autowired). Másrészt ez az annotáció azt is megmondja, hogy a benne található kezelő metódusok a visszatérési értéküket közvetlenül a válasz body-jába írják (nem pedig egy modellen keresztül cipeljük a view-hoz).

Megjegyzés

A fenti megvalósítás egyenértékű azzal, ha az osztályt a @Controller annotációval látjuk el, de ebben az esetben az összes metódust annotálnunk kell a @ResponseBody-val, mely pontosan a fenti második pontot jelenti.

A @RequestMapping az osztályban megadott összes metódusra ad egy URL prefix-et, melyet tovább pontosíthatunk a metódusra adott Mapping-ekkel (jelen esetben a @GetMapping nem ad hozzá ehhez semmit, így az a /api/contact URL-re küldött GET kéréseket fogja kiszolgálni). A @RequestMapping-nél azt is megadjuk, hogy csak akkor szolgáljuk ki a kérést, ha annak Accept headérjében megtalálható az application/json. Ezzel igazából limitáljuk, hogy csak JSON eredményeket adunk, ugyanakkor lehet másik controller-ünk ugyanezzel az útvonallal (Pl.: az MVC-s megvalósításból /contact), feltéve, hogy azok a kérések nem igénylik a JSON output-ot (máskülönben összevesznének).

Megjegyzés

Amennyiben JSON mellett mondjuk XML-t is engedélyezni szeretnénk, akkor a produces attribútumban egy listát is megadhatunk:

1
@RequestMapping(path = "/api/contact", produces={"application/json", "text/xml"})

Amennyiben a klienseink más host-on futnak, akkor szükségünk lehet arra, hogy engedélyezzük a Cross Origin-t a megadott hostnak, vagy ha publikus az API-t készítünk, akkor mindenkinek adhatunk hozzáférést:

1
2
3
4
@CrossOrigin(origins = "*")
public class ContactRestController {
    ...
}

Ezután nézzük, hogy mi az eredménye a REST API hívásnak! Tesztelhetjük Postman-el, vagy egyszerűen curl-el. Amennyiben alapértelmezett porton futtatjuk az alkalmazásunkat, akkor a következőt kell kiadnunk:

1
curl http://localhost:8080/api/contact
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
[
    {
        "createdAt": null,
        "lastModifiedAt": null,
        "createdBy": null,
        "lastModifiedBy": null,
        "id": 1,
        "name": "Kiss Béla",
        "phone": null,
        "email": "kiss@bela.com",
        "address": null,
        "birthDate": null,
        "group": null
    },
    {
        "createdAt": null,
        "lastModifiedAt": null,
        "createdBy": null,
        "lastModifiedBy": null,
        "id": 2,
        "name": "Nagy János",
        "phone": null,
        "email": "nagy@janos.com",
        "address": null,
        "birthDate": null,
        "group": null
    }
]

A fenti példában elhelyezett elemekben csak a név és email attribútumokat adtuk meg, így ez teljesen helyes.

Feladat

Készítsünk egy másik végpontot, mely id alapján ad vissza egy contact-ot!

Az alapvető megoldás igen egyszerű:

1
2
3
4
@GetMapping("/{id}")
public Contact contactById(@PathVariable Long id){
    return contactService.getContactById(id);
}

A fentiben semmilyen extra dolog nincs, a @PathVariable-el az URL-ben megadott azonosítót leképezzük a metódus paraméterébe.

Egy problémát az jelent, hogy amikor a megadott id érvénytelen, akkor a getContactById() hívás eredménye null lesz, mely nem pont ideális, hiszen ilyen esetben a kliens egy üres body-t kap HTTP 200-as kóddal, vagyis azt mondjuk, hogy minden sikeres volt, pedig nem is létezik az amit kért. Egy jobb megoldás lehet, ha 404-et adunk vissza. Az előző példát ehhez kicsit átpofozzuk:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@GetMapping("/{id}")
public ResponseEntity<Contact> contactById(@PathVariable Long id){
    Contact c = contactService.getContactById(id);

    if(c == null){
        return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
    }

    return new ResponseEntity<>(c, HttpStatus.OK);
}

Az új elem, amit használunk a ResponseEntity, mely egy generikus osztály. A generikus paramétere nem más, mint a visszaadni kívánt objektum típusa. A lényeges különbség, hogy így a ResponseEntity rendelkezik egy status-al és a header-öket is meg tudjuk adni. A példában megvizsgáljuk, hogy a visszakapott objektum null-e vagy sem. Igaz esetben 404-es HTTP statust szeretnénk visszaadni, melyet meg tudunk csinálni a következőképpen:

1
ResponseEntity.status(HttpStatus.NOT_FOUND)

A ResponseEntity statikus metódusainak egy nagy része visszaad egy BodyBuilder objektumot (mint, ahogy a status is). Ezeket azok a metódusok adják vissza, melyek a statust módosítják valamilyen formában, így aztán a BodyBuilder-rel a válasz body-ját adhatjuk meg. Amikor készen vagyunk akkor a build() alkalmazásával kaphatjuk vissza a legyártott ResponseEntity objektumot. A 404 jelzésére használhattunk volna ezt is: return ResponseEntity.notFound().build();.

A statikus metódusokon kívül használhatjuk a konstruktort is, hogy a megfelelő ResponseEntity-t előállítsuk. A legbővebb paraméterlistával megadhatjuk a body-t, a header-t és a status kódot is. Jelen helyzetben viszont elegendő a status-t beállítanunk, illetve a body-ba elhelyezni a lekért Contact-ot.

1
return new ResponseEntity<>(c, HttpStatus.OK);

Próbáljuk ki az alkalmazást, mondjuk a 2-es id-val, melynek eredménye a következő:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
    "createdAt": null,
    "lastModifiedAt": null,
    "createdBy": null,
    "lastModifiedBy": null,
    "id": 2,
    "name": "Nagy János",
    "phone": null,
    "email": "nagy@janos.com",
    "address": null,
    "birthDate": null,
    "group": null
}

Amennyiben érvénytelen id-val hívjuk a végpontot (Pl.: http://localhost:8080/api/contact/5), akkor 404 - Not Found statust kapunk vissza üres body-val.

Feladat

A lekérések mellett adjunk lehetőséget arra, hogy új felhasználót adjunk a rendszerhez a REST API-n keresztül!

A feladat nem tűnik túl összetettnek, a következő kód megoldást is ad:

1
2
3
4
5
@PostMapping(consumes = "application/json")
@ResponseStatus(HttpStatus.CREATED)
public Contact addContact(@RequestBody Contact contact){
    return contactService.saveContact(contact);
}

A @PostMapping-ben megadjuk, hogy csakis JSON formátumban vagyunk hajlandóak fogadni az adatot (consumes = "application/json", azaz ezeknek a kéréseknek a Content-TYpe header-je application/json-nek kell lennie). Továbbá jelen helyzetben a @ResponseStatus(HttpStatus.CREATED)-t használjuk a status beállítására, azaz ha lefut a metódusunk, akkor automatikusan az itt megadott status code-al fog visszatérni a HttpServletResponse-unk.

Ha most kipróbáljuk ezt az alkalmazásunkban, akkor viszont hibát kapunk, pontosabban visszakapunk egy bejelentkezésre felszólító oldalt. Ez a CSRF védelem miatt van, amire adat lekéréskor (korábbi GET kérések) nem kellett figyelnünk, azonban a POST kéréseknél már lehetnek turpisságok, amiket ki kell védenünk. Spring-ben a CSRF védelem alapból be van kapcsolva. Ennek kiküszöbölésére (mivel most nem akarunk semmilyen autentikációt a REST kéréseinkre) hozzá kell nyúlnunk a WebSecurityConfig osztályhoz. A korábban megadott configure(HttpSecurity http) metódust egészítsük ki a legvégén a következővel:

1
2
3
4
5
6
7
@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
    ...
    .logoutSuccessUrl("/login")
    .and().csrf().ignoringAntMatchers("/api/**");
}

A fenti kódban, mivel nem akarjuk a CSRF-et teljesen kikapcsolni (amit nem is ajánlok), ezért azt mondjuk, hogy csak a /api/** -ra érkező kéréseknél kapcsoljuk ki a CSRF védelmet.

Ha már itt vagyunk, akkor nézzük meg, hogy mi történik, ha a h2-console-t szeretnénk jelenleg elérni. A Security beállításaink miatt be kell jelentkeznünk, pedig magának a h2-console-nak is van bejelentkeztetése. Ha nem szeretnénk, hogy ide is be kelljen jelentkezni, akkor a WebSecurityConfig-ba a következőt is adjuk meg:

1
2
3
4
@Override
public void configure(WebSecurity web) throws Exception {
    web.ignoring().antMatchers("/h2-console/**");
}

Ezzel nem egyszerűséggel azt mondjuk, hogy a /h2-console/ kezdetű URL-eken nem foglalkozunk security-vel.

Feladat

Készítsünk REST API végpontot a Contact frissítéséhez!

Ezzel sincs nagy dolgunk:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@PutMapping("/{id}")
public ResponseEntity<Contact> updateContact(@PathVariable Long id, @RequestBody Contact contact){
    if(contact.getId() == null){
        contact.setId(id);
    } else if(!Objects.equals(id, contact.getId())){
        return ResponseEntity.badRequest().build();
    }

    return ResponseEntity.ok(contactService.saveContact(contact));
}

Néhány dolgot azért szem előtt kell tartanunk! Ha nincs megadva a contact id-ja, akkor használjuk az url-ben megadottat, máskülönben megvizsgáljuk, hogy a két azonosító megegyezett-e, mivel eltérés esetén valami nem úgy lett elküldve, ahogy a felhasználó szerette volna.

Tegyük fel hogy a 3-as id-val létező kontakt nevét szeretném szerkeszteni, így a http://localhost:8080/api/contact/3-ra a következő body-val rendelkező PUT kérést küldöm:

1
2
3
{
    "name": "Beviz Elek Jónás"
} 

El is mentem a kontaktot, de ebben az esetben, ha lekérdezem a kontaktot ugyanezen az URL-en csak GET kéréssel, akkor a következőt kapom:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
    "createdAt": null,
    "lastModifiedAt": "2020-10-11T15:17:08.916+00:00",
    "createdBy": null,
    "lastModifiedBy": "anonymousUser",
    "id": 3,
    "name": "Beviz Elek Jónás",
    "phone": null,
    "email": null,
    "address": null,
    "birthDate": null,
    "group": null
}

A @RequestBody Contact contact paraméterbe szépen leképződik egy kontakt, mivel azonban nincs megadva csak a név a többi field null lesz és ezt mentjük el az adatbázisba. Ez akkor tud működni úgy ahogy gondoltuk, ha a kliens a többi adatot is magával küldi.

Amennyiben szeretnénk olyan megoldást, amely nem nullázza ki a meglévő adatot, akkor ajánlatos lehet egy PATCH kérést elkészítése is:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@PatchMapping("/{id}")
public ResponseEntity<Contact> patchContact(@PathVariable Long id, @RequestBody Contact contact){

    Contact existing = contactService.getContactById(id);
    if(existing == null){
        return ResponseEntity.notFound().build();
    }

    if(contact.getAddress() != null){
        existing.setAddress(contact.getAddress());
    }

    if(contact.getBirthDate() != null){
        existing.setBirthDate(contact.getBirthDate());
    }

    if(contact.getEmail() != null){
        existing.setEmail(contact.getEmail());
    }

    if(contact.getGroup() != null){
        existing.setGroup(contact.getGroup());
    }

    if(contact.getName() != null){
        existing.setName(contact.getName());
    }

    if(contact.getPhone() != null){
        existing.setPhone(contact.getPhone());
    }

    return ResponseEntity.ok(contactService.saveContact(existing));
}

A fenti kéréssorozatot megismételve most már az elvártat kapjuk és csak a name field módosult (group eddig is null volt):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
    "createdAt": "2020-10-11T15:32:50.871+00:00",
    "lastModifiedAt": "2020-10-11T15:33:04.211+00:00",
    "createdBy": "anonymousUser",
    "lastModifiedBy": "anonymousUser",
    "id": 3,
    "name": "Beviz Elek Jónás",
    "phone": "+36 20 111 2222",
    "email": "elek@beviz.com",
    "address": "9999 Alsóbucsaröcsöge, Jajj utca 2.",
    "birthDate": "1977-12-24T00:00:00.000+00:00",
    "group": null
}

Feladat

Végül készítsük el a törléshez szükséges API végpontot is!

1
2
3
4
5
@DeleteMapping("/{id}")
@ResponseStatus(code=HttpStatus.NO_CONTENT)
public void deleteContact(@PathVariable Long id){
    contactService.deleteContact(id);
}

Egy DELETE kéréseket támogató API végpontot definiálunk, melyben töröljük a megadott id-val rendelkező elemet. Ha nincs ilyen id-jú elem, akkor a lényeget végülis elértük, mert így nincs ilyen azonosítójú elem az adatbázisban, így nem kezeljük külön, hogy mi történjen akkor, ha az elem nem létezik. A @ResponseStatus(code=HttpStatus.NO_CONTENT) a választ a 204-es kóddal látja el, mely szerint sikeres kérést hajtottunk végre, de nincs a válaszban semmi.

Hypermedia engedélyezése

Az eddig megírt API teljesen rendben van és nagyszerűen működik. Viszont a kliensnek teljes egészében ismernie kell az API felépítését. Például a kliensnek tudnia kell, hogy a http://localhost:8080/api/contact URL-en kérheti le az összes Contact-ot, illetve azt is, hogy ha ehhez hozzárakja az id-t, akkor lekérheti az adott Contact tulajdonságait. Ez mindaddig rendben is van ameddig nem változik az API.

Ebben a problémakörben tud segíteni a HATEOAS (Hypermedia as the Engine of Application State), amely önleíró API készítését teszi lehetővé úgy, hogy a visszaadott válaszban linkeket helyez el az adott erőforrások elérésére. Segítségével a kliens magabiztosabban navigálhat az API végpontjain anélkül, hogy pontosan tudnia kéne, hogy milyen végpontot is használ. Ehelyett az API által szolgáltatott (visszaadott) erőforrások közötti kapcsolatokra fókuszál.

Annak érdekében, hogy könnyebben megértsük a fent leírtakat, mutatunk egy példát:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
{
  "_embedded" : {
    "contacts" : [ {
      "createdAt" : null,
      "lastModifiedAt" : null,
      "createdBy" : null,
      "lastModifiedBy" : null,
      "name" : "Kiss Béla",
      "phone" : null,
      "email" : "kiss@bela.com",
      "address" : null,
      "birthDate" : null,
      "_links" : {
        "self" : {
          "href" : "http://localhost:8080/api-v2/contacts/1"
        },
        "contact" : {
          "href" : "http://localhost:8080/api-v2/contacts/1"
        },
        "group" : {
          "href" : "http://localhost:8080/api-v2/contacts/1/group"
        }
      }
    }, {
      "createdAt" : null,
      "lastModifiedAt" : null,
      "createdBy" : null,
      "lastModifiedBy" : null,
      "name" : "Nagy János",
      "phone" : null,
      "email" : "nagy@janos.com",
      "address" : null,
      "birthDate" : null,
      "_links" : {
        "self" : {
          "href" : "http://localhost:8080/api-v2/contacts/2"
        },
        "contact" : {
          "href" : "http://localhost:8080/api-v2/contacts/2"
        },
        "group" : {
          "href" : "http://localhost:8080/api-v2/contacts/2/group"
        }
      }
    } ]
  },
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/api-v2/contacts"
    },
    "profile" : {
      "href" : "http://localhost:8080/api-v2/profile/contacts"
    }
  },
  "page" : {
    "size" : 20,
    "totalElements" : 2,
    "totalPages" : 1,
    "number" : 0
  }
}

A HATEOAS maga a technika, melynek több megvalósítása is létezik. A fent látható formátum a HAL (Hypertext Application Language).

A listában minden elem rendelkezik egy _links property-vel, mely hyperlink-eket tárol a kliens számára, melyeken keresztül navigálhat. Például a Kiss Béla kontaktnál a self megmondja, hogy erről a kontaktról, hol tudunk lekérdezni infokat (ezt a típusával is biztosítja számunkra), továbbá láthatjuk, hogy a kontakthoz tartozó csoportot melyik url-en kérhetem le.

A fentiek azért nagyon jók, mert a kliensnek nem kell ismernie az API felépítését, nem kell URL-t konkatenálnia, egyszerűen a self linket követi, melyet a szerver állított elő a számára.

A Spring alkalmazásunkhoz a következő függőséget kell hozzáadnunk, ha szeretnénk használni a HATEOAS nyújtotta lehetőségeket:

1
2
3
4
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>

Azon felül, hogy ez így bekerül a classpath-ba, ad egy autokonfigurációt is, amely miatt csak a kontrollereket kell kicsit átínunk, hogy használni tudjuk a HATEOAS-t.

Ezután a contacts() metódust írjuk át a következőképpen:

1
2
3
4
@GetMapping
public CollectionModel<EntityModel<Contact>> contacts(){
    return CollectionModel.wrap(contactService.getContacts());
}

A fenti példában két új elemmel találkozhatunk: CollectionModel és EntityModel, melyek rendre egy kollekció és maga egy entitás HATEOAS megfelelői. A CollectionModel wrap metódusát meghívva becsomagolhatjuk a visszakapott kontaktok listáját egy CollectionModel<EntityModel<Contact>> típusba.

Próbáljuk is ki, hogy mit kapunk eredményül:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
{
    "_embedded": {
        "contactList": [
            {
                "createdAt": null,
                "lastModifiedAt": null,
                "createdBy": null,
                "lastModifiedBy": null,
                "id": 1,
                "name": "Kiss Béla",
                "phone": null,
                "email": "kiss@bela.com",
                "address": null,
                "birthDate": null,
                "group": null
            },
            ...
        ]
    }
}

Az eddigiekhez képest annyi a különbség, hogy az egész válasz, mely eddig egy lista volt, most belekerült egy objektumba, melynek van egy _embedded property-je, amin belül a contactList adja meg magukat a kontaktokat.

Adjunk hozzá linkeket is!

1
2
3
4
5
6
@GetMapping
public CollectionModel<EntityModel<Contact>> contacts(){
    CollectionModel<EntityModel<Contact>> contactsModel = CollectionModel.wrap(contactService.getContacts());
    contactsModel.add(Link.of("http://localhost:8080/api/contact", "self"));
    return contactsModel;
}
A CollectionModel-hez az add(...) metódussal tudunk egy új linket hozzáadni, melynek eredményeképpen a JSON végére bekerül a következő:

1
2
3
4
5
"_links": {
        "self": {
            "href": "http://localhost:8080/api/contact"
        }
    }

Ez az egyik kulcsa annak, hogy elkészíthessük a HATEOAS támogatással bíró válaszokat. A linkek létrehozásakor a relation type-ot elhagyhatjuk, ha self típusú linket szeretnénk létrehozni (mivel ez az alapértelmezett), így a fentit írhattuk volna így is:

1
contactsModel.add(Link.of("http://localhost:8080/api/contact"));

Kezdetnek nem is rossz, de ez csak a teljes lista elérését adja meg a válaszban, nincs link magukra az egyes kontaktokra, illetve a kontaktokhoz tartozó group-okhoz sem tartozik link. Mielőtt ezeket rendezzük, vegyük észre, hogy mennyire nem jó ötlet az URL-t hardcode-olni! Ennek leküzdésében az úgynevezett link builder-eket használhatjuk. A konkrét osztály, mely segít nekünk a WebMvcLinkBuilder, melynek használata a következőképpen nézhet ki:

1
2
3
4
5
6
7
@GetMapping
public CollectionModel<EntityModel<Contact>> contacts(){
    CollectionModel<EntityModel<Contact>> contactsModel = CollectionModel.wrap(contactService.getContacts());
    contactsModel.add(WebMvcLinkBuilder.linkTo(ContactRestController.class)
            .withRel("contact"));
    return contactsModel;
}

Ennek következtében a szerver felépíti az URL-t, nekünk csak a .withRel("contact")-t kell megadnunk. Az útvonal többi részét a linkTo() paraméterében megadott ContactRestController osztályra elhelyezett URL-ből számítja a rendszer.

Amennyiben további URL részeket szeretnénk megadni, akkor használhatjuk a slash("asd") metódust is, mely egy perrel hozzáfűzi az eddigi URL-hez a paraméterben megadottat. Ez akkor jól jöhet, ha nem a controller basepath-t használjuk csak, hanem az adott kezelő metóduson van még valamilyen URL megadva a mappingben. A slash helyett azonban használhatunk egy másik alternatívát, mely a kezelő metódus mapping-jét is figyelembe veszi:

1
2
3
contactsModel.add(
        linkTo(methodOn(ContactRestController.class).contacts()).withSelfRel()
);

Most hogy ez megvan lássuk, hogy hogyan lehet a kontaktokhoz és a csoportokhoz is hozzáadni a linkeket. A fenti tudásunk alapján csinálhatnánk azt, hogy egyszerűen végig iterálunk a lista összes elemén és hozzáadogatjuk a linkeket, ahogy azt már láttuk is. Ezt viszont elég sok helyen meg kellene ismételnünk így, ami nem túl hatékony.

Egy megoldás erre a RepresentationModel<T> használata, melyből származtatni kell az adott modellt.

Linkek


Utolsó frissítés: 2020-10-14 08:26:31