feat: first commit

This commit is contained in:
sittichok Ouamsiri
2026-06-15 21:25:57 +07:00
commit 3395ab6dd3
88 changed files with 10034 additions and 0 deletions
+198
View File
@@ -0,0 +1,198 @@
package api_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/snip/backend/internal/adapter/memory"
"github.com/snip/backend/internal/adapter/security"
"github.com/snip/backend/internal/config"
"github.com/snip/backend/internal/domain"
"github.com/snip/backend/internal/httpx/api"
"github.com/snip/backend/internal/service"
)
type harness struct {
h http.Handler
token string
}
func newHarness(t *testing.T) *harness {
t.Helper()
repo := memory.NewLinkRepo()
cache := memory.NewCache()
users := memory.NewUserRepo()
hasher := security.NewBcryptHasher()
sessions := security.NewHMACSessions("test-secret")
links := service.NewLinkService(repo, cache, hasher)
auth := service.NewAuthService(nil, users, sessions)
cfg := config.Config{PostLoginRedirect: "/dashboard"}
user, err := users.Upsert(context.Background(), &domain.User{
Email: "[email protected]", Name: "Sam", Provider: "test", Subject: "s1",
})
if err != nil {
t.Fatal(err)
}
token, _ := sessions.Issue(user.ID, time.Hour)
return &harness{h: api.New(links, auth, sessions, cfg).Handler(), token: token}
}
func (h *harness) do(t *testing.T, method, path, body, token string) *httptest.ResponseRecorder {
t.Helper()
var r *http.Request
if body != "" {
r = httptest.NewRequest(method, path, bytes.NewBufferString(body))
} else {
r = httptest.NewRequest(method, path, nil)
}
if token != "" {
r.AddCookie(&http.Cookie{Name: "snip_session", Value: token})
}
w := httptest.NewRecorder()
h.h.ServeHTTP(w, r)
return w
}
func decodeLink(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil {
t.Fatalf("bad json (%d): %s", w.Code, w.Body.String())
}
return m
}
func TestAnonCanCreateRandomButNotList(t *testing.T) {
h := newHarness(t)
w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com/x","mode":"random"}`, "")
if w.Code != http.StatusCreated {
t.Fatalf("create: want 201, got %d: %s", w.Code, w.Body.String())
}
if code, _ := decodeLink(t, w)["code"].(string); code == "" {
t.Fatal("expected a code")
}
if w := h.do(t, http.MethodGet, "/api/v1/links", "", ""); w.Code != http.StatusUnauthorized {
t.Fatalf("anon list: want 401, got %d", w.Code)
}
}
func TestCustomAndReservedAndConflict(t *testing.T) {
h := newHarness(t)
// reserved alias → 409
if w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"custom","customAlias":"dashboard"}`, h.token); w.Code != http.StatusConflict {
t.Fatalf("reserved: want 409, got %d", w.Code)
}
// valid custom → 201
if w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"custom","customAlias":"promo"}`, h.token); w.Code != http.StatusCreated {
t.Fatalf("custom: want 201, got %d: %s", w.Code, w.Body.String())
}
// duplicate → 409
if w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"custom","customAlias":"promo"}`, h.token); w.Code != http.StatusConflict {
t.Fatalf("dupe: want 409, got %d", w.Code)
}
// anon custom → 401
if w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"custom","customAlias":"x9z"}`, ""); w.Code != http.StatusUnauthorized {
t.Fatalf("anon custom: want 401, got %d", w.Code)
}
}
func TestFullLifecycleWithPin(t *testing.T) {
h := newHarness(t)
// create
w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com/a","mode":"random"}`, h.token)
id := decodeLink(t, w)["id"].(string)
// list shows it
w = h.do(t, http.MethodGet, "/api/v1/links", "", h.token)
if w.Code != http.StatusOK {
t.Fatalf("list: %d", w.Code)
}
if total := listTotal(t, w); total != 1 {
t.Fatalf("want total 1, got %d", total)
}
// patch destination
w = h.do(t, http.MethodPatch, "/api/v1/links/"+id, `{"longUrl":"acme.com/b"}`, h.token)
if w.Code != http.StatusOK || decodeLink(t, w)["longUrl"] != "https://acme.com/b" {
t.Fatalf("patch failed: %d %s", w.Code, w.Body.String())
}
// set pin
w = h.do(t, http.MethodPut, "/api/v1/links/"+id+"/pin", `{"pin":"123456"}`, h.token)
if w.Code != http.StatusOK || decodeLink(t, w)["hasPin"] != true {
t.Fatalf("set pin failed: %d %s", w.Code, w.Body.String())
}
// bad pin → 400
if w := h.do(t, http.MethodPut, "/api/v1/links/"+id+"/pin", `{"pin":"12"}`, h.token); w.Code != http.StatusBadRequest {
t.Fatalf("bad pin: want 400, got %d", w.Code)
}
// remove pin
w = h.do(t, http.MethodDelete, "/api/v1/links/"+id+"/pin", "", h.token)
if w.Code != http.StatusOK || decodeLink(t, w)["hasPin"] != false {
t.Fatalf("remove pin failed: %d", w.Code)
}
// delete
if w := h.do(t, http.MethodDelete, "/api/v1/links/"+id, "", h.token); w.Code != http.StatusNoContent {
t.Fatalf("delete: want 204, got %d", w.Code)
}
// gone from list
w = h.do(t, http.MethodGet, "/api/v1/links", "", h.token)
if total := listTotal(t, w); total != 0 {
t.Fatalf("want 0 links after delete, got %d", total)
}
}
func listTotal(t *testing.T, w *httptest.ResponseRecorder) int {
t.Helper()
var resp struct {
Items []map[string]any `json:"items"`
Total int `json:"total"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("bad list json: %s", w.Body.String())
}
return resp.Total
}
func TestPatchForbiddenForOtherUser(t *testing.T) {
h := newHarness(t)
w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"random"}`, h.token)
id := decodeLink(t, w)["id"].(string)
other := security.NewHMACSessions("test-secret")
otherToken, _ := other.Issue("someone-else", time.Hour)
if w := h.do(t, http.MethodPatch, "/api/v1/links/"+id, `{"longUrl":"https://evil.com"}`, otherToken); w.Code != http.StatusForbidden {
t.Fatalf("want 403 for non-owner, got %d", w.Code)
}
}
func TestMeRequiresSession(t *testing.T) {
h := newHarness(t)
if w := h.do(t, http.MethodGet, "/api/v1/auth/me", "", ""); w.Code != http.StatusUnauthorized {
t.Fatalf("me anon: want 401, got %d", w.Code)
}
w := h.do(t, http.MethodGet, "/api/v1/auth/me", "", h.token)
if w.Code != http.StatusOK || decodeLink(t, w)["email"] != "[email protected]" {
t.Fatalf("me: %d %s", w.Code, w.Body.String())
}
}
func TestHealthz(t *testing.T) {
h := newHarness(t)
if w := h.do(t, http.MethodGet, "/api/v1/healthz", "", ""); w.Code != http.StatusOK {
t.Fatalf("healthz: %d", w.Code)
}
}
+88
View File
@@ -0,0 +1,88 @@
package api
import (
"net/http"
"strings"
"time"
"github.com/snip/backend/internal/domain"
)
// /api/v1/auth/...
// GET providers
// GET {provider}/login
// GET {provider}/callback
// GET me
// POST logout
func (a *API) authRoutes(w http.ResponseWriter, r *http.Request) {
rest := pathTail(r.URL.Path, "/api/v1/auth/")
parts := strings.Split(strings.Trim(rest, "/"), "/")
switch {
case len(parts) == 1 && parts[0] == "providers":
writeJSON(w, http.StatusOK, map[string][]string{"providers": a.auth.Providers()})
case len(parts) == 1 && parts[0] == "me":
a.me(w, r)
case len(parts) == 1 && parts[0] == "logout":
a.clearCookie(w, sessionCookie)
w.WriteHeader(http.StatusNoContent)
case len(parts) == 2 && parts[1] == "login":
a.beginLogin(w, r, parts[0])
case len(parts) == 2 && parts[1] == "callback":
a.callback(w, r, parts[0])
default:
w.WriteHeader(http.StatusNotFound)
}
}
func (a *API) me(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(sessionCookie)
if err != nil {
writeError(w, domain.ErrUnauthorized)
return
}
user, err := a.auth.Me(r.Context(), c.Value)
if err != nil {
writeError(w, domain.ErrUnauthorized)
return
}
writeJSON(w, http.StatusOK, userDTO{ID: user.ID, Email: user.Email, Name: user.Name})
}
func (a *API) beginLogin(w http.ResponseWriter, r *http.Request, provider string) {
state := randomState()
url, err := a.auth.AuthURL(provider, state)
if err != nil {
writeError(w, err)
return
}
// Double-submit: stash state in a short-lived cookie, compare on callback.
http.SetCookie(w, &http.Cookie{
Name: stateCookie, Value: state, Path: "/", HttpOnly: true,
Secure: a.cfg.CookieSecure, SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(10 * time.Minute),
})
http.Redirect(w, r, url, http.StatusFound)
}
func (a *API) callback(w http.ResponseWriter, r *http.Request, provider string) {
cookie, err := r.Cookie(stateCookie)
if err != nil || cookie.Value == "" || cookie.Value != r.URL.Query().Get("state") {
writeError(w, domain.ErrUnauthorized)
return
}
a.clearCookie(w, stateCookie)
code := r.URL.Query().Get("code")
if code == "" {
writeError(w, domain.ErrUnauthorized)
return
}
_, token, err := a.auth.Complete(r.Context(), provider, code)
if err != nil {
writeError(w, err)
return
}
a.setSession(w, token)
http.Redirect(w, r, a.cfg.PostLoginRedirect, http.StatusFound)
}
+69
View File
@@ -0,0 +1,69 @@
package api
import (
"time"
"github.com/snip/backend/internal/domain"
)
// linkDTO is the JSON shape returned to the SPA. It intentionally matches the
// frontend's `ShortLink` type so the mock API is a drop-in swap. PinHash is
// never included.
type linkDTO struct {
ID string `json:"id"`
Code string `json:"code"`
LongURL string `json:"longUrl"`
Mode string `json:"mode"`
HasPin bool `json:"hasPin"`
CreatedAt string `json:"createdAt"`
TotalClicks int64 `json:"totalClicks"`
Last7Days []domain.DayCount `json:"last7Days"`
Owned bool `json:"owned"`
}
func toDTO(l *domain.Link) linkDTO {
week := l.Last7Days
if week == nil {
week = []domain.DayCount{}
}
return linkDTO{
ID: l.ID,
Code: l.Code,
LongURL: l.LongURL,
Mode: string(l.Mode),
HasPin: l.HasPin,
CreatedAt: l.CreatedAt.Format(time.RFC3339),
TotalClicks: l.TotalClicks,
Last7Days: week,
Owned: l.OwnerID != "",
}
}
// listResponse is the paginated link listing returned to the dashboard.
type listResponse struct {
Items []linkDTO `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
type createReq struct {
LongURL string `json:"longUrl"`
Mode string `json:"mode"`
CustomAlias string `json:"customAlias"`
Pin string `json:"pin"`
}
type updateReq struct {
LongURL string `json:"longUrl"`
}
type pinReq struct {
Pin string `json:"pin"`
}
type userDTO struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
}
+148
View File
@@ -0,0 +1,148 @@
package api
import (
"net/http"
"strconv"
"strings"
"github.com/snip/backend/internal/domain"
"github.com/snip/backend/internal/service"
)
// /api/v1/links — POST create (auth optional), GET list (auth required)
func (a *API) linksCollection(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
a.createLink(w, r)
case http.MethodGet:
a.listLinks(w, r)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (a *API) createLink(w http.ResponseWriter, r *http.Request) {
var req createReq
if err := decode(r, &req); err != nil {
writeError(w, domain.ErrInvalidURL)
return
}
mode := domain.Mode(req.Mode)
if mode != domain.ModeRandom && mode != domain.ModeMemorable && mode != domain.ModeCustom {
mode = domain.ModeRandom
}
link, err := a.links.Create(r.Context(), service.CreateInput{
LongURL: req.LongURL,
Mode: mode,
CustomAlias: req.CustomAlias,
Pin: req.Pin,
}, a.userID(r))
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusCreated, toDTO(link))
}
func (a *API) listLinks(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
page, _ := strconv.Atoi(q.Get("page"))
pageSize, _ := strconv.Atoi(q.Get("pageSize"))
result, err := a.links.List(r.Context(), a.userID(r), q.Get("q"), page, pageSize)
if err != nil {
writeError(w, err)
return
}
items := make([]linkDTO, 0, len(result.Items))
for i := range result.Items {
items = append(items, toDTO(&result.Items[i]))
}
writeJSON(w, http.StatusOK, listResponse{
Items: items,
Total: result.Total,
Page: result.Page,
PageSize: result.PageSize,
})
}
func (a *API) linkStats(w http.ResponseWriter, r *http.Request) {
stats, err := a.links.Stats(r.Context(), a.userID(r))
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, stats)
}
// /api/v1/links/{id} — PATCH, DELETE
// /api/v1/links/{id}/pin — PUT, DELETE
func (a *API) linkItem(w http.ResponseWriter, r *http.Request) {
rest := pathTail(r.URL.Path, "/api/v1/links/")
parts := strings.Split(strings.Trim(rest, "/"), "/")
id := parts[0]
if id == "" {
w.WriteHeader(http.StatusNotFound)
return
}
owner := a.userID(r)
// /{id}/pin
if len(parts) == 2 && parts[1] == "pin" {
a.managePin(w, r, id, owner)
return
}
if len(parts) != 1 {
w.WriteHeader(http.StatusNotFound)
return
}
switch r.Method {
case http.MethodPatch:
var req updateReq
if err := decode(r, &req); err != nil {
writeError(w, domain.ErrInvalidURL)
return
}
link, err := a.links.UpdateDestination(r.Context(), id, owner, req.LongURL)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, toDTO(link))
case http.MethodDelete:
if err := a.links.Delete(r.Context(), id, owner); err != nil {
writeError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (a *API) managePin(w http.ResponseWriter, r *http.Request, id, owner string) {
switch r.Method {
case http.MethodPut:
var req pinReq
if err := decode(r, &req); err != nil {
writeError(w, domain.ErrInvalidPin)
return
}
link, err := a.links.SetPin(r.Context(), id, owner, req.Pin)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, toDTO(link))
case http.MethodDelete:
link, err := a.links.SetPin(r.Context(), id, owner, "")
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, toDTO(link))
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
+152
View File
@@ -0,0 +1,152 @@
// Package api is the inbound HTTP adapter for the /api/v1 surface.
package api
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/snip/backend/internal/config"
"github.com/snip/backend/internal/domain"
"github.com/snip/backend/internal/port"
"github.com/snip/backend/internal/service"
)
const (
sessionCookie = "snip_session"
stateCookie = "snip_oauth_state"
)
// API wires the link and auth use cases to HTTP.
type API struct {
links *service.LinkService
auth *service.AuthService
sessions port.SessionManager
cfg config.Config
}
func New(links *service.LinkService, auth *service.AuthService, sessions port.SessionManager, cfg config.Config) *API {
return &API{links: links, auth: auth, sessions: sessions, cfg: cfg}
}
// Handler returns the routed, CORS-wrapped /api/v1 handler.
func (a *API) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/healthz", a.health)
mux.HandleFunc("/api/v1/config", a.config)
mux.HandleFunc("/api/v1/links/stats", a.linkStats) // exact: wins over /links/
mux.HandleFunc("/api/v1/links", a.linksCollection)
mux.HandleFunc("/api/v1/links/", a.linkItem)
mux.HandleFunc("/api/v1/auth/", a.authRoutes)
return cors(mux)
}
func (a *API) health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// config exposes client-relevant, non-secret settings (e.g. the short domain),
// so the frontend doesn't hardcode them.
func (a *API) config(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"shortDomain": a.cfg.ShortDomain})
}
// --- helpers ---
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, err error) {
status := http.StatusInternalServerError
switch {
case errors.Is(err, domain.ErrUnauthorized):
status = http.StatusUnauthorized
case errors.Is(err, domain.ErrForbidden):
status = http.StatusForbidden
case errors.Is(err, domain.ErrNotFound):
status = http.StatusNotFound
case errors.Is(err, domain.ErrCodeTaken), errors.Is(err, domain.ErrReserved):
status = http.StatusConflict
case errors.Is(err, domain.ErrInvalidURL), errors.Is(err, domain.ErrInvalidAlias),
errors.Is(err, domain.ErrInvalidPin), errors.Is(err, domain.ErrPinInvalid),
errors.Is(err, domain.ErrPinRequired):
status = http.StatusBadRequest
}
writeJSON(w, status, map[string]string{"error": err.Error()})
}
// userID extracts and verifies the caller's session, or "" if anonymous.
func (a *API) userID(r *http.Request) string {
c, err := r.Cookie(sessionCookie)
if err != nil {
return ""
}
id, err := a.sessions.Verify(c.Value)
if err != nil {
return ""
}
return id
}
func (a *API) setSession(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: token,
Path: "/",
HttpOnly: true,
Secure: a.cfg.CookieSecure,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(30 * 24 * time.Hour),
})
}
func (a *API) clearCookie(w http.ResponseWriter, name string) {
http.SetCookie(w, &http.Cookie{
Name: name, Value: "", Path: "/", HttpOnly: true,
Secure: a.cfg.CookieSecure, SameSite: http.SameSiteLaxMode, MaxAge: -1,
})
}
func randomState() string {
b := make([]byte, 24)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func decode(r *http.Request, v any) error {
defer r.Body.Close()
return json.NewDecoder(r.Body).Decode(v)
}
// cors reflects the request Origin and allows credentials (cookies). In
// production the SPA and API are same-origin behind a gateway; this keeps local
// dev (vite :5173 → api :8080) working.
func cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PATCH,PUT,DELETE,OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// pathTail returns the part of the request path after a prefix.
func pathTail(path, prefix string) string {
return strings.TrimPrefix(path, prefix)
}