From deec440038d9a651803b2c7ed25b90120e0e2d78 Mon Sep 17 00:00:00 2001 From: xkm Date: Wed, 12 Nov 2025 19:55:29 +0800 Subject: [PATCH] init. --- .gitignore | 4 ++ db.go | 30 ++++++++ go.mod | 8 +++ go.sum | 4 ++ handler.go | 197 +++++++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 45 ++++++++++++ respose.go | 31 +++++++++ 7 files changed, 319 insertions(+) create mode 100644 .gitignore create mode 100644 db.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 handler.go create mode 100644 main.go create mode 100644 respose.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..25d2e51 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +.envrc +mysql/ +run_db.sh diff --git a/db.go b/db.go new file mode 100644 index 0000000..a6c9d26 --- /dev/null +++ b/db.go @@ -0,0 +1,30 @@ +package main + +import ( + "context" + "database/sql" + "log" + "os" +) + +func openDB() (*sql.DB, error) { + dsn := os.Getenv("MYSQL_DSN") + if dsn == "" { + log.Fatal("MYSQL_DSN is empty!") + } + return sql.Open("mysql", dsn) +} + +func migrate(ctx context.Context, db *sql.DB) error { + ddl := ` +CREATE TABLE IF NOT EXISTS students ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(100) NOT NULL, + age INT NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;` + _, err := db.ExecContext(ctx, ddl) + return err +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fe1bf1a --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module work3 + +go 1.25.4 + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/go-sql-driver/mysql v1.9.3 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4bcdcfa --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= diff --git a/handler.go b/handler.go new file mode 100644 index 0000000..06ea9aa --- /dev/null +++ b/handler.go @@ -0,0 +1,197 @@ +package main + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + "time" +) + +func healthHandler(w http.ResponseWriter, r *http.Request) { + now := time.Now().Format(time.RFC3339) + ok(w, &map[string]string{"status": "UP", "time": now}) +} + +func studentsHandler(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + listStudents(w, r) + case http.MethodPost: + createStudent(w, r) + default: + w.Header().Set("Allow", "GET, POST") + fail(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func studentByIDHandler(w http.ResponseWriter, r *http.Request) { + idStr := strings.TrimPrefix(r.URL.Path, "/students/") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "invalid student id") + return + } + + switch r.Method { + case http.MethodGet: + getStudent(w, r, id) + case http.MethodPut: + updateStudent(w, r, id) + case http.MethodDelete: + deleteStudent(w, r, id) + default: + w.Header().Set("Allow", "GET, PUT, DELETE") + fail(w, http.StatusMethodNotAllowed, "method not allowed") + } + +} + +func getStudent(w http.ResponseWriter, r *http.Request, id int64) { + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + defer cancel() + + var s Student + + err := db.QueryRowContext(ctx, + `SELECT id,name,age,email,created_at,updated_at FROM students WHERE id=?`, id, + ).Scan(&s.ID, &s.Name, &s.Age, &s.Email, &s.CreatedAt, &s.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + fail(w, http.StatusNotFound, "student not found") + return + } else if err != nil { + fail(w, http.StatusInternalServerError, "db query error: "+err.Error()) + return + } + ok(w, &s) +} + +func updateStudent(w http.ResponseWriter, r *http.Request, id int64) { + if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(strings.ToLower(ct), "application/json") { + fail(w, http.StatusUnsupportedMediaType, "Content-Type must be application/json") + return + } + + var in struct { + Name string `json:"name"` + Age int `json:"age"` + Email string `json:"email"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + fail(w, http.StatusBadRequest, "invalid json: "+err.Error()) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + defer cancel() + + res, err := db.ExecContext(ctx, + `UPDATE students SET name=?, age=?, email=? WHERE id=?`, + in.Name, in.Age, in.Email, id) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "duplicate") { + fail(w, http.StatusConflict, "email already exists") + return + } + fail(w, http.StatusInternalServerError, "update error: "+err.Error()) + return + } + n, _ := res.RowsAffected() + if n == 0 { + fail(w, http.StatusNotFound, "student not found") + return + } + + getStudent(w, r, id) +} + +func deleteStudent(w http.ResponseWriter, r *http.Request, id int64) { + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + defer cancel() + + res, err := db.ExecContext(ctx, `DELETE FROM students WHERE id=?`, id) + if err != nil { + fail(w, http.StatusInternalServerError, "delete error: "+err.Error()) + return + } + n, _ := res.RowsAffected() + if n == 0 { + fail(w, http.StatusNotFound, "student not found") + return + } + + ok(w, &map[string]any{"deleted": id}) +} + +func createStudent(w http.ResponseWriter, r *http.Request) { + if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(strings.ToLower(ct), "application/json") { + fail(w, http.StatusUnsupportedMediaType, "Context-Type must be application/json") + return + } + var in struct { + Name string `json:"name"` + Age int `json:"age"` + Email string `json:"email"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + fail(w, http.StatusBadRequest, "invalid json: "+err.Error()) + return + } + if strings.TrimSpace(in.Name) == "" || in.Age <= 0 || strings.TrimSpace(in.Email) == "" { + fail(w, http.StatusBadRequest, "name/age/email required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + defer cancel() + + res, err := db.ExecContext(ctx, + `INSERT INTO students(name, age, email) VALUES(?,?,?)`, + in.Name, in.Age, in.Email) + if err != nil { + fail(w, http.StatusInternalServerError, "insert error: "+err.Error()) + return + } + id, _ := res.LastInsertId() + + var out Student + err = db.QueryRowContext(ctx, + `SELECT id,name,age,email,created_at,updated_at FROM students WHERE id=?`, id, + ).Scan(&out.ID, &out.Name, &out.Age, &out.Email, &out.CreatedAt, &out.UpdatedAt) + if err != nil { + fail(w, http.StatusInternalServerError, "fetch created row error: "+err.Error()) + return + } + + created(w, "/students/"+strconv.FormatInt(out.ID, 10), &out) +} + +func listStudents(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + defer cancel() + rows, err := db.QueryContext(ctx, + `SELECT id,name,age,email,created_at,updated_at FROM students ORDER BY id`) + if err != nil { + fail(w, http.StatusInternalServerError, "db query error: "+err.Error()) + return + } + defer rows.Close() + + var items []Student + for rows.Next() { + var s Student + if err := rows.Scan(&s.ID, &s.Name, &s.Age, &s.Email, &s.CreatedAt, &s.UpdatedAt); err != nil { + fail(w, http.StatusInternalServerError, "scan error: "+err.Error()) + return + } + items = append(items, s) + } + if err := rows.Err(); err != nil { + fail(w, http.StatusInternalServerError, "rows error: "+err.Error()) + return + } + ok(w, &items) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..34aeb79 --- /dev/null +++ b/main.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "database/sql" + "log" + "net/http" + "time" + + _ "github.com/go-sql-driver/mysql" +) + +var db *sql.DB + +type Student struct { + ID int64 `json:"id"` + Name string `json:"name"` + Age int `json:"age"` + Email string `json:"email"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func main() { + mux := http.NewServeMux() + mux.HandleFunc("/health", healthHandler) + mux.HandleFunc("/students", studentsHandler) + mux.HandleFunc("/students/", studentByIDHandler) + + var err error + db, err = openDB() + if err != nil { + log.Fatal(err) + } + + if err = migrate(context.Background(), db); err != nil { + log.Fatal("migrate error: "+err.Error()) + } + + addr := ":8080" + log.Printf("Start server at %s\n", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatal(err) + } +} diff --git a/respose.go b/respose.go new file mode 100644 index 0000000..6f2ce46 --- /dev/null +++ b/respose.go @@ -0,0 +1,31 @@ +package main + +import ( + "encoding/json" + "net/http" +) + +type Result[T any] struct { + Code int `json:"code"` + Message string `json:"message"` + Data *T `json:"data,omitempty"` +} + +func writeJSON[T any](w http.ResponseWriter, status int, payload Result[T]) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + json.NewEncoder(w).Encode(payload) +} + +func ok[T any](w http.ResponseWriter, data *T) { + writeJSON(w, http.StatusOK, Result[T]{Code: 0, Message: "OK", Data: data}) +} + +func created[T any](w http.ResponseWriter, location string, data *T) { + w.Header().Set("Location", location) + writeJSON(w, http.StatusCreated, Result[T]{Code: 0, Message: "Created", Data: data}) +} + +func fail(w http.ResponseWriter, status int, msg string) { + writeJSON[any](w, status, Result[any]{Code: status, Message: msg, Data: nil}) +}