all repos

onasty @ 04083be

a one-time notes service
24 files changed, 734 insertions(+), 23 deletions(-)
feat: impl the core of the app, notes (#5)

* feat(notes): set up boiletplade files

* feat(models): add note

* test(models): test validation of note

* chore(migrations): add notes

* feat(notes): create

* fix(e2e): app could be able to build here too

* feat(note): get by slug

* refactor(notesrv): use more descriptive name

* feat: add user_id to note

* fix(http): somehow it removes redicect

* go mod tidy

* chore(taskfile): add one more task for migrations

* fix(notesrv): handle if note has to be burnt

* remove unused lint ignore

* test(e2e): note create, unauthorized

* refactor(e2e): fix linter

* refactor(http): reorganize methods

* feat(notes): save user_id if user is authorized

FIRST TRY

* fix(notes): add migrations for storing author_ids

* go mod tidy

* feat(notes): store author_id if user authorized

* fix linting

* test(e2e): create note authorized

* fix(notes): notes might be readed and removed after

* test(e2e): get notes

* linter momonet

* chore(ci): update few versions

* refactor(e2e): rename files

* fix migration

* refactor(e2e): renaming

* refactor(e2e): some formatting
Author: Smirnov Oleksandr ss2316544@gmail.com
Committed by: GitHub noreply@github.com
Committed at: 2024-07-28 15:41:31 +0300
Parent: 2cd7240
M .github/workflows/golang.yml

@@ -9,7 +9,7 @@ jobs:

release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v4
M .github/workflows/linter.yml

@@ -8,7 +8,7 @@ jobs:

golang: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Golangci Lint uses: golangci/golangci-lint-action@v3
M cmd/server/main.go

@@ -13,7 +13,9 @@ "github.com/gin-gonic/gin"

"github.com/olexsmir/onasty/internal/config" "github.com/olexsmir/onasty/internal/hasher" "github.com/olexsmir/onasty/internal/jwtutil" + "github.com/olexsmir/onasty/internal/service/notesrv" "github.com/olexsmir/onasty/internal/service/usersrv" + "github.com/olexsmir/onasty/internal/store/psql/noterepo" "github.com/olexsmir/onasty/internal/store/psql/sessionrepo" "github.com/olexsmir/onasty/internal/store/psql/userepo" "github.com/olexsmir/onasty/internal/store/psqlutil"

@@ -55,7 +57,10 @@

userepo := userepo.New(psqlDB) usersrv := usersrv.New(userepo, sessionrepo, sha256Hasher, jwtTokenizer) - handler := httptransport.NewTransport(usersrv) + noterepo := noterepo.New(psqlDB) + notesrv := notesrv.New(noterepo) + + handler := httptransport.NewTransport(usersrv, notesrv) // http server srv := httpserver.NewServer(cfg.ServerPort, handler.Handler())
M e2e/apiv1_auth_test.go

@@ -71,15 +71,16 @@ e.Equal(http.StatusBadRequest, httpResp.Code)

} } -type apiv1AuthSignInRequest struct { - Email string `json:"email"` - Password string `json:"password"` -} - -type apiv1AuthSignInResponse struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` -} +type ( + apiv1AuthSignInRequest struct { + Email string `json:"email"` + Password string `json:"password"` + } + apiv1AuthSignInResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + } +) func (e *AppTestSuite) TestAuthV1_SignIn() { email := e.uuid() + "email@email.com"
A e2e/apiv1_notes_test.go

@@ -0,0 +1,139 @@

+package e2e + +import ( + "net/http" + "net/http/httptest" + "time" + + "github.com/gofrs/uuid/v5" +) + +type ( + apiv1NoteCreateRequest struct { + Content string `json:"content"` + Slug string `json:"slug"` + BurnBeforeExpiration bool `json:"burn_before_expiration"` + ExpiresAt time.Time `json:"expires_at"` + } + apiv1NoteCreateResponse struct { + Slug string `json:"slug"` + } +) + +func (e *AppTestSuite) TestNoteV1_Create_unauthorized() { + tests := []struct { + name string + inp apiv1NoteCreateRequest + assert func(*httptest.ResponseRecorder, apiv1NoteCreateRequest) + }{ + { + name: "empty request", + inp: apiv1NoteCreateRequest{}, + assert: func(r *httptest.ResponseRecorder, _ apiv1NoteCreateRequest) { + e.Equal(r.Code, http.StatusBadRequest) + }, + }, + { + name: "content only", + inp: apiv1NoteCreateRequest{Content: e.uuid()}, + assert: func(r *httptest.ResponseRecorder, _ apiv1NoteCreateRequest) { + e.Equal(r.Code, http.StatusCreated) + + var body apiv1NoteCreateResponse + e.readBodyAndUnjsonify(r.Body, &body) + + _, err := uuid.FromString(body.Slug) + e.require.NoError(err) + + dbNote := e.getNoteFromDBbySlug(body.Slug) + e.NotEmpty(dbNote) + }, + }, + { + name: "set slug", + inp: apiv1NoteCreateRequest{ + Slug: e.uuid() + "fuker", + Content: e.uuid(), + }, + assert: func(r *httptest.ResponseRecorder, inp apiv1NoteCreateRequest) { + e.Equal(r.Code, http.StatusCreated) + + var body apiv1NoteCreateResponse + e.readBodyAndUnjsonify(r.Body, &body) + + dbNote := e.getNoteFromDBbySlug(inp.Slug) + e.NotEmpty(dbNote) + }, + }, + { + name: "all possible fields", + inp: apiv1NoteCreateRequest{ + Content: e.uuid(), + BurnBeforeExpiration: true, + ExpiresAt: time.Now().Add(time.Hour), + }, + assert: func(r *httptest.ResponseRecorder, inp apiv1NoteCreateRequest) { + e.Equal(r.Code, http.StatusCreated) + + var body apiv1NoteCreateResponse + e.readBodyAndUnjsonify(r.Body, &body) + + dbNote := e.getNoteFromDBbySlug(body.Slug) + e.NotEmpty(dbNote) + + e.Equal(dbNote.Content, inp.Content) + e.Equal(dbNote.BurnBeforeExpiration, inp.BurnBeforeExpiration) + e.Equal(dbNote.ExpiresAt.Unix(), inp.ExpiresAt.Unix()) + }, + }, + } + + for _, tt := range tests { + httpResp := e.httpRequest(http.MethodPost, "/api/v1/note", e.jsonify(tt.inp)) + tt.assert(httpResp, tt.inp) + } +} + +func (e *AppTestSuite) TestNoteV1_Create_authorized() { + uid, toks := e.createAndSingIn(e.uuid()+"@test.com", e.uuid(), "password") + httpResp := e.httpRequest(http.MethodPost, "/api/v1/note", e.jsonify(apiv1NoteCreateRequest{ + Content: "some random ass content for the test", + }), toks.AccessToken) + + var body apiv1NoteCreateResponse + e.readBodyAndUnjsonify(httpResp.Body, &body) + + dbNote := e.getNoteFromDBbySlug(body.Slug) + dbNoteAuthor := e.getLastNoteAuthorsRecordByAuthorID(uid) + + e.Equal(http.StatusCreated, httpResp.Code) + e.Equal(dbNote.ID.String(), dbNoteAuthor.noteID.String()) +} + +type apiv1NoteGetResponse struct { + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +func (e *AppTestSuite) TestNoteV1_Get() { + content := e.uuid() + httpResp := e.httpRequest(http.MethodPost, "/api/v1/note", e.jsonify(apiv1NoteCreateRequest{ + Content: content, + })) + e.Equal(http.StatusCreated, httpResp.Code) + + var bodyCreated apiv1NoteCreateResponse + e.readBodyAndUnjsonify(httpResp.Body, &bodyCreated) + + httpResp = e.httpRequest(http.MethodGet, "/api/v1/note/"+bodyCreated.Slug, nil) + e.Equal(httpResp.Code, http.StatusOK) + + var body apiv1NoteGetResponse + e.readBodyAndUnjsonify(httpResp.Body, &body) + + e.Equal(content, body.Content) + + dbNote := e.getNoteFromDBbySlug(bodyCreated.Slug) + e.Empty(dbNote) +}
M e2e/e2e_test.go

@@ -13,7 +13,9 @@ "github.com/golang-migrate/migrate/v4/database/pgx"

"github.com/jackc/pgx/v5/stdlib" "github.com/olexsmir/onasty/internal/hasher" "github.com/olexsmir/onasty/internal/jwtutil" + "github.com/olexsmir/onasty/internal/service/notesrv" "github.com/olexsmir/onasty/internal/service/usersrv" + "github.com/olexsmir/onasty/internal/store/psql/noterepo" "github.com/olexsmir/onasty/internal/store/psql/sessionrepo" "github.com/olexsmir/onasty/internal/store/psql/userepo" "github.com/olexsmir/onasty/internal/store/psqlutil"

@@ -83,7 +85,10 @@

userepo := userepo.New(e.postgresDB) usersrv := usersrv.New(userepo, sessionrepo, e.hasher, e.jwtTokenizer) - handler := httptransport.NewTransport(usersrv) + noterepo := noterepo.New(e.postgresDB) + notesrv := notesrv.New(noterepo) + + handler := httptransport.NewTransport(usersrv, notesrv) e.router = handler.Handler() }
M e2e/e2e_utils_db_test.go

@@ -66,3 +66,47 @@

e.require.NoError(err) return session } + +func (e *AppTestSuite) getNoteFromDBbySlug(slug string) models.Note { + query, args, err := pgq. + Select("id", "content", "slug", "burn_before_expiration", "created_at", "expires_at"). + From("notes"). + Where("slug = ?", slug). + SQL() + e.require.NoError(err) + + var note models.Note + err = e.postgresDB.QueryRow(e.ctx, query, args...). + Scan(&note.ID, &note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.CreatedAt, &note.ExpiresAt) + if errors.Is(err, pgx.ErrNoRows) { + return models.Note{} + } + + e.require.NoError(err) + return note +} + +type noteAuthorModel struct { + noteID uuid.UUID + userID uuid.UUID +} + +func (e *AppTestSuite) getLastNoteAuthorsRecordByAuthorID(uid uuid.UUID) noteAuthorModel { + qeuery, args, err := pgq. + Select("note_id", "user_id"). + From("notes_authors"). + Where(pgq.Eq{"user_id": uid.String()}). + OrderBy("created_at DESC"). + Limit(1). + SQL() + e.require.NoError(err) + + var na noteAuthorModel + err = e.postgresDB.QueryRow(e.ctx, qeuery, args...).Scan(&na.noteID, &na.userID) + if errors.Is(err, pgx.ErrNoRows) { + return noteAuthorModel{} + } + + e.require.NoError(err) + return na +}
M e2e/e2e_utils_test.go

@@ -35,7 +35,7 @@

// httpRequest sends http request to the server and returns `httptest.ResponseRecorder` // conteny-type always set to application/json func (e *AppTestSuite) httpRequest( - method, url string, //nolint:unparam // TODO: fix me later + method, url string, body []byte, accessToken ...string, ) *httptest.ResponseRecorder {
A internal/dtos/note.go

@@ -0,0 +1,28 @@

+package dtos + +import ( + "time" + + "github.com/gofrs/uuid/v5" +) + +type NoteSlugDTO string + +func (n NoteSlugDTO) String() string { return string(n) } + +type NoteDTO struct { + Content string + Slug string + BurnBeforeExpiration bool + CreatedAt time.Time + ExpiresAt time.Time +} + +type CreateNoteDTO struct { + Content string + UserID uuid.UUID + Slug string + BurnBeforeExpiration bool + CreatedAt time.Time + ExpiresAt time.Time +}
A internal/models/notes.go

@@ -0,0 +1,46 @@

+package models + +import ( + "errors" + "time" + + "github.com/gofrs/uuid/v5" +) + +var ( + ErrNoteContentIsEmpty = errors.New("note: content is empty") + ErrNoteSlugIsAlreadyInUse = errors.New("note: slug is already in use") + ErrNoteExpired = errors.New("note: expired") + ErrNoteNotFound = errors.New("note: not found") +) + +type Note struct { + ID uuid.UUID + Content string + Slug string + BurnBeforeExpiration bool + CreatedAt time.Time + ExpiresAt time.Time +} + +func (n Note) Validate() error { + if n.Content == "" { + return ErrNoteContentIsEmpty + } + + if n.IsExpired() { + return ErrNoteExpired + } + + return nil +} + +func (n Note) IsExpired() bool { + return !n.ExpiresAt.IsZero() && + n.ExpiresAt.Before(time.Now()) +} + +func (n Note) ShouldBeBurnt() bool { + return !n.ExpiresAt.IsZero() && + n.BurnBeforeExpiration +}
A internal/models/notes_test.go

@@ -0,0 +1,113 @@

+package models + +import ( + "testing" + "time" + + assert "github.com/stretchr/testify/require" +) + +func TestNote_Validate(t *testing.T) { + tests := []struct { + name string + note Note + willError bool + error error + }{ + // NOTE: there no need to test if note is expired since it tested in IsExpired test + { + name: "ok", + note: Note{ + Content: "some wired ass content", + ExpiresAt: time.Now().Add(time.Hour), + }, + willError: false, + }, + { + name: "content missing", + note: Note{Content: ""}, + willError: true, + error: ErrNoteContentIsEmpty, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.note.Validate() + if tt.willError { + assert.EqualError(t, err, tt.error.Error()) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestNote_IsExpired(t *testing.T) { + tests := []struct { + name string + note Note + expected bool + }{ + { + name: "expired", + note: Note{ExpiresAt: time.Now().Add(-time.Hour)}, + expected: true, + }, + { + name: "not expired", + note: Note{ExpiresAt: time.Now().Add(time.Hour)}, + expected: false, + }, + { + name: "zero expiration", + note: Note{ExpiresAt: time.Time{}}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.note.IsExpired()) + }) + } +} + +func TestNote_ShouldBeBurnt(t *testing.T) { + tests := []struct { + name string + note Note + expected bool + }{ + { + name: "should be burnt", + note: Note{ + BurnBeforeExpiration: true, + ExpiresAt: time.Now().Add(time.Hour), + }, + expected: true, + }, + { + name: "could not be burnt, no expiration time", + note: Note{ + BurnBeforeExpiration: true, + ExpiresAt: time.Time{}, + }, + expected: false, + }, + { + name: "could not be burnt, burn when expiration and burn is false", + note: Note{ + BurnBeforeExpiration: false, + ExpiresAt: time.Time{}, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.note.ShouldBeBurnt()) + }) + } +}
A internal/service/notesrv/notesrv.go

@@ -0,0 +1,83 @@

+package notesrv + +import ( + "context" + + "github.com/gofrs/uuid/v5" + "github.com/olexsmir/onasty/internal/dtos" + "github.com/olexsmir/onasty/internal/models" + "github.com/olexsmir/onasty/internal/store/psql/noterepo" +) + +type NoteServicer interface { + // Create create note + // if slug is empty it will be generated, otherwise used as is + // if userID is empty it means user isn't authorized so it will be used + Create(ctx context.Context, note dtos.CreateNoteDTO, userID uuid.UUID) (dtos.NoteSlugDTO, error) + GetBySlugAndRemoveIfNeeded(ctx context.Context, slug dtos.NoteSlugDTO) (dtos.NoteDTO, error) +} + +var _ NoteServicer = (*NoteSrv)(nil) + +type NoteSrv struct { + noterepo noterepo.NoteStorer +} + +func New(noterepo noterepo.NoteStorer) NoteServicer { + return &NoteSrv{ + noterepo: noterepo, + } +} + +func (n *NoteSrv) Create( + ctx context.Context, + inp dtos.CreateNoteDTO, + userID uuid.UUID, +) (dtos.NoteSlugDTO, error) { + if inp.Slug == "" { + inp.Slug = uuid.Must(uuid.NewV4()).String() + } + + err := n.noterepo.Create(ctx, inp) + if err != nil { + return "", err + } + + if !userID.IsNil() { + if err := n.noterepo.SetAuthorIDBySlug(ctx, dtos.NoteSlugDTO(inp.Slug), userID); err != nil { + return "", err + } + } + + return dtos.NoteSlugDTO(inp.Slug), nil +} + +func (n *NoteSrv) GetBySlugAndRemoveIfNeeded( + ctx context.Context, + slug dtos.NoteSlugDTO, +) (dtos.NoteDTO, error) { + note, err := n.noterepo.GetBySlug(ctx, slug) + if err != nil { + return dtos.NoteDTO{}, err + } + + // TODO: there should be a better way to do it + m := models.Note{ + ExpiresAt: note.ExpiresAt, + BurnBeforeExpiration: note.BurnBeforeExpiration, + } + + if m.IsExpired() { + return dtos.NoteDTO{}, models.ErrNoteExpired + } + + // since not every note should be burn before expiration + // we return early if it's not + if m.ShouldBeBurnt() { + return note, nil + } + + // TODO: in future not remove, leave some metadata + // to shot user that note was alreasy seen + return note, n.noterepo.DeleteBySlug(ctx, dtos.NoteSlugDTO(note.Slug)) +}
A internal/store/psql/noterepo/noterepo.go

@@ -0,0 +1,119 @@

+package noterepo + +import ( + "context" + "errors" + + "github.com/gofrs/uuid/v5" + "github.com/henvic/pgq" + "github.com/jackc/pgx/v5" + "github.com/olexsmir/onasty/internal/dtos" + "github.com/olexsmir/onasty/internal/models" + "github.com/olexsmir/onasty/internal/store/psqlutil" +) + +type NoteStorer interface { + Create(ctx context.Context, inp dtos.CreateNoteDTO) error + GetBySlug(ctx context.Context, slug dtos.NoteSlugDTO) (dtos.NoteDTO, error) + DeleteBySlug(ctx context.Context, slug dtos.NoteSlugDTO) error + + SetAuthorIDBySlug(ctx context.Context, slug dtos.NoteSlugDTO, authorID uuid.UUID) error +} + +var _ NoteStorer = (*NoteRepo)(nil) + +type NoteRepo struct { + db *psqlutil.DB +} + +func New(db *psqlutil.DB) NoteStorer { + return &NoteRepo{db} +} + +func (s *NoteRepo) Create(ctx context.Context, inp dtos.CreateNoteDTO) error { + query, args, err := pgq. + Insert("notes"). + Columns("content", "slug", "burn_before_expiration ", "created_at", "expires_at"). + Values(inp.Content, inp.Slug, inp.BurnBeforeExpiration, inp.CreatedAt, inp.ExpiresAt). + SQL() + if err != nil { + return err + } + + _, err = s.db.Exec(ctx, query, args...) + if psqlutil.IsDuplicateErr(err) { + return models.ErrNoteSlugIsAlreadyInUse + } + + return err +} + +func (s *NoteRepo) GetBySlug(ctx context.Context, slug dtos.NoteSlugDTO) (dtos.NoteDTO, error) { + query, args, err := pgq. + Select("content", "slug", "burn_before_expiration", "created_at", "expires_at"). + From("notes"). + Where("slug = ?", slug). + SQL() + if err != nil { + return dtos.NoteDTO{}, err + } + + var note dtos.NoteDTO + err = s.db.QueryRow(ctx, query, args...). + Scan(&note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.CreatedAt, &note.ExpiresAt) + + if errors.Is(err, pgx.ErrNoRows) { + return dtos.NoteDTO{}, models.ErrNoteNotFound + } + + return note, err +} + +func (s *NoteRepo) DeleteBySlug(ctx context.Context, slug dtos.NoteSlugDTO) error { + query, args, err := pgq. + Delete("notes"). + Where(pgq.Eq{"slug": slug}). + SQL() + if err != nil { + return err + } + + _, err = s.db.Exec(ctx, query, args...) + if errors.Is(err, pgx.ErrNoRows) { + return models.ErrNoteNotFound + } + + return err +} + +func (s *NoteRepo) SetAuthorIDBySlug( + ctx context.Context, + slug dtos.NoteSlugDTO, + authorID uuid.UUID, +) error { + tx, err := s.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) //nolint:errcheck + + var noteID uuid.UUID + err = tx.QueryRow(ctx, "select id from notes where slug = $1", slug.String()).Scan(&noteID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return models.ErrNoteNotFound + } + return err + } + + _, err = tx.Exec( + ctx, + "insert into notes_authors (note_id, user_id) values ($1, $2)", + noteID, authorID, + ) + if err != nil { + return err + } + + return tx.Commit(ctx) +}
M internal/transport/http/apiv1/apiv1.go

@@ -2,16 +2,22 @@ package apiv1

import ( "github.com/gin-gonic/gin" + "github.com/olexsmir/onasty/internal/service/notesrv" "github.com/olexsmir/onasty/internal/service/usersrv" ) type APIV1 struct { usersrv usersrv.UserServicer + notesrv notesrv.NoteServicer } -func NewAPIV1(us usersrv.UserServicer) *APIV1 { +func NewAPIV1( + us usersrv.UserServicer, + ns notesrv.NoteServicer, +) *APIV1 { return &APIV1{ usersrv: us, + notesrv: ns, } }

@@ -26,5 +32,11 @@ authorized := auth.Group("/", a.authorizedMiddleware)

{ authorized.POST("/logout", a.logOutHandler) } + } + + note := r.Group("/note", a.couldBeAuthorizedMiddleware) + { + note.GET("/:slug", a.getNoteBySlugHandler) + note.POST("", a.createNoteHandler) } }
M internal/transport/http/apiv1/auth.go

@@ -105,7 +105,7 @@ })

} func (a *APIV1) logOutHandler(c *gin.Context) { - if err := a.usersrv.Logout(c.Request.Context(), getUserID(c)); err != nil { + if err := a.usersrv.Logout(c.Request.Context(), a.getUserID(c)); err != nil { errorResponse(c, err) return }
M internal/transport/http/apiv1/middleware.go

@@ -40,7 +40,6 @@

c.Next() } -//nolint:unused // TODO: remove me later func (a *APIV1) couldBeAuthorizedMiddleware(c *gin.Context) { token, ok := getTokenFromAuthHeaders(c) if ok {

@@ -66,7 +65,7 @@ }

//nolint:unused // TODO: remove me later func (a *APIV1) isUserAuthorized(c *gin.Context) bool { - return !getUserID(c).IsNil() + return !a.getUserID(c).IsNil() } func getTokenFromAuthHeaders(c *gin.Context) (token string, ok bool) { //nolint:nonamedreturns

@@ -100,7 +99,7 @@ }

// getUserId returns userId from the context // getting user id is only possible if user is authorized -func getUserID(c *gin.Context) uuid.UUID { +func (a *APIV1) getUserID(c *gin.Context) uuid.UUID { userID, exists := c.Get(userIDCtxKey) if !exists { return uuid.Nil
A internal/transport/http/apiv1/note.go

@@ -0,0 +1,81 @@

+package apiv1 + +import ( + "log/slog" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/olexsmir/onasty/internal/dtos" + "github.com/olexsmir/onasty/internal/models" +) + +type createNoteRequest struct { + Content string `json:"content"` + Slug string `json:"slug"` + BurnBeforeExpiration bool `json:"burn_before_expiration"` + ExpiresAt time.Time `json:"expires_at"` +} + +type createNoteResponse struct { + Slug string `json:"slug"` +} + +func (a *APIV1) createNoteHandler(c *gin.Context) { + var req createNoteRequest + if err := c.ShouldBindJSON(&req); err != nil { + newError(c, http.StatusBadRequest, "invalid request") + return + } + + note := models.Note{ + Content: req.Content, + Slug: req.Slug, + BurnBeforeExpiration: req.BurnBeforeExpiration, + CreatedAt: time.Now(), + ExpiresAt: req.ExpiresAt, + } + + if err := note.Validate(); err != nil { + newErrorStatus(c, http.StatusBadRequest, err.Error()) + return + } + + slog.Debug("userid", "a", a.getUserID(c)) + + slug, err := a.notesrv.Create(c.Request.Context(), dtos.CreateNoteDTO{ + Content: note.Content, + UserID: a.getUserID(c), + Slug: note.Slug, + BurnBeforeExpiration: note.BurnBeforeExpiration, + CreatedAt: note.CreatedAt, + ExpiresAt: note.ExpiresAt, + }, a.getUserID(c)) + if err != nil { + errorResponse(c, err) + return + } + + c.JSON(http.StatusCreated, createNoteResponse{slug.String()}) +} + +type getNoteBySlugResponse struct { + Content string `json:"content"` + CratedAt time.Time `json:"crated_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +func (a *APIV1) getNoteBySlugHandler(c *gin.Context) { + slug := c.Param("slug") + note, err := a.notesrv.GetBySlugAndRemoveIfNeeded(c.Request.Context(), dtos.NoteSlugDTO(slug)) + if err != nil { + errorResponse(c, err) + return + } + + c.JSON(http.StatusOK, getNoteBySlugResponse{ + Content: note.Content, + CratedAt: note.CreatedAt, + ExpiresAt: note.ExpiresAt, + }) +}
M internal/transport/http/apiv1/response.go

