REST API sa Spring Boot-om: aplikacija Zadaci

Lekcija 2 od 3 · Spring Boot

REST API je skup adresa preko kojih druga aplikacija čita i menja podatke na serveru. U ovoj lekciji praviš API za listu školskih zadataka (dodavanje, pregled, označavanje kao urađeno, brisanje), deliš kod na kontroler i servis i praviš jednostavnu stranu koja koristi API.

Pre nego što počneš: potrebno predznanje – Spring Boot – prva aplikacija, izuzeci.
Kako da učiš: radi u svom projektu korak po korak i posle svakog koraka pokreni server i proveri rezultat u pregledaču. Primeri su za Spring Boot 3 ili 4 i Javu 17+; svaki deo koda upiši u fajl naveden u komentaru na vrhu primera.

REST: resursi i HTTP metode

U REST stilu podaci su resursi sa svojim adresama (/api/zadaci, /api/zadaci/3), a vrsta akcije određuje se HTTP metodom:

Metoda i adresaAkcijaOdgovor (status)
GET /api/zadacilista svih zadataka200 OK + JSON niz
GET /api/zadaci/3jedan zadatak200 OK, ili 404 Not Found
POST /api/zadacinovi zadatak (podaci u telu zahteva, JSON)201 Created
PUT /api/zadaci/3izmena zadatka200 OK, ili 404
DELETE /api/zadaci/3brisanje204 No Content, ili 404

Status je trocifren broj koji kaže kako je zahtev prošao: 2xx – uspeh, 4xx – greška klijenta (pogrešan zahtev, ne postoji), 5xx – greška na serveru.

Model: klasa Zadatak

// src/main/java/rs/svetprogramiranja/zadaci/Zadatak.java
package rs.svetprogramiranja.zadaci;

import java.time.LocalDate;

public class Zadatak {
    private Long id;
    private String naslov;
    private String predmet;
    private LocalDate rok;          // u JSON-u: "2026-11-20"
    private boolean uradjen;

    public Zadatak() { }            // prazan konstruktor – potreban za pretvaranje iz JSON-a

    public Zadatak(String naslov, String predmet, LocalDate rok) {
        this.naslov = naslov;
        this.predmet = predmet;
        this.rok = rok;
    }

    // geteri i seteri – preko njih Spring čita i upisuje polja u JSON
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getNaslov() { return naslov; }
    public void setNaslov(String naslov) { this.naslov = naslov; }
    public String getPredmet() { return predmet; }
    public void setPredmet(String predmet) { this.predmet = predmet; }
    public LocalDate getRok() { return rok; }
    public void setRok(LocalDate rok) { this.rok = rok; }
    public boolean isUradjen() { return uradjen; }
    public void setUradjen(boolean uradjen) { this.uradjen = uradjen; }
}

Ovde je obična klasa (a ne record) jer će se polja menjati, a u sledećoj lekciji ista klasa postaje tabela u bazi.

Servis: logika i podaci

Kontroler treba samo da primi zahtev i vrati odgovor. Sam posao (čuvanje, traženje, pravila) radi servis – klasa označena sa @Service. Za sada se zadaci čuvaju u mapi u memoriji (nestaju kad se server ugasi):

// src/main/java/rs/svetprogramiranja/zadaci/ZadatakService.java
package rs.svetprogramiranja.zadaci;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Service;

@Service
public class ZadatakService {
    // server istovremeno obrađuje više zahteva – zato mapa i brojač bezbedni za više niti
    private final Map<Long, Zadatak> zadaci = new ConcurrentHashMap<>();
    private final AtomicLong sledeciId = new AtomicLong(1);

    public ZadatakService() {
        // nekoliko primera da lista ne bude prazna
        sacuvaj(new Zadatak("Uraditi zadatke 1-5", "Matematika", LocalDate.now().plusDays(2)));
        sacuvaj(new Zadatak("Procitati lektiru", "Srpski", LocalDate.now().plusDays(7)));
    }

