feat: add notes api

issue #46
This commit is contained in:
Ricardo Campos
2024-09-20 13:24:11 -03:00
parent 197818d927
commit 3f3ca0c59b
14 changed files with 564 additions and 0 deletions
@@ -0,0 +1,185 @@
package br.com.tasknoteapp.java_api.controller;
import br.com.tasknoteapp.java_api.entity.NoteEntity;
import br.com.tasknoteapp.java_api.request.NotePatchRequest;
import br.com.tasknoteapp.java_api.request.NoteRequest;
import br.com.tasknoteapp.java_api.response.NoteResponse;
import br.com.tasknoteapp.java_api.service.NoteService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
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.tags.Tag;
import jakarta.validation.Valid;
import java.util.List;
import lombok.AllArgsConstructor;
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.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
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("/rest/notes")
@AllArgsConstructor
@Tag(name = "Notes", description = "Notes resources to handle stored notes.")
public class NoteController {
private final NoteService noteService;
/**
* Get all notes.
*
* @return List of NoteResponse with all found notes and its urls, if any.
*/
@GetMapping
@Operation(
summary = "Get all notes",
description = "Get all notes for the current user and its urls, if any",
responses = {
@ApiResponse(
responseCode = "200",
description = "Notes successfully retrieved",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = NoteResponse.class, type = "array"))),
@ApiResponse(
responseCode = "403",
description = "Forbidden. Access Denied",
content = @Content(schema = @Schema(implementation = Void.class)))
})
public List<NoteResponse> getAllNotes() {
return noteService.getAllNotes();
}
/**
* Patch a note.
*
* @param id The note id to be patched.
* @param noteRequest Note data to be patched, including optionally its urls.
* @return NoteResponse containing data that was updated.
* @throws NoteNotFoundException when note not found.
*/
@PatchMapping("/{id}")
@Operation(
summary = "Patch a note",
description = "Patch a note and all its urls. Option to patch only the urls.",
responses = {
@ApiResponse(
responseCode = "200",
description = "Note successfully patched",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = NoteResponse.class))),
@ApiResponse(
responseCode = "403",
description = "Forbidden. Access Denied",
content = @Content(schema = @Schema(implementation = Void.class))),
@ApiResponse(
responseCode = "404",
description = "Note not found",
content = @Content(schema = @Schema(implementation = Void.class)))
})
public ResponseEntity<NoteResponse> putNote(
@Parameter(
name = "id",
in = ParameterIn.PATH,
description = "Note id to be patched.",
required = true,
schema = @Schema(type = "integer", format = "int64"))
@PathVariable
Long id,
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "Note data to be patched, including optionally its urls.",
required = true)
@RequestBody
@Valid
NotePatchRequest noteRequest) {
return ResponseEntity.ok(noteService.patchNote(id, noteRequest));
}
/**
* Create a note.
*
* @param noteRequest Note data to be created, including optionally its urls. Following RESTful
* API pattern from https://restfulapi.net/rest-put-vs-post/.
* @return NoteResponse containing data that was created.
*/
@PostMapping
@Operation(
summary = "Create a note",
description = "Create a note and all its urls.",
responses = {
@ApiResponse(
responseCode = "201",
description = "Note successfully crated.",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = NoteResponse.class))),
@ApiResponse(
responseCode = "400",
description = "Wrong or missing information",
content = @Content(schema = @Schema(implementation = Void.class))),
@ApiResponse(
responseCode = "403",
description = "Forbidden. Access Denied",
content = @Content(schema = @Schema(implementation = Void.class))),
})
public ResponseEntity<NoteResponse> postNotes(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "Note data to be created, including optionally its urls.",
required = true)
@RequestBody
@Valid
NoteRequest noteRequest) {
NoteEntity createdNote = noteService.createNote(noteRequest);
return ResponseEntity.status(HttpStatus.CREATED).body(NoteResponse.fromEntity(createdNote));
}
/**
* Delete a note given its ID.
*
* @param id Note identification.
* @throws NoteNotFoundException when note not found.
*/
@DeleteMapping("/{id}")
@Operation(
summary = "Delete a note",
description = "Delete a note given its ID.",
responses = {
@ApiResponse(
responseCode = "204",
description = "Note successfully deleted",
content = @Content(schema = @Schema(implementation = Void.class))),
@ApiResponse(
responseCode = "403",
description = "Forbidden. Access Denied",
content = @Content(schema = @Schema(implementation = Void.class))),
@ApiResponse(
responseCode = "404",
description = "Note not found",
content = @Content(schema = @Schema(implementation = Void.class)))
})
public ResponseEntity<Void> deleteNotes(
@Parameter(
name = "id",
in = ParameterIn.PATH,
description = "Note id to be patched.",
required = true,
schema = @Schema(type = "integer", format = "int64"))
@PathVariable
Long id) {
noteService.deleteNote(id);
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}
}
@@ -0,0 +1,36 @@
package br.com.tasknoteapp.java_api.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import java.util.List;
@Data
@Entity
@Table(name = "notes")
@ToString
@EqualsAndHashCode
public class NoteEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String description;
@JoinColumn(name = "user_id", referencedColumnName = "id", nullable = false, updatable = false)
@ManyToOne(fetch = FetchType.LAZY)
private UserEntity user;
@OneToMany(mappedBy = "note", fetch = FetchType.LAZY)
private List<NoteUrlEntity> urls;
}
@@ -0,0 +1,31 @@
package br.com.tasknoteapp.java_api.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
@Data
@Entity
@ToString
@Table(name = "note_urls")
@EqualsAndHashCode
public class NoteUrlEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String url;
@JoinColumn(name = "note_id", referencedColumnName = "id", nullable = false, updatable = false)
@ManyToOne(fetch = FetchType.LAZY)
private NoteEntity note;
}
@@ -0,0 +1,14 @@
package br.com.tasknoteapp.java_api.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.server.ResponseStatusException;
/** This class represents a Note Not Found request. */
@ResponseStatus(code = HttpStatus.NOT_FOUND)
public class NoteNotFoundException extends ResponseStatusException {
public NoteNotFoundException() {
super(HttpStatus.NOT_FOUND, "Task not found");
}
}
@@ -0,0 +1,14 @@
package br.com.tasknoteapp.java_api.repository;
import br.com.tasknoteapp.java_api.entity.NoteEntity;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
public interface NoteRepository extends JpaRepository<NoteEntity, Long> {
List<NoteEntity> findAllByUser_id(Long userId);
@Query("select n from NoteEntity n where upper(n.description) like %?1% and n.user.id = ?2")
List<NoteEntity> findAllBySearchTerm(String searchTerm, Long userId);
}
@@ -0,0 +1,10 @@
package br.com.tasknoteapp.java_api.repository;
import br.com.tasknoteapp.java_api.entity.NoteUrlEntity;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface NoteUrlRepository extends JpaRepository<NoteUrlEntity, Long> {
void deleteAllByIdIn(List<Long> ids);
}
@@ -0,0 +1,10 @@
package br.com.tasknoteapp.java_api.request;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
/** This record represents a note patch payload. */
@Schema(description = "Note patch payload.")
public record NotePatchRequest(
@Schema(description = "Note description. Optional.") String description,
@Schema(description = "Note urls. Optional.") List<NoteUrlPatchRequest> urls) {}
@@ -0,0 +1,11 @@
package br.com.tasknoteapp.java_api.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import java.util.List;
/** This record represents a note request to be created. */
@Schema(description = "Note request to be created.")
public record NoteRequest(
@Schema(description = "Note description.") @NotNull String description,
@Schema(description = "Note urls. Optional.") List<String> urls) {}
@@ -0,0 +1,3 @@
package br.com.tasknoteapp.java_api.request;
public record NoteUrlPatchRequest(Long id, String url) {}
@@ -0,0 +1,38 @@
package br.com.tasknoteapp.java_api.response;
import br.com.tasknoteapp.java_api.entity.NoteEntity;
import br.com.tasknoteapp.java_api.entity.NoteUrlEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/** This record represents a task and its urls object to be returned. */
@Schema(description = "This record represents a task and its urls object to be returned.")
public record NoteResponse(
@Schema(description = "The id of the note", example = "1") Long id,
@Schema(description = "The description of the note", example = "Note 1") String description,
@Schema(description = "The urls of the task, zero, one or more.", example = "[]")
List<NoteUrlResponse> urls) {
/**
* Creates a NoteResponse given a NoteEntity and its Urls.
*
* @param entity The NoteEntity source data.
* @return NoteResponse instance with all note data and urls, if any.
*/
public static NoteResponse fromEntity(NoteEntity entity) {
List<NoteUrlEntity> urls = entity.getUrls();
List<NoteUrlResponse> urlsResponse = new ArrayList<>();
if (Objects.isNull(urls)) {
urls = List.of();
} else {
for (NoteUrlEntity url : urls) {
NoteUrlResponse urlResponse = new NoteUrlResponse(url.getId(), url.getUrl());
urlsResponse.add(urlResponse);
}
}
return new NoteResponse(entity.getId(), entity.getDescription(), urlsResponse);
}
}
@@ -0,0 +1,9 @@
package br.com.tasknoteapp.java_api.response;
import io.swagger.v3.oas.annotations.media.Schema;
/** This record represents a note url object. */
@Schema(description = "This record represents a note url object.")
public record NoteUrlResponse(
@Schema(description = "Note url id", example = "1") Long id,
@Schema(description = "Note url link", example = "http://duckduckgo.com") String url) {}
@@ -0,0 +1,20 @@
package br.com.tasknoteapp.java_api.service;
import br.com.tasknoteapp.java_api.entity.NoteEntity;
import br.com.tasknoteapp.java_api.request.NotePatchRequest;
import br.com.tasknoteapp.java_api.request.NoteRequest;
import br.com.tasknoteapp.java_api.response.NoteResponse;
import java.util.List;
public interface NoteService {
public List<NoteResponse> getAllNotes();
public NoteEntity createNote(NoteRequest noteRequest);
public NoteResponse patchNote(Long taskId, NotePatchRequest taskRequest);
public void deleteNote(Long noteId);
public List<NoteResponse> searchNotes(String searchTerm);
}
@@ -0,0 +1,168 @@
package br.com.tasknoteapp.java_api.service.impl;
import br.com.tasknoteapp.java_api.entity.NoteEntity;
import br.com.tasknoteapp.java_api.entity.NoteUrlEntity;
import br.com.tasknoteapp.java_api.entity.UserEntity;
import br.com.tasknoteapp.java_api.exception.NoteNotFoundException;
import br.com.tasknoteapp.java_api.repository.NoteRepository;
import br.com.tasknoteapp.java_api.repository.NoteUrlRepository;
import br.com.tasknoteapp.java_api.request.NotePatchRequest;
import br.com.tasknoteapp.java_api.request.NoteRequest;
import br.com.tasknoteapp.java_api.request.NoteUrlPatchRequest;
import br.com.tasknoteapp.java_api.response.NoteResponse;
import br.com.tasknoteapp.java_api.service.AuthService;
import br.com.tasknoteapp.java_api.service.NoteService;
import br.com.tasknoteapp.java_api.util.AuthUtil;
import jakarta.transaction.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@AllArgsConstructor
public class NoteServiceImpl implements NoteService {
private final NoteRepository noteRepository;
private final AuthService authService;
private final AuthUtil authUtil;
private final NoteUrlRepository noteUrlRepository;
@Override
public List<NoteResponse> getAllNotes() {
UserEntity user = getCurrentUser();
log.info("Get all notes to user {}", user.getId());
List<NoteEntity> notes = noteRepository.findAllByUser_id(user.getId());
log.info("{} notes found!", notes.size());
return notes.stream().map(NoteResponse::fromEntity).toList();
}
@Override
public NoteEntity createNote(NoteRequest noteRequest) {
UserEntity user = getCurrentUser();
log.info("Creating note to user {}", user.getId());
NoteEntity note = new NoteEntity();
note.setDescription(noteRequest.description());
note.setUser(user);
NoteEntity created = noteRepository.save(note);
if (!Objects.isNull(noteRequest.urls()) && !noteRequest.urls().isEmpty()) {
List<NoteUrlEntity> urls = saveUrls(note, noteRequest.urls());
note.setUrls(urls);
}
log.info("Note created! Id {}", created.getId());
return created;
}
@Override
@Transactional
public NoteResponse patchNote(Long noteId, NotePatchRequest patch) {
UserEntity user = getCurrentUser();
log.info("Patching task {} to user {}", noteId, user.getId());
Optional<NoteEntity> note = noteRepository.findById(noteId);
if (note.isEmpty()) {
throw new NoteNotFoundException();
}
NoteEntity noteEntity = note.get();
if (!Objects.isNull(patch.description()) && !patch.description().isBlank()) {
noteEntity.setDescription(patch.description());
}
if (!Objects.isNull(patch.urls())) {
List<Long> urlIds =
patch.urls().stream().filter(p -> p.id() != null).map(NoteUrlPatchRequest::id).toList();
if (!urlIds.isEmpty()) {
noteUrlRepository.deleteAllByIdIn(urlIds);
log.info("Deleted {} urls from task {}", urlIds.size(), noteId);
} else {
log.info("No urls to patch for task {}", noteId);
}
List<String> urlsList =
patch.urls().stream().filter(p -> p.id() == null).map(NoteUrlPatchRequest::url).toList();
List<NoteUrlEntity> urls = saveUrls(noteEntity, urlsList);
noteEntity.setUrls(urls);
}
NoteEntity patchedNote = noteRepository.save(noteEntity);
log.info("Note patched! Id {}", patchedNote.getId());
return NoteResponse.fromEntity(patchedNote);
}
@Override
@Transactional
public void deleteNote(Long noteId) {
UserEntity user = getCurrentUser();
log.info("Deleting note {} to user {}", noteId, user.getId());
Optional<NoteEntity> note = noteRepository.findById(noteId);
if (note.isEmpty()) {
throw new NoteNotFoundException();
}
List<NoteUrlEntity> urls = note.get().getUrls();
if (!urls.isEmpty()) {
noteUrlRepository.deleteAll(urls);
log.info("Deleted {} urls from task {}", urls.size(), noteId);
} else {
log.info("No urls to delete for task {}", noteId);
}
noteRepository.delete(note.get());
log.info("Note deleted! Id {}", noteId);
}
@Override
public List<NoteResponse> searchNotes(String searchTerm) {
UserEntity user = getCurrentUser();
log.info("Searching notes to user {}", user.getId());
List<NoteEntity> notes = noteRepository.findAllBySearchTerm(searchTerm, user.getId());
log.info("{} tasks found!", notes.size());
return notes.stream().map(NoteResponse::fromEntity).toList();
}
private UserEntity getCurrentUser() {
Optional<String> currentUserEmail = authUtil.getCurrentUserEmail();
String email = currentUserEmail.orElseThrow();
return authService.findByEmail(email).orElseThrow();
}
private List<NoteUrlEntity> saveUrls(NoteEntity noteEntity, List<String> urls) {
List<NoteUrlEntity> tasksUrl = new ArrayList<>();
for (String url : urls) {
NoteUrlEntity taskUrl = new NoteUrlEntity();
taskUrl.setUrl(url);
taskUrl.setNote(noteEntity);
tasksUrl.add(taskUrl);
}
List<NoteUrlEntity> savedUrls = noteUrlRepository.saveAll(tasksUrl);
log.info("Saved {} urls to note {}", savedUrls.size(), noteEntity.getId());
return savedUrls;
}
}
@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS tasknote.notes (
id SERIAL,
user_id INTEGER NOT NULL,
description VARCHAR(300) NOT NULL,
CONSTRAINT notes_pk PRIMARY KEY (id),
CONSTRAINT notes_user_id_fk FOREIGN KEY (user_id) REFERENCES tasknote.users (id)
);
CREATE TABLE IF NOT EXISTS tasknote.note_urls (
id SERIAL,
note_id INTEGER NOT NULL,
url VARCHAR(200) NOT NULL,
CONSTRAINT note_url_pk PRIMARY KEY (id),
CONSTRAINT note_url_note_id_fk FOREIGN KEY (note_id) REFERENCES tasknote.notes (id)
);