@@ -15,8 +15,20 @@ }

func errorResponse(c *gin.Context, err error) { if errors.Is(err, models.ErrUserEmailIsAlreadyInUse) || - errors.Is(err, models.ErrUsernameIsAlreadyInUse) { + errors.Is(err, models.ErrUsernameIsAlreadyInUse) || + errors.Is(err, models.ErrNoteContentIsEmpty) || + errors.Is(err, models.ErrNoteSlugIsAlreadyInUse) { newError(c, http.StatusBadRequest, err.Error()) + return + } + + if errors.Is(err, models.ErrNoteExpired) { + newError(c, http.StatusGone, err.Error()) + return + } + + if errors.Is(err, models.ErrNoteNotFound) { + newErrorStatus(c, http.StatusNotFound, err.Error()) return }

@@ -34,7 +46,7 @@

newInternalError(c, err) } -func newError(c *gin.Context, status int, msg string) { //nolint:unparam // TODO: remove me later +func newError(c *gin.Context, status int, msg string) { slog.Error(msg, "status", status) c.AbortWithStatusJSON(status, response{msg}) }
M internal/transport/http/http.go

@@ -4,17 +4,23 @@ import (

"net/http" "github.com/gin-gonic/gin" + "github.com/olexsmir/onasty/internal/service/notesrv" "github.com/olexsmir/onasty/internal/service/usersrv" "github.com/olexsmir/onasty/internal/transport/http/apiv1" ) type Transport struct { usersrv usersrv.UserServicer + notesrv notesrv.NoteServicer } -func NewTransport(us usersrv.UserServicer) *Transport { +func NewTransport( + us usersrv.UserServicer, + ns notesrv.NoteServicer, +) *Transport { return &Transport{ usersrv: us, + notesrv: ns, } }

@@ -27,7 +33,7 @@ )

api := r.Group("/api") api.GET("/ping", t.pingHandler) - apiv1.NewAPIV1(t.usersrv).Routes(api.Group("/v1")) + apiv1.NewAPIV1(t.usersrv, t.notesrv).Routes(api.Group("/v1")) return r.Handler() }
A migrations/20240716235210_notes.down.sql

@@ -0,0 +1,1 @@

+drop table if exists notes;
A migrations/20240716235210_notes.up.sql

@@ -0,0 +1,8 @@

+create table notes ( + id uuid primary key default uuid_generate_v4(), + content text not null, + slug varchar(255) not null unique, + burn_before_expiration boolean default false, + created_at timestamptz not null default now(), + expires_at timestamptz +);
A migrations/20240724122920_notes_authors.up.sql

@@ -0,0 +1,6 @@

+create table notes_authors ( + id uuid primary key default uuid_generate_v4(), + note_id uuid references notes (id) on delete cascade, + user_id uuid references users (id) on delete cascade, + created_at timestamptz not null default now() +);
M migrations/Taskfile.yml

@@ -17,3 +17,6 @@ - migrate -database $POSTGRESQL_DSN -path {{.MIGRATIONS_DIR}} down 1

drop: - migrate -database $POSTGRESQL_DSN -path {{.MIGRATIONS_DIR}} drop + + current-version: + - migrate -database $POSTGRESQL_DSN -path {{.MIGRATIONS_DIR}} version