    private Zadatak sacuvaj(Zadatak z) {
        z.setId(sledeciId.getAndIncrement());       // novi zadatak dobija sledeći broj
        zadaci.put(z.getId(), z);
        return z;
    }

    public List<Zadatak> svi() {
        List<Zadatak> lista = new ArrayList<>(zadaci.values());
        lista.sort(Comparator.comparing(Zadatak::getRok));       // najbliži rok prvi
        return lista;
    }

    public Optional<Zadatak> nadji(long id) {
        return Optional.ofNullable(zadaci.get(id));
    }

    public Zadatak dodaj(Zadatak z) {
        return sacuvaj(z);
    }

    public Optional<Zadatak> izmeni(long id, Zadatak novi) {
        Zadatak z = zadaci.get(id);
        if (z == null) return Optional.empty();
        z.setNaslov(novi.getNaslov());
        z.setPredmet(novi.getPredmet());
        z.setRok(novi.getRok());
        z.setUradjen(novi.isUradjen());
        return Optional.of(z);
    }

    public boolean obrisi(long id) {
        return zadaci.remove(id) != null;
    }
}

Kontroler i ubacivanje zavisnosti

Kontroler dobija servis kroz konstruktor – to je ubacivanje zavisnosti (dependency injection): Spring sam pravi jedan objekat servisa i predaje ga svima kojima treba. @RequestMapping("/api/zadaci") na klasi zadaje početak adrese za sve metode.

// src/main/java/rs/svetprogramiranja/zadaci/ZadatakController.java
package rs.svetprogramiranja.zadaci;

import java.util.List;
import org.springframework.http.HttpStatus;
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.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.RestController;

@RestController
@RequestMapping("/api/zadaci")
public class ZadatakController {
    private final ZadatakService servis;

    public ZadatakController(ZadatakService servis) {   // Spring ubacuje servis
        this.servis = servis;
    }

    @GetMapping
    public List<Zadatak> svi() {
        return servis.svi();
    }

    @GetMapping("/{id}")
    public ResponseEntity<Zadatak> jedan(@PathVariable long id) {
        return servis.nadji(id)
                .map(ResponseEntity::ok)                    // 200 + zadatak
                .orElse(ResponseEntity.notFound().build()); // 404
    }

    @PostMapping
    public ResponseEntity<Zadatak> dodaj(@RequestBody Zadatak z) {   // JSON iz tela zahteva -> objekat
        if (z.getNaslov() == null || z.getNaslov().isBlank())
            return ResponseEntity.badRequest().build();     // 400
        return ResponseEntity.status(HttpStatus.CREATED).body(servis.dodaj(z));   // 201
    }

