diff --git a/.gitignore b/.gitignore index e660fd9..9a71d42 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ bin/ +*.env* diff --git a/chirpy b/chirpy new file mode 100755 index 0000000..1367632 Binary files /dev/null and b/chirpy differ diff --git a/go.mod b/go.mod index 14a776e..35748e2 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,9 @@ module github.com/StevanFreeborn/chirpy go 1.26.3 + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/lib/pq v1.12.3 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..cb0b5c5 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= diff --git a/internal/database/chirps.sql.go b/internal/database/chirps.sql.go new file mode 100644 index 0000000..c51b40a --- /dev/null +++ b/internal/database/chirps.sql.go @@ -0,0 +1,42 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: chirps.sql + +package database + +import ( + "context" + + "github.com/google/uuid" +) + +const createChirp = `-- name: CreateChirp :one +INSERT INTO chirps (id, created_at, updated_at, body, user_id) +VALUES ( + gen_random_uuid(), + NOW(), + NOW(), + $1, + $2 +) +RETURNING id, created_at, updated_at, body, user_id +` + +type CreateChirpParams struct { + Body string + UserID uuid.UUID +} + +func (q *Queries) CreateChirp(ctx context.Context, arg CreateChirpParams) (Chirp, error) { + row := q.db.QueryRowContext(ctx, createChirp, arg.Body, arg.UserID) + var i Chirp + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Body, + &i.UserID, + ) + return i, err +} diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..9f7a5a6 --- /dev/null +++ b/internal/database/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package database + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/database/models.go b/internal/database/models.go new file mode 100644 index 0000000..f33c6a8 --- /dev/null +++ b/internal/database/models.go @@ -0,0 +1,26 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package database + +import ( + "time" + + "github.com/google/uuid" +) + +type Chirp struct { + ID uuid.UUID + CreatedAt time.Time + UpdatedAt time.Time + Body string + UserID uuid.UUID +} + +type User struct { + ID uuid.UUID + CreatedAt time.Time + UpdatedAt time.Time + Email string +} diff --git a/internal/database/users.sql.go b/internal/database/users.sql.go new file mode 100644 index 0000000..29e2e3f --- /dev/null +++ b/internal/database/users.sql.go @@ -0,0 +1,61 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: users.sql + +package database + +import ( + "context" + + "github.com/google/uuid" +) + +const createUser = `-- name: CreateUser :one +INSERT INTO users (id, created_at, updated_at, email) +VALUES ( + gen_random_uuid(), + NOW(), + NOW(), + $1 +) +RETURNING id, created_at, updated_at, email +` + +func (q *Queries) CreateUser(ctx context.Context, email string) (User, error) { + row := q.db.QueryRowContext(ctx, createUser, email) + var i User + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Email, + ) + return i, err +} + +const deleteAllUsers = `-- name: DeleteAllUsers :exec +DELETE FROM users +` + +func (q *Queries) DeleteAllUsers(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteAllUsers) + return err +} + +const getUserById = `-- name: GetUserById :one +SELECT id, created_at, updated_at, email FROM users +WHERE id = $1 +` + +func (q *Queries) GetUserById(ctx context.Context, id uuid.UUID) (User, error) { + row := q.db.QueryRowContext(ctx, getUserById, id) + var i User + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Email, + ) + return i, err +} diff --git a/main.go b/main.go index 2602047..81bfb23 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "database/sql" "encoding/json" "errors" "fmt" @@ -14,22 +15,17 @@ import ( "sync/atomic" "syscall" "time" + + "github.com/StevanFreeborn/chirpy/internal/database" + "github.com/google/uuid" + "github.com/joho/godotenv" + _ "github.com/lib/pq" ) type server struct { fileServerHits atomic.Int32 -} - -type chirpRequest struct { - Body string `json:"body"` -} - -type validationError struct { - Err string `json:"error"` -} - -type chirpResponse struct { - CleanedBody string `json:"cleaned_body"` + database *database.Queries + platform string } func writeJsonResponse(w http.ResponseWriter, response any) { @@ -56,8 +52,15 @@ func (s *server) HandleHealthChecks(w http.ResponseWriter, r *http.Request) { } func (s *server) HandleReset(w http.ResponseWriter, r *http.Request) { + if s.platform != "dev" { + w.WriteHeader(http.StatusForbidden) + return + } + s.fileServerHits.Store(0) + s.database.DeleteAllUsers(r.Context()) + w.Header().Add("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) @@ -81,38 +84,75 @@ func (s *server) HandleMetrics(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, template, hits) } -func (s *server) HandleChirpValidation(w http.ResponseWriter, r *http.Request) { +type apiError struct { + Err string `json:"error"` +} + +type createChirpRequest struct { + Body string `json:"body"` + UserId string `json:"user_id"` +} + +type createChirpResponse struct { + Id string `json:"id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Body string `json:"body"` + UserId string `json:"user_id"` +} + +func (s *server) HandleCreateChirp(w http.ResponseWriter, r *http.Request) { blacklist := []string{ "kerfuffle", "sharbert", "fornax", } - var chirpRequest chirpRequest + var createChirpRequest createChirpRequest defer r.Body.Close() w.Header().Add("Content-Type", "application/json") - err := json.NewDecoder(r.Body).Decode(&chirpRequest) + err := json.NewDecoder(r.Body).Decode(&createChirpRequest) if err != nil { w.WriteHeader(http.StatusBadRequest) - writeJsonResponse(w, validationError{ + writeJsonResponse(w, apiError{ Err: "Unable to deserialize JSON", }) return } - if len(chirpRequest.Body) > 140 { + if len(createChirpRequest.Body) > 140 { w.WriteHeader(http.StatusBadRequest) - writeJsonResponse(w, validationError{ + writeJsonResponse(w, apiError{ Err: "Chirp is too long", }) return } - words := strings.Split(chirpRequest.Body, " ") + requestUserId, err := uuid.Parse(createChirpRequest.UserId) + + invalidUserIdError := apiError{ + Err: "Invalid user_id. user_id must be valid UUID", + } + + if err != nil { + w.WriteHeader(http.StatusBadRequest) + writeJsonResponse(w, invalidUserIdError) + return + } + + existingUser, err := s.database.GetUserById(r.Context(), requestUserId) + + if err != nil { + w.WriteHeader(http.StatusBadRequest) + writeJsonResponse(w, invalidUserIdError) + return + } + + words := strings.Split(createChirpRequest.Body, " ") sanitized := []string{} for _, word := range words { @@ -126,12 +166,105 @@ func (s *server) HandleChirpValidation(w http.ResponseWriter, r *http.Request) { cleanedBody := strings.Join(sanitized, " ") - w.WriteHeader(http.StatusOK) - writeJsonResponse(w, chirpResponse{CleanedBody: cleanedBody}) + createChirpParams := database.CreateChirpParams{ + Body: cleanedBody, + UserID: existingUser.ID, + } + + createdChirp, err := s.database.CreateChirp(r.Context(), createChirpParams) + + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + writeJsonResponse(w, apiError{ + Err: "Failed to create chirp. 🤷🏻‍♂️", + }) + } + + w.WriteHeader(http.StatusCreated) + writeJsonResponse(w, createChirpResponse{ + Id: createdChirp.ID.String(), + CreatedAt: createdChirp.CreatedAt.Format(time.RFC3339), + UpdatedAt: createdChirp.UpdatedAt.Format(time.RFC3339), + Body: createdChirp.Body, + UserId: createdChirp.UserID.String(), + }) +} + +type createUserRequest struct { + Email string `json:"email"` +} + +type createUserResponse struct { + Id string `json:"id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Email string `json:"email"` +} + +func (s *server) HandleCreateUser(w http.ResponseWriter, r *http.Request) { + var createUserRequest createUserRequest + + defer r.Body.Close() + + err := json.NewDecoder(r.Body).Decode(&createUserRequest) + + if err != nil { + w.WriteHeader(http.StatusBadRequest) + writeJsonResponse(w, apiError{ + Err: "Unable to deserialize JSON", + }) + } + + trimmedEmail := strings.TrimSpace(createUserRequest.Email) + + if strings.TrimSpace(trimmedEmail) == "" { + w.WriteHeader(http.StatusBadRequest) + writeJsonResponse(w, apiError{ + Err: "email is required. must be valid email address.", + }) + } + + createdUser, err := s.database.CreateUser(r.Context(), trimmedEmail) + + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + writeJsonResponse(w, apiError{ + Err: "Uh oh we were unable to create a new user", + }) + } + + w.WriteHeader(http.StatusCreated) + writeJsonResponse(w, createUserResponse{ + Id: createdUser.ID.String(), + CreatedAt: createdUser.CreatedAt.Format(time.RFC3339), + UpdatedAt: createdUser.UpdatedAt.Format(time.RFC3339), + Email: createdUser.Email, + }) } func main() { - server := &server{} + err := godotenv.Load() + + if err != nil { + log.Fatalf("Failed to load environment variables") + os.Exit(1) + } + + dbURL := os.Getenv("DB_URL") + platform := os.Getenv("PLATFORM") + + db, err := sql.Open("postgres", dbURL) + + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + os.Exit(1) + } + + server := &server{ + database: database.New(db), + platform: platform, + } + mux := http.NewServeMux() appRoot := "/app/" @@ -140,9 +273,12 @@ func main() { mux.HandleFunc("GET /admin/metrics", server.HandleMetrics) mux.HandleFunc("POST /admin/reset", server.HandleReset) - mux.HandleFunc("POST /api/validate_chirp", server.HandleChirpValidation) mux.HandleFunc("GET /api/healthz", server.HandleHealthChecks) + mux.HandleFunc("POST /api/users", server.HandleCreateUser) + + mux.HandleFunc("POST /api/chirps", server.HandleCreateChirp) + port := ":8080" httpServer := http.Server{ diff --git a/sql/queries/chirps.sql b/sql/queries/chirps.sql new file mode 100644 index 0000000..664a1a5 --- /dev/null +++ b/sql/queries/chirps.sql @@ -0,0 +1,10 @@ +-- name: CreateChirp :one +INSERT INTO chirps (id, created_at, updated_at, body, user_id) +VALUES ( + gen_random_uuid(), + NOW(), + NOW(), + $1, + $2 +) +RETURNING *; diff --git a/sql/queries/users.sql b/sql/queries/users.sql new file mode 100644 index 0000000..c65a5c6 --- /dev/null +++ b/sql/queries/users.sql @@ -0,0 +1,17 @@ +-- name: CreateUser :one +INSERT INTO users (id, created_at, updated_at, email) +VALUES ( + gen_random_uuid(), + NOW(), + NOW(), + $1 +) +RETURNING *; + + +-- name: DeleteAllUsers :exec +DELETE FROM users; + +-- name: GetUserById :one +SELECT id, created_at, updated_at, email FROM users +WHERE id = $1; diff --git a/sql/schema/001_users.sql b/sql/schema/001_users.sql new file mode 100644 index 0000000..05a36cf --- /dev/null +++ b/sql/schema/001_users.sql @@ -0,0 +1,10 @@ +-- +goose Up +CREATE TABLE users ( + id UUID PRIMARY KEY, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + email TEXT UNIQUE NOT NULL +); + +-- +goose Down +DROP TABLE users; diff --git a/sql/schema/002_chirps.sql b/sql/schema/002_chirps.sql new file mode 100644 index 0000000..0082da3 --- /dev/null +++ b/sql/schema/002_chirps.sql @@ -0,0 +1,12 @@ +-- +goose Up +CREATE TABLE chirps ( + id UUID PRIMARY KEY, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + body TEXT NOT NULL, + user_id UUID NOT NULL, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +-- +goose Down +DROP TABLE chirps; diff --git a/sqlc.yml b/sqlc.yml new file mode 100644 index 0000000..08ef51e --- /dev/null +++ b/sqlc.yml @@ -0,0 +1,8 @@ +version: "2" +sql: + - schema: "sql/schema" + queries: "sql/queries" + engine: "postgresql" + gen: + go: + out: "internal/database"