291 lines
9.8 KiB
Go
291 lines
9.8 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"git.umbrella.haus/ae/notatest/internal/data"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const (
|
|
titleMaxLength = 150
|
|
initVersionTitle = "Untitled"
|
|
initVersionContent = ""
|
|
)
|
|
|
|
// Note object context key for incoming requests (handled my middlewares). Only `*data.GetFullNoteRow`
|
|
// type objects should be stored behind this key for consistency.
|
|
type noteCtxKey struct{}
|
|
|
|
// Note version object context key for incoming requests (handled by middlewares). Only
|
|
// `*data.GetVersionRow` type objects should be stored behind this key for consistency.
|
|
type versionCtxKey struct{}
|
|
|
|
// Mockable database operations interface
|
|
type NoteStore interface {
|
|
CreateNote(ctx context.Context, userID uuid.UUID) (data.Note, error)
|
|
DeleteNote(ctx context.Context, arg data.DeleteNoteParams) error
|
|
GetFullNote(ctx context.Context, noteID uuid.UUID) (data.GetFullNoteRow, error)
|
|
ListNotes(ctx context.Context, arg data.ListNotesParams) ([]data.ListNotesRow, error)
|
|
CreateNoteVersion(ctx context.Context, arg data.CreateNoteVersionParams) error
|
|
GetVersion(ctx context.Context, arg data.GetVersionParams) (data.GetVersionRow, error)
|
|
GetVersionHistory(ctx context.Context, arg data.GetVersionHistoryParams) ([]data.GetVersionHistoryRow, error)
|
|
}
|
|
|
|
// Chi HTTP router for notes related CRUD actions.
|
|
type notesResource struct {
|
|
Config SvcConfig
|
|
Notes NoteStore
|
|
}
|
|
|
|
func (rs notesResource) Routes() chi.Router {
|
|
r := chi.NewRouter()
|
|
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(requireAccessToken(rs.Config.JWTSecret)) // JWT claims -> ctx.
|
|
|
|
r.Post("/", rs.Create) // POST /notes - create new note
|
|
r.Get("/", rs.ListMetadata) // GET /notes - get all notes (metadata + titles)
|
|
|
|
/*
|
|
Clients should utilize `rs.ListMetadata` to load index of user's available notes (e.g.
|
|
sidebar view), use `rs.GetFullNote` to get full notes individually, and if request the
|
|
versioning history with `rs.GetVersionHistory` if necessary (and similarly fetch each
|
|
version individually if the client wants to view them).
|
|
*/
|
|
|
|
r.Route(fmt.Sprintf("/{%s}", noteUUIDCtxParameter), func(r chi.Router) {
|
|
r.Use(uuidCtx(noteUUIDCtxParameter))
|
|
r.Use(noteCtx(rs.Notes)) // DB -> req. context (metadata + active version)
|
|
r.Get("/", rs.GetFullNote) // GET /notes/{id} - get note from context
|
|
r.Delete("/", rs.Delete) // DELETE /notes/{id} - delete note
|
|
r.Get("/versions", rs.GetVersionHistory) // GET /notes/{id}/versions - get full versioning history
|
|
r.Post("/versions", rs.CreateVersion) // POST /notes/{id}/versions - create new version
|
|
|
|
r.Route(fmt.Sprintf("/{%s}", versionUUIDCtxParameter), func(r chi.Router) {
|
|
r.Use(uuidCtx(versionUUIDCtxParameter))
|
|
r.Use(versionCtx(rs.Notes)) // DB -> req. context (scoped version)
|
|
r.Get("/", rs.GetFullVersion) // GET /notes/{id}/{id} - get specific version's contents
|
|
})
|
|
})
|
|
})
|
|
|
|
return r
|
|
}
|
|
|
|
// Handler for new note creation. Creates the parent metadata object (`notes` table) and an initial
|
|
// placeholder content version (`note_versions` table), and returns the placeholder contents to the
|
|
// caller in the HTTP response.
|
|
func (rs *notesResource) Create(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := r.Context().Value(userCtxKey{}).(*userClaims)
|
|
if !ok {
|
|
respondError(w, http.StatusUnauthorized, "Unauthorized")
|
|
return
|
|
}
|
|
|
|
userID, err := uuid.Parse(user.Subject)
|
|
if err != nil {
|
|
respondError(w, http.StatusUnauthorized, "Invalid user ID")
|
|
return
|
|
}
|
|
|
|
// Metadata object (parent)
|
|
note, err := rs.Notes.CreateNote(r.Context(), userID)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Failed to create note")
|
|
return
|
|
}
|
|
|
|
// Initial (empty) placeholder version of the contents
|
|
err = rs.Notes.CreateNoteVersion(r.Context(), data.CreateNoteVersionParams{
|
|
NoteID: note.ID,
|
|
Title: initVersionTitle,
|
|
Content: initVersionContent,
|
|
ContentHash: sha1ContentHash(initVersionTitle, initVersionContent),
|
|
})
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Failed to create initial version")
|
|
return
|
|
}
|
|
|
|
// Placeholder contents are decided server-side, so we need to inform the client of them via a
|
|
// one-time-use DTO
|
|
respondJSON(w, http.StatusCreated, map[string]string{
|
|
"title": initVersionTitle,
|
|
"content": initVersionContent,
|
|
})
|
|
}
|
|
|
|
// Handler for listing the metadata of all user's available notes. This metadata contains IDs of
|
|
// the note and its owner, its title, and the time it was last updated.
|
|
func (rs *notesResource) ListMetadata(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := r.Context().Value(userCtxKey{}).(*userClaims)
|
|
if !ok {
|
|
respondError(w, http.StatusUnauthorized, "Unauthorized")
|
|
return
|
|
}
|
|
|
|
userID, err := uuid.Parse(user.Subject)
|
|
if err != nil {
|
|
respondError(w, http.StatusUnauthorized, "Invalid user ID")
|
|
return
|
|
}
|
|
|
|
limit, offset := getPaginationParams(r)
|
|
notes, err := rs.Notes.ListNotes(r.Context(), data.ListNotesParams{
|
|
UserID: userID,
|
|
Limit: limit,
|
|
Offset: offset,
|
|
})
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Failed to retrieve notes")
|
|
return
|
|
}
|
|
|
|
for _, note := range notes {
|
|
if userID != note.OwnerID {
|
|
respondError(w, http.StatusForbidden, "Forbidden")
|
|
return
|
|
}
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, notes)
|
|
}
|
|
|
|
// Handler for returning the currently scoped (included to the request's context by a middleware)
|
|
// full note object.
|
|
func (rs *notesResource) GetFullNote(w http.ResponseWriter, r *http.Request) {
|
|
fullNote, ok := r.Context().Value(noteCtxKey{}).(*data.GetFullNoteRow)
|
|
if !ok {
|
|
respondError(w, http.StatusNotFound, "Note not found")
|
|
return
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, fullNote)
|
|
}
|
|
|
|
// Handler for hard deelting the currently scoped note (including its versions via database cascade).
|
|
func (rs *notesResource) Delete(w http.ResponseWriter, r *http.Request) {
|
|
fullNote, ok := r.Context().Value(noteCtxKey{}).(*data.GetFullNoteRow)
|
|
if !ok {
|
|
respondError(w, http.StatusNotFound, "Note not found")
|
|
return
|
|
}
|
|
|
|
user, ok := r.Context().Value(userCtxKey{}).(*userClaims)
|
|
if !ok {
|
|
respondError(w, http.StatusUnauthorized, "Unauthorized")
|
|
return
|
|
}
|
|
|
|
userID, err := uuid.Parse(user.Subject)
|
|
if err != nil {
|
|
respondError(w, http.StatusUnauthorized, "Invalid user ID")
|
|
return
|
|
}
|
|
|
|
err = rs.Notes.DeleteNote(r.Context(), data.DeleteNoteParams{
|
|
ID: fullNote.NoteID,
|
|
UserID: userID, // NOTE: using `fullNote.userID` here'd be insecure
|
|
})
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Failed to delete note")
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// Handler for listing the currently scoped note's version history. If pagination parameters
|
|
// (`limit` and `offset`) aren't defined, limit of 50 versions (with offset 0) will be returned.
|
|
func (rs *notesResource) GetVersionHistory(w http.ResponseWriter, r *http.Request) {
|
|
fullNote, ok := r.Context().Value(noteCtxKey{}).(*data.GetFullNoteRow)
|
|
if !ok {
|
|
respondError(w, http.StatusNotFound, "Note not found")
|
|
return
|
|
}
|
|
|
|
limit, offset := getPaginationParams(r)
|
|
versions, err := rs.Notes.GetVersionHistory(r.Context(), data.GetVersionHistoryParams{
|
|
NoteID: fullNote.NoteID,
|
|
Limit: limit,
|
|
Offset: offset,
|
|
})
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Failed to get version history")
|
|
return
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, versions)
|
|
}
|
|
|
|
// Handler for creating a new content version for the currently scoped note. Will check the incoming
|
|
// JSON object's integrity and perform a de-duplication check for identical versions stored in the
|
|
// database (SHA-1 hash of version contents). If a duplicate version is found, it'll be placed as the
|
|
// active version by swapping its version number to HEAD+1.
|
|
func (rs *notesResource) CreateVersion(w http.ResponseWriter, r *http.Request) {
|
|
fullNote, ok := r.Context().Value(noteCtxKey{}).(*data.GetFullNoteRow)
|
|
if !ok {
|
|
respondError(w, http.StatusNotFound, "Note not found")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Title *string `json:"title"`
|
|
Content *string `json:"content"`
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Title == nil || req.Content == nil {
|
|
respondError(w, http.StatusBadRequest, "Invalid request body")
|
|
return
|
|
}
|
|
|
|
// Extra check for frontend readability reasons (max. length isn't specifically limited in the database)
|
|
if len(*req.Title) > titleMaxLength {
|
|
respondError(w, http.StatusBadRequest, fmt.Sprintf("Title must be shorter than %d characters", titleMaxLength))
|
|
return
|
|
}
|
|
|
|
/*
|
|
The SQL query handles de-duplication checks and "intelligent" versioning increments, so we
|
|
don't have to worry about them here (`latest_version` = highest version number that exists
|
|
in this note's context; `current_version` = note's active content version):
|
|
|
|
- New version's contents are a duplicate of a historical version:
|
|
- Don't increment `latest_version`
|
|
- Sync `current_version` with the `version_number` of the duplicate version
|
|
- New version's contents are unique:
|
|
- Increment `latest_version`
|
|
- Sync `current_version` with `latest_version`
|
|
*/
|
|
|
|
err := rs.Notes.CreateNoteVersion(r.Context(), data.CreateNoteVersionParams{
|
|
NoteID: fullNote.NoteID,
|
|
Title: *req.Title,
|
|
Content: *req.Content,
|
|
ContentHash: sha1ContentHash(*req.Title, *req.Content),
|
|
})
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Failed to create note version")
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// Handler for returning full data of the currently scoped note version. Identical to the beginning
|
|
// of the `RollbackNoteVersion` handler.
|
|
func (rs *notesResource) GetFullVersion(w http.ResponseWriter, r *http.Request) {
|
|
fullVersion, ok := r.Context().Value(noteCtxKey{}).(*data.GetVersionRow)
|
|
if !ok {
|
|
respondError(w, http.StatusNotFound, "Note not found")
|
|
return
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, fullVersion)
|
|
}
|