24 files changed,
734 insertions(+),
23 deletions(-)
Author:
Smirnov Oleksandr
ss2316544@gmail.com
Committed by:
GitHub
noreply@github.com
Committed at:
2024-07-28 15:41:31 +0300
Parent:
2cd7240
jump to
M
cmd/server/main.go
··· 13 13 "github.com/olexsmir/onasty/internal/config" 14 14 "github.com/olexsmir/onasty/internal/hasher" 15 15 "github.com/olexsmir/onasty/internal/jwtutil" 16 + "github.com/olexsmir/onasty/internal/service/notesrv" 16 17 "github.com/olexsmir/onasty/internal/service/usersrv" 18 + "github.com/olexsmir/onasty/internal/store/psql/noterepo" 17 19 "github.com/olexsmir/onasty/internal/store/psql/sessionrepo" 18 20 "github.com/olexsmir/onasty/internal/store/psql/userepo" 19 21 "github.com/olexsmir/onasty/internal/store/psqlutil" ··· 55 57 userepo := userepo.New(psqlDB) 56 58 usersrv := usersrv.New(userepo, sessionrepo, sha256Hasher, jwtTokenizer) 57 59 58 - handler := httptransport.NewTransport(usersrv) 60 + noterepo := noterepo.New(psqlDB) 61 + notesrv := notesrv.New(noterepo) 62 + 63 + handler := httptransport.NewTransport(usersrv, notesrv) 59 64 60 65 // http server 61 66 srv := httpserver.NewServer(cfg.ServerPort, handler.Handler())
M
e2e/apiv1_auth_test.go
··· 71 71 } 72 72 } 73 73 74 -type apiv1AuthSignInRequest struct { 75 - Email string `json:"email"` 76 - Password string `json:"password"` 77 -} 78 - 79 -type apiv1AuthSignInResponse struct { 80 - AccessToken string `json:"access_token"` 81 - RefreshToken string `json:"refresh_token"` 82 -} 74 +type ( 75 + apiv1AuthSignInRequest struct { 76 + Email string `json:"email"` 77 + Password string `json:"password"` 78 + } 79 + apiv1AuthSignInResponse struct { 80 + AccessToken string `json:"access_token"` 81 + RefreshToken string `json:"refresh_token"` 82 + } 83 +) 83 84 84 85 func (e *AppTestSuite) TestAuthV1_SignIn() { 85 86 email := e.uuid() + "email@email.com"
A
e2e/apiv1_notes_test.go
··· 1 +package e2e 2 + 3 +import ( 4 + "net/http" 5 + "net/http/httptest" 6 + "time" 7 + 8 + "github.com/gofrs/uuid/v5" 9 +) 10 + 11 +type ( 12 + apiv1NoteCreateRequest struct { 13 + Content string `json:"content"` 14 + Slug string `json:"slug"` 15 + BurnBeforeExpiration bool `json:"burn_before_expiration"` 16 + ExpiresAt time.Time `json:"expires_at"` 17 + } 18 + apiv1NoteCreateResponse struct { 19 + Slug string `json:"slug"` 20 + } 21 +) 22 + 23 +func (e *AppTestSuite) TestNoteV1_Create_unauthorized() { 24 + tests := []struct { 25 + name string 26 + inp apiv1NoteCreateRequest 27 + assert func(*httptest.ResponseRecorder, apiv1NoteCreateRequest) 28 + }{ 29 + { 30 + name: "empty request", 31 + inp: apiv1NoteCreateRequest{}, 32 + assert: func(r *httptest.ResponseRecorder, _ apiv1NoteCreateRequest) { 33 + e.Equal(r.Code, http.StatusBadRequest) 34 + }, 35 + }, 36 + { 37 + name: "content only", 38 + inp: apiv1NoteCreateRequest{Content: e.uuid()}, 39 + assert: func(r *httptest.ResponseRecorder, _ apiv1NoteCreateRequest) { 40 + e.Equal(r.Code, http.StatusCreated) 41 + 42 + var body apiv1NoteCreateResponse 43 + e.readBodyAndUnjsonify(r.Body, &body) 44 + 45 + _, err := uuid.FromString(body.Slug) 46 + e.require.NoError(err) 47 + 48 + dbNote := e.getNoteFromDBbySlug(body.Slug) 49 + e.NotEmpty(dbNote) 50 + }, 51 + }, 52 + { 53 + name: "set slug", 54 + inp: apiv1NoteCreateRequest{ 55 + Slug: e.uuid() + "fuker", 56 + Content: e.uuid(), 57 + }, 58 + assert: func(r *httptest.ResponseRecorder, inp apiv1NoteCreateRequest) { 59 + e.Equal(r.Code, http.StatusCreated) 60 + 61 + var body apiv1NoteCreateResponse 62 + e.readBodyAndUnjsonify(r.Body, &body) 63 + 64 + dbNote := e.getNoteFromDBbySlug(inp.Slug) 65 + e.NotEmpty(dbNote) 66 + }, 67 + }, 68 + { 69 + name: "all possible fields", 70 + inp: apiv1NoteCreateRequest{ 71 + Content: e.uuid(), 72 + BurnBeforeExpiration: true, 73 + ExpiresAt: time.Now().Add(time.Hour), 74 + }, 75 + assert: func(r *httptest.ResponseRecorder, inp apiv1NoteCreateRequest) { 76 + e.Equal(r.Code, http.StatusCreated) 77 + 78 + var body apiv1NoteCreateResponse 79 + e.readBodyAndUnjsonify(r.Body, &body) 80 + 81 + dbNote := e.getNoteFromDBbySlug(body.Slug) 82 + e.NotEmpty(dbNote) 83 + 84 + e.Equal(dbNote.Content, inp.Content) 85 + e.Equal(dbNote.BurnBeforeExpiration, inp.BurnBeforeExpiration) 86 + e.Equal(dbNote.ExpiresAt.Unix(), inp.ExpiresAt.Unix()) 87 + }, 88 + }, 89 + } 90 + 91 + for _, tt := range tests { 92 + httpResp := e.httpRequest(http.MethodPost, "/api/v1/note", e.jsonify(tt.inp)) 93 + tt.assert(httpResp, tt.inp) 94 + } 95 +} 96 + 97 +func (e *AppTestSuite) TestNoteV1_Create_authorized() { 98 + uid, toks := e.createAndSingIn(e.uuid()+"@test.com", e.uuid(), "password") 99 + httpResp := e.httpRequest(http.MethodPost, "/api/v1/note", e.jsonify(apiv1NoteCreateRequest{ 100 + Content: "some random ass content for the test", 101 + }), toks.AccessToken) 102 + 103 + var body apiv1NoteCreateResponse 104 + e.readBodyAndUnjsonify(httpResp.Body, &body) 105 + 106 + dbNote := e.getNoteFromDBbySlug(body.Slug) 107 + dbNoteAuthor := e.getLastNoteAuthorsRecordByAuthorID(uid) 108 + 109 + e.Equal(http.StatusCreated, httpResp.Code) 110 + e.Equal(dbNote.ID.String(), dbNoteAuthor.noteID.String()) 111 +} 112 + 113 +type apiv1NoteGetResponse struct { 114 + Content string `json:"content"` 115 + CreatedAt time.Time `json:"created_at"` 116 + ExpiresAt time.Time `json:"expires_at"` 117 +} 118 + 119 +func (e *AppTestSuite) TestNoteV1_Get() { 120 + content := e.uuid() 121 + httpResp := e.httpRequest(http.MethodPost, "/api/v1/note", e.jsonify(apiv1NoteCreateRequest{ 122 + Content: content, 123 + })) 124 + e.Equal(http.StatusCreated, httpResp.Code) 125 + 126 + var bodyCreated apiv1NoteCreateResponse 127 + e.readBodyAndUnjsonify(httpResp.Body, &bodyCreated) 128 + 129 + httpResp = e.httpRequest(http.MethodGet, "/api/v1/note/"+bodyCreated.Slug, nil) 130 + e.Equal(httpResp.Code, http.StatusOK) 131 + 132 + var body apiv1NoteGetResponse 133 + e.readBodyAndUnjsonify(httpResp.Body, &body) 134 + 135 + e.Equal(content, body.Content) 136 + 137 + dbNote := e.getNoteFromDBbySlug(bodyCreated.Slug) 138 + e.Empty(dbNote) 139 +}
M
e2e/e2e_test.go
··· 13 13 "github.com/jackc/pgx/v5/stdlib" 14 14 "github.com/olexsmir/onasty/internal/hasher" 15 15 "github.com/olexsmir/onasty/internal/jwtutil" 16 + "github.com/olexsmir/onasty/internal/service/notesrv" 16 17 "github.com/olexsmir/onasty/internal/service/usersrv" 18 + "github.com/olexsmir/onasty/internal/store/psql/noterepo" 17 19 "github.com/olexsmir/onasty/internal/store/psql/sessionrepo" 18 20 "github.com/olexsmir/onasty/internal/store/psql/userepo" 19 21 "github.com/olexsmir/onasty/internal/store/psqlutil" ··· 83 85 userepo := userepo.New(e.postgresDB) 84 86 usersrv := usersrv.New(userepo, sessionrepo, e.hasher, e.jwtTokenizer) 85 87 86 - handler := httptransport.NewTransport(usersrv) 88 + noterepo := noterepo.New(e.postgresDB) 89 + notesrv := notesrv.New(noterepo) 90 + 91 + handler := httptransport.NewTransport(usersrv, notesrv) 87 92 e.router = handler.Handler() 88 93 } 89 94
M
e2e/e2e_utils_db_test.go
··· 66 66 e.require.NoError(err) 67 67 return session 68 68 } 69 + 70 +func (e *AppTestSuite) getNoteFromDBbySlug(slug string) models.Note { 71 + query, args, err := pgq. 72 + Select("id", "content", "slug", "burn_before_expiration", "created_at", "expires_at"). 73 + From("notes"). 74 + Where("slug = ?", slug). 75 + SQL() 76 + e.require.NoError(err) 77 + 78 + var note models.Note 79 + err = e.postgresDB.QueryRow(e.ctx, query, args...). 80 + Scan(¬e.ID, ¬e.Content, ¬e.Slug, ¬e.BurnBeforeExpiration, ¬e.CreatedAt, ¬e.ExpiresAt) 81 + if errors.Is(err, pgx.ErrNoRows) { 82 + return models.Note{} 83 + } 84 + 85 + e.require.NoError(err) 86 + return note 87 +} 88 + 89 +type noteAuthorModel struct { 90 + noteID uuid.UUID 91 + userID uuid.UUID 92 +} 93 + 94 +func (e *AppTestSuite) getLastNoteAuthorsRecordByAuthorID(uid uuid.UUID) noteAuthorModel { 95 + qeuery, args, err := pgq. 96 + Select("note_id", "user_id"). 97 + From("notes_authors"). 98 + Where(pgq.Eq{"user_id": uid.String()}). 99 + OrderBy("created_at DESC"). 100 + Limit(1). 101 + SQL() 102 + e.require.NoError(err) 103 + 104 + var na noteAuthorModel 105 + err = e.postgresDB.QueryRow(e.ctx, qeuery, args...).Scan(&na.noteID, &na.userID) 106 + if errors.Is(err, pgx.ErrNoRows) { 107 + return noteAuthorModel{} 108 + } 109 + 110 + e.require.NoError(err) 111 + return na 112 +}
M
e2e/e2e_utils_test.go
··· 35 35 // httpRequest sends http request to the server and returns `httptest.ResponseRecorder` 36 36 // conteny-type always set to application/json 37 37 func (e *AppTestSuite) httpRequest( 38 - method, url string, //nolint:unparam // TODO: fix me later 38 + method, url string, 39 39 body []byte, 40 40 accessToken ...string, 41 41 ) *httptest.ResponseRecorder {
A
internal/dtos/note.go
··· 1 +package dtos 2 + 3 +import ( 4 + "time" 5 + 6 + "github.com/gofrs/uuid/v5" 7 +) 8 + 9 +type NoteSlugDTO string 10 + 11 +func (n NoteSlugDTO) String() string { return string(n) } 12 + 13 +type NoteDTO struct { 14 + Content string 15 + Slug string 16 + BurnBeforeExpiration bool 17 + CreatedAt time.Time 18 + ExpiresAt time.Time 19 +} 20 + 21 +type CreateNoteDTO struct { 22 + Content string 23 + UserID uuid.UUID 24 + Slug string 25 + BurnBeforeExpiration bool 26 + CreatedAt time.Time 27 + ExpiresAt time.Time 28 +}
A
internal/models/notes.go
··· 1 +package models 2 + 3 +import ( 4 + "errors" 5 + "time" 6 + 7 + "github.com/gofrs/uuid/v5" 8 +) 9 + 10 +var ( 11 + ErrNoteContentIsEmpty = errors.New("note: content is empty") 12 + ErrNoteSlugIsAlreadyInUse = errors.New("note: slug is already in use") 13 + ErrNoteExpired = errors.New("note: expired") 14 + ErrNoteNotFound = errors.New("note: not found") 15 +) 16 + 17 +type Note struct { 18 + ID uuid.UUID 19 + Content string 20 + Slug string 21 + BurnBeforeExpiration bool 22 + CreatedAt time.Time 23 + ExpiresAt time.Time 24 +} 25 + 26 +func (n Note) Validate() error { 27 + if n.Content == "" { 28 + return ErrNoteContentIsEmpty 29 + } 30 + 31 + if n.IsExpired() { 32 + return ErrNoteExpired 33 + } 34 + 35 + return nil 36 +} 37 + 38 +func (n Note) IsExpired() bool { 39 + return !n.ExpiresAt.IsZero() && 40 + n.ExpiresAt.Before(time.Now()) 41 +} 42 + 43 +func (n Note) ShouldBeBurnt() bool { 44 + return !n.ExpiresAt.IsZero() && 45 + n.BurnBeforeExpiration 46 +}
A
internal/models/notes_test.go
··· 1 +package models 2 + 3 +import ( 4 + "testing" 5 + "time" 6 + 7 + assert "github.com/stretchr/testify/require" 8 +) 9 + 10 +func TestNote_Validate(t *testing.T) { 11 + tests := []struct { 12 + name string 13 + note Note 14 + willError bool 15 + error error 16 + }{ 17 + // NOTE: there no need to test if note is expired since it tested in IsExpired test 18 + { 19 + name: "ok", 20 + note: Note{ 21 + Content: "some wired ass content", 22 + ExpiresAt: time.Now().Add(time.Hour), 23 + }, 24 + willError: false, 25 + }, 26 + { 27 + name: "content missing", 28 + note: Note{Content: ""}, 29 + willError: true, 30 + error: ErrNoteContentIsEmpty, 31 + }, 32 + } 33 + 34 + for _, tt := range tests { 35 + t.Run(tt.name, func(t *testing.T) { 36 + err := tt.note.Validate() 37 + if tt.willError { 38 + assert.EqualError(t, err, tt.error.Error()) 39 + } else { 40 + assert.NoError(t, err) 41 + } 42 + }) 43 + } 44 +} 45 + 46 +func TestNote_IsExpired(t *testing.T) { 47 + tests := []struct { 48 + name string 49 + note Note 50 + expected bool 51 + }{ 52 + { 53 + name: "expired", 54 + note: Note{ExpiresAt: time.Now().Add(-time.Hour)}, 55 + expected: true, 56 + }, 57 + { 58 + name: "not expired", 59 + note: Note{ExpiresAt: time.Now().Add(time.Hour)}, 60 + expected: false, 61 + }, 62 + { 63 + name: "zero expiration", 64 + note: Note{ExpiresAt: time.Time{}}, 65 + expected: false, 66 + }, 67 + } 68 + 69 + for _, tt := range tests { 70 + t.Run(tt.name, func(t *testing.T) { 71 + assert.Equal(t, tt.expected, tt.note.IsExpired()) 72 + }) 73 + } 74 +} 75 + 76 +func TestNote_ShouldBeBurnt(t *testing.T) { 77 + tests := []struct { 78 + name string 79 + note Note 80 + expected bool 81 + }{ 82 + { 83 + name: "should be burnt", 84 + note: Note{ 85 + BurnBeforeExpiration: true, 86 + ExpiresAt: time.Now().Add(time.Hour), 87 + }, 88 + expected: true, 89 + }, 90 + { 91 + name: "could not be burnt, no expiration time", 92 + note: Note{ 93 + BurnBeforeExpiration: true, 94 + ExpiresAt: time.Time{}, 95 + }, 96 + expected: false, 97 + }, 98 + { 99 + name: "could not be burnt, burn when expiration and burn is false", 100 + note: Note{ 101 + BurnBeforeExpiration: false, 102 + ExpiresAt: time.Time{}, 103 + }, 104 + expected: false, 105 + }, 106 + } 107 + 108 + for _, tt := range tests { 109 + t.Run(tt.name, func(t *testing.T) { 110 + assert.Equal(t, tt.expected, tt.note.ShouldBeBurnt()) 111 + }) 112 + } 113 +}
A
internal/service/notesrv/notesrv.go
··· 1 +package notesrv 2 + 3 +import ( 4 + "context" 5 + 6 + "github.com/gofrs/uuid/v5" 7 + "github.com/olexsmir/onasty/internal/dtos" 8 + "github.com/olexsmir/onasty/internal/models" 9 + "github.com/olexsmir/onasty/internal/store/psql/noterepo" 10 +) 11 + 12 +type NoteServicer interface { 13 + // Create create note 14 + // if slug is empty it will be generated, otherwise used as is 15 + // if userID is empty it means user isn't authorized so it will be used 16 + Create(ctx context.Context, note dtos.CreateNoteDTO, userID uuid.UUID) (dtos.NoteSlugDTO, error) 17 + GetBySlugAndRemoveIfNeeded(ctx context.Context, slug dtos.NoteSlugDTO) (dtos.NoteDTO, error) 18 +} 19 + 20 +var _ NoteServicer = (*NoteSrv)(nil) 21 + 22 +type NoteSrv struct { 23 + noterepo noterepo.NoteStorer 24 +} 25 + 26 +func New(noterepo noterepo.NoteStorer) NoteServicer { 27 + return &NoteSrv{ 28 + noterepo: noterepo, 29 + } 30 +} 31 + 32 +func (n *NoteSrv) Create( 33 + ctx context.Context, 34 + inp dtos.CreateNoteDTO, 35 + userID uuid.UUID, 36 +) (dtos.NoteSlugDTO, error) { 37 + if inp.Slug == "" { 38 + inp.Slug = uuid.Must(uuid.NewV4()).String() 39 + } 40 + 41 + err := n.noterepo.Create(ctx, inp) 42 + if err != nil { 43 + return "", err 44 + } 45 + 46 + if !userID.IsNil() { 47 + if err := n.noterepo.SetAuthorIDBySlug(ctx, dtos.NoteSlugDTO(inp.Slug), userID); err != nil { 48 + return "", err 49 + } 50 + } 51 + 52 + return dtos.NoteSlugDTO(inp.Slug), nil 53 +} 54 + 55 +func (n *NoteSrv) GetBySlugAndRemoveIfNeeded( 56 + ctx context.Context, 57 + slug dtos.NoteSlugDTO, 58 +) (dtos.NoteDTO, error) { 59 + note, err := n.noterepo.GetBySlug(ctx, slug) 60 + if err != nil { 61 + return dtos.NoteDTO{}, err 62 + } 63 + 64 + // TODO: there should be a better way to do it 65 + m := models.Note{ 66 + ExpiresAt: note.ExpiresAt, 67 + BurnBeforeExpiration: note.BurnBeforeExpiration, 68 + } 69 + 70 + if m.IsExpired() { 71 + return dtos.NoteDTO{}, models.ErrNoteExpired 72 + } 73 + 74 + // since not every note should be burn before expiration 75 + // we return early if it's not 76 + if m.ShouldBeBurnt() { 77 + return note, nil 78 + } 79 + 80 + // TODO: in future not remove, leave some metadata 81 + // to shot user that note was alreasy seen 82 + return note, n.noterepo.DeleteBySlug(ctx, dtos.NoteSlugDTO(note.Slug)) 83 +}
A
internal/store/psql/noterepo/noterepo.go
··· 1 +package noterepo 2 + 3 +import ( 4 + "context" 5 + "errors" 6 + 7 + "github.com/gofrs/uuid/v5" 8 + "github.com/henvic/pgq" 9 + "github.com/jackc/pgx/v5" 10 + "github.com/olexsmir/onasty/internal/dtos" 11 + "github.com/olexsmir/onasty/internal/models" 12 + "github.com/olexsmir/onasty/internal/store/psqlutil" 13 +) 14 + 15 +type NoteStorer interface { 16 + Create(ctx context.Context, inp dtos.CreateNoteDTO) error 17 + GetBySlug(ctx context.Context, slug dtos.NoteSlugDTO) (dtos.NoteDTO, error) 18 + DeleteBySlug(ctx context.Context, slug dtos.NoteSlugDTO) error 19 + 20 + SetAuthorIDBySlug(ctx context.Context, slug dtos.NoteSlugDTO, authorID uuid.UUID) error 21 +} 22 + 23 +var _ NoteStorer = (*NoteRepo)(nil) 24 + 25 +type NoteRepo struct { 26 + db *psqlutil.DB 27 +} 28 + 29 +func New(db *psqlutil.DB) NoteStorer { 30 + return &NoteRepo{db} 31 +} 32 + 33 +func (s *NoteRepo) Create(ctx context.Context, inp dtos.CreateNoteDTO) error { 34 + query, args, err := pgq. 35 + Insert("notes"). 36 + Columns("content", "slug", "burn_before_expiration ", "created_at", "expires_at"). 37 + Values(inp.Content, inp.Slug, inp.BurnBeforeExpiration, inp.CreatedAt, inp.ExpiresAt). 38 + SQL() 39 + if err != nil { 40 + return err 41 + } 42 + 43 + _, err = s.db.Exec(ctx, query, args...) 44 + if psqlutil.IsDuplicateErr(err) { 45 + return models.ErrNoteSlugIsAlreadyInUse 46 + } 47 + 48 + return err 49 +} 50 + 51 +func (s *NoteRepo) GetBySlug(ctx context.Context, slug dtos.NoteSlugDTO) (dtos.NoteDTO, error) { 52 + query, args, err := pgq. 53 + Select("content", "slug", "burn_before_expiration", "created_at", "expires_at"). 54 + From("notes"). 55 + Where("slug = ?", slug). 56 + SQL() 57 + if err != nil { 58 + return dtos.NoteDTO{}, err 59 + } 60 + 61 + var note dtos.NoteDTO 62 + err = s.db.QueryRow(ctx, query, args...). 63 + Scan(¬e.Content, ¬e.Slug, ¬e.BurnBeforeExpiration, ¬e.CreatedAt, ¬e.ExpiresAt) 64 + 65 + if errors.Is(err, pgx.ErrNoRows) { 66 + return dtos.NoteDTO{}, models.ErrNoteNotFound 67 + } 68 + 69 + return note, err 70 +} 71 + 72 +func (s *NoteRepo) DeleteBySlug(ctx context.Context, slug dtos.NoteSlugDTO) error { 73 + query, args, err := pgq. 74 + Delete("notes"). 75 + Where(pgq.Eq{"slug": slug}). 76 + SQL() 77 + if err != nil { 78 + return err 79 + } 80 + 81 + _, err = s.db.Exec(ctx, query, args...) 82 + if errors.Is(err, pgx.ErrNoRows) { 83 + return models.ErrNoteNotFound 84 + } 85 + 86 + return err 87 +} 88 + 89 +func (s *NoteRepo) SetAuthorIDBySlug( 90 + ctx context.Context, 91 + slug dtos.NoteSlugDTO, 92 + authorID uuid.UUID, 93 +) error { 94 + tx, err := s.db.Begin(ctx) 95 + if err != nil { 96 + return err 97 + } 98 + defer tx.Rollback(ctx) //nolint:errcheck 99 + 100 + var noteID uuid.UUID 101 + err = tx.QueryRow(ctx, "select id from notes where slug = $1", slug.String()).Scan(¬eID) 102 + if err != nil { 103 + if errors.Is(err, pgx.ErrNoRows) { 104 + return models.ErrNoteNotFound 105 + } 106 + return err 107 + } 108 + 109 + _, err = tx.Exec( 110 + ctx, 111 + "insert into notes_authors (note_id, user_id) values ($1, $2)", 112 + noteID, authorID, 113 + ) 114 + if err != nil { 115 + return err 116 + } 117 + 118 + return tx.Commit(ctx) 119 +}
M
internal/transport/http/apiv1/apiv1.go
··· 2 2 3 3 import ( 4 4 "github.com/gin-gonic/gin" 5 + "github.com/olexsmir/onasty/internal/service/notesrv" 5 6 "github.com/olexsmir/onasty/internal/service/usersrv" 6 7 ) 7 8 8 9 type APIV1 struct { 9 10 usersrv usersrv.UserServicer 11 + notesrv notesrv.NoteServicer 10 12 } 11 13 12 -func NewAPIV1(us usersrv.UserServicer) *APIV1 { 14 +func NewAPIV1( 15 + us usersrv.UserServicer, 16 + ns notesrv.NoteServicer, 17 +) *APIV1 { 13 18 return &APIV1{ 14 19 usersrv: us, 20 + notesrv: ns, 15 21 } 16 22 } 17 23 ··· 26 32 { 27 33 authorized.POST("/logout", a.logOutHandler) 28 34 } 35 + } 36 + 37 + note := r.Group("/note", a.couldBeAuthorizedMiddleware) 38 + { 39 + note.GET("/:slug", a.getNoteBySlugHandler) 40 + note.POST("", a.createNoteHandler) 29 41 } 30 42 }
M
internal/transport/http/apiv1/auth.go
··· 105 105 } 106 106 107 107 func (a *APIV1) logOutHandler(c *gin.Context) { 108 - if err := a.usersrv.Logout(c.Request.Context(), getUserID(c)); err != nil { 108 + if err := a.usersrv.Logout(c.Request.Context(), a.getUserID(c)); err != nil { 109 109 errorResponse(c, err) 110 110 return 111 111 }
M
internal/transport/http/apiv1/middleware.go
··· 40 40 c.Next() 41 41 } 42 42 43 -//nolint:unused // TODO: remove me later 44 43 func (a *APIV1) couldBeAuthorizedMiddleware(c *gin.Context) { 45 44 token, ok := getTokenFromAuthHeaders(c) 46 45 if ok { ··· 66 65 67 66 //nolint:unused // TODO: remove me later 68 67 func (a *APIV1) isUserAuthorized(c *gin.Context) bool { 69 - return !getUserID(c).IsNil() 68 + return !a.getUserID(c).IsNil() 70 69 } 71 70 72 71 func getTokenFromAuthHeaders(c *gin.Context) (token string, ok bool) { //nolint:nonamedreturns ··· 100 99 101 100 // getUserId returns userId from the context 102 101 // getting user id is only possible if user is authorized 103 -func getUserID(c *gin.Context) uuid.UUID { 102 +func (a *APIV1) getUserID(c *gin.Context) uuid.UUID { 104 103 userID, exists := c.Get(userIDCtxKey) 105 104 if !exists { 106 105 return uuid.Nil
A
internal/transport/http/apiv1/note.go
··· 1 +package apiv1 2 + 3 +import ( 4 + "log/slog" 5 + "net/http" 6 + "time" 7 + 8 + "github.com/gin-gonic/gin" 9 + "github.com/olexsmir/onasty/internal/dtos" 10 + "github.com/olexsmir/onasty/internal/models" 11 +) 12 + 13 +type createNoteRequest struct { 14 + Content string `json:"content"` 15 + Slug string `json:"slug"` 16 + BurnBeforeExpiration bool `json:"burn_before_expiration"` 17 + ExpiresAt time.Time `json:"expires_at"` 18 +} 19 + 20 +type createNoteResponse struct { 21 + Slug string `json:"slug"` 22 +} 23 + 24 +func (a *APIV1) createNoteHandler(c *gin.Context) { 25 + var req createNoteRequest 26 + if err := c.ShouldBindJSON(&req); err != nil { 27 + newError(c, http.StatusBadRequest, "invalid request") 28 + return 29 + } 30 + 31 + note := models.Note{ 32 + Content: req.Content, 33 + Slug: req.Slug, 34 + BurnBeforeExpiration: req.BurnBeforeExpiration, 35 + CreatedAt: time.Now(), 36 + ExpiresAt: req.ExpiresAt, 37 + } 38 + 39 + if err := note.Validate(); err != nil { 40 + newErrorStatus(c, http.StatusBadRequest, err.Error()) 41 + return 42 + } 43 + 44 + slog.Debug("userid", "a", a.getUserID(c)) 45 + 46 + slug, err := a.notesrv.Create(c.Request.Context(), dtos.CreateNoteDTO{ 47 + Content: note.Content, 48 + UserID: a.getUserID(c), 49 + Slug: note.Slug, 50 + BurnBeforeExpiration: note.BurnBeforeExpiration, 51 + CreatedAt: note.CreatedAt, 52 + ExpiresAt: note.ExpiresAt, 53 + }, a.getUserID(c)) 54 + if err != nil { 55 + errorResponse(c, err) 56 + return 57 + } 58 + 59 + c.JSON(http.StatusCreated, createNoteResponse{slug.String()}) 60 +} 61 + 62 +type getNoteBySlugResponse struct { 63 + Content string `json:"content"` 64 + CratedAt time.Time `json:"crated_at"` 65 + ExpiresAt time.Time `json:"expires_at"` 66 +} 67 + 68 +func (a *APIV1) getNoteBySlugHandler(c *gin.Context) { 69 + slug := c.Param("slug") 70 + note, err := a.notesrv.GetBySlugAndRemoveIfNeeded(c.Request.Context(), dtos.NoteSlugDTO(slug)) 71 + if err != nil { 72 + errorResponse(c, err) 73 + return 74 + } 75 + 76 + c.JSON(http.StatusOK, getNoteBySlugResponse{ 77 + Content: note.Content, 78 + CratedAt: note.CreatedAt, 79 + ExpiresAt: note.ExpiresAt, 80 + }) 81 +}
M
internal/transport/http/apiv1/response.go
··· 15 15 16 16 func errorResponse(c *gin.Context, err error) { 17 17 if errors.Is(err, models.ErrUserEmailIsAlreadyInUse) || 18 - errors.Is(err, models.ErrUsernameIsAlreadyInUse) { 18 + errors.Is(err, models.ErrUsernameIsAlreadyInUse) || 19 + errors.Is(err, models.ErrNoteContentIsEmpty) || 20 + errors.Is(err, models.ErrNoteSlugIsAlreadyInUse) { 19 21 newError(c, http.StatusBadRequest, err.Error()) 22 + return 23 + } 24 + 25 + if errors.Is(err, models.ErrNoteExpired) { 26 + newError(c, http.StatusGone, err.Error()) 27 + return 28 + } 29 + 30 + if errors.Is(err, models.ErrNoteNotFound) { 31 + newErrorStatus(c, http.StatusNotFound, err.Error()) 20 32 return 21 33 } 22 34 ··· 34 46 newInternalError(c, err) 35 47 } 36 48 37 -func newError(c *gin.Context, status int, msg string) { //nolint:unparam // TODO: remove me later 49 +func newError(c *gin.Context, status int, msg string) { 38 50 slog.Error(msg, "status", status) 39 51 c.AbortWithStatusJSON(status, response{msg}) 40 52 }
M
internal/transport/http/http.go
··· 4 4 "net/http" 5 5 6 6 "github.com/gin-gonic/gin" 7 + "github.com/olexsmir/onasty/internal/service/notesrv" 7 8 "github.com/olexsmir/onasty/internal/service/usersrv" 8 9 "github.com/olexsmir/onasty/internal/transport/http/apiv1" 9 10 ) 10 11 11 12 type Transport struct { 12 13 usersrv usersrv.UserServicer 14 + notesrv notesrv.NoteServicer 13 15 } 14 16 15 -func NewTransport(us usersrv.UserServicer) *Transport { 17 +func NewTransport( 18 + us usersrv.UserServicer, 19 + ns notesrv.NoteServicer, 20 +) *Transport { 16 21 return &Transport{ 17 22 usersrv: us, 23 + notesrv: ns, 18 24 } 19 25 } 20 26 ··· 27 33 28 34 api := r.Group("/api") 29 35 api.GET("/ping", t.pingHandler) 30 - apiv1.NewAPIV1(t.usersrv).Routes(api.Group("/v1")) 36 + apiv1.NewAPIV1(t.usersrv, t.notesrv).Routes(api.Group("/v1")) 31 37 32 38 return r.Handler() 33 39 }
A
migrations/20240716235210_notes.up.sql
··· 1 +create table notes ( 2 + id uuid primary key default uuid_generate_v4(), 3 + content text not null, 4 + slug varchar(255) not null unique, 5 + burn_before_expiration boolean default false, 6 + created_at timestamptz not null default now(), 7 + expires_at timestamptz 8 +);