    @PutMapping("/{id}")
    public ResponseEntity<Zadatak> izmeni(@PathVariable long id, @RequestBody Zadatak z) {
        return servis.izmeni(id, z)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> obrisi(@PathVariable long id) {
        return servis.obrisi(id)
                ? ResponseEntity.noContent().build()        // 204
                : ResponseEntity.notFound().build();
    }
}

ResponseEntity omogućava da se uz podatke vrati i tačan status. Metode map i orElse su iste kao kod Optional-a u lekciji Lambda izrazi i Stream API.

Isprobavanje API-ja: curl

GET zahteve možeš da otvoriš u pregledaču, ali POST, PUT i DELETE šalje program. Najjednostavniji je curl, koji postoji i u Windows-u (komande za cmd; navodnici unutar JSON-a pišu se kao \"):

curl http://localhost:8080/api/zadaci

curl -X POST http://localhost:8080/api/zadaci -H "Content-Type: application/json" -d "{\"naslov\":\"Referat o Tesli\",\"predmet\":\"Fizika\",\"rok\":\"2026-11-25\"}"

curl -X PUT http://localhost:8080/api/zadaci/3 -H "Content-Type: application/json" -d "{\"naslov\":\"Referat o Tesli\",\"predmet\":\"Fizika\",\"rok\":\"2026-11-25\",\"uradjen\":true}"

curl -i -X DELETE http://localhost:8080/api/zadaci/1

curl -i http://localhost:8080/api/zadaci/99

Opcija -i prikazuje i status odgovora (npr. HTTP/1.1 404). Udobniji su grafički alati kao Postman ili Bruno, ili ugrađeni HTTP klijent u IntelliJ IDEA (fajl .http).

Strana koja koristi API

Strana u static/index.html čita listu i šalje nove zadatke pomoću fetch:

<!-- src/main/resources/static/index.html -->
<!DOCTYPE html>
<html lang="sr">
<head>
  <meta charset="UTF-8">
  <title>Zadaci</title>
  <style>
    body { font-family: sans-serif; max-width: 640px; margin: 2rem auto; }
    li.uradjen span { text-decoration: line-through; color: gray; }
    li button { margin-left: .5rem; }
  </style>
</head>
<body>
  <h1>Moji zadaci</h1>
  <form id="forma">
    <input id="naslov" placeholder="Zadatak" required>
    <input id="predmet" placeholder="Predmet">
    <input id="rok" type="date" required>
    <button>Dodaj</button>
  </form>
  <ul id="lista"></ul>

  <script>
    const API = "/api/zadaci";

    async function ucitaj() {
      const zadaci = await (await fetch(API)).json();
      const ul = document.getElementById("lista");
      ul.innerHTML = "";
      for (const z of zadaci) {
        const li = document.createElement("li");
        if (z.uradjen) li.className = "uradjen";
        const tekst = document.createElement("span");
        tekst.textContent = `${z.rok} – ${z.predmet}: ${z.naslov}`;   // textContent: bez ubacivanja HTML-a
        const gotovo = document.createElement("button");
        gotovo.textContent = z.uradjen ? "Vrati" : "Urađeno";
        gotovo.onclick = async () => {
          z.uradjen = !z.uradjen;
          await fetch(`${API}/${z.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(z) });
          ucitaj();
        };
        const brisi = document.createElement("button");
        brisi.textContent = "Obriši";
        brisi.onclick = async () => { await fetch(`${API}/${z.id}`, { method: "DELETE" }); ucitaj(); };
        li.append(tekst, gotovo, brisi);
        ul.appendChild(li);
      }
    }

    document.getElementById("forma").onsubmit = async (e) => {
      e.preventDefault();                           // ne šalji formu na stari način
      const novi = {
        naslov: document.getElementById("naslov").value,
        predmet: document.getElementById("predmet").value,
        rok: document.getElementById("rok").value
      };
      await fetch(API, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(novi) });
      e.target.reset();
      ucitaj();
    };

    ucitaj();
  </script>
</body>
</html>

Zadaci za sada nestaju kada se server ponovo pokrene. U sledećoj lekciji se čuvaju u bazi podataka.

Zadaci za vežbu

Za svaki zadatak postoji primer, uputstvo i rešenje.

ZADATAK 01Težina: srednje

Filtriranje i statistika

Dopuni API:

  • GET /api/zadaci?predmet=Fizika vraća samo zadatke iz tog predmeta (bez obzira na velika i mala slova); bez parametra vraća sve;
  • GET /api/zadaci/statistika vraća JSON oblika {"ukupno":5,"uradjeno":2,"kasni":1}, gde su „kasni“ neurađeni zadaci kojima je rok prošao.
Česte greške i saveti
  • Ruta /statistika i ruta /{id} se ne sudaraju: Spring prvo bira tačnu putanju, pa tek onda šablon sa promenljivom.
  • Za POST i PUT u zahtevu mora biti zaglavlje Content-Type: application/json, inače server vraća grešku 415.
  • Klasa koja se prima iz JSON-a mora imati prazan konstruktor i setere (ili biti record).
  • Ne prikazuj tekst iz baze ili od korisnika sa innerHTML – koristi textContent, da neko ne bi ubacio svoj HTML ili JavaScript.

Proveri znanje

Otvori kviz

REST API

1. Koja HTTP metoda se koristi za dodavanje novog zadatka?




2. Šta znači status 404?




3. Čemu služi @RequestBody?




4. Kako kontroler dobija objekat servisa?