Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 54 additions & 6 deletions apis.rest
Original file line number Diff line number Diff line change
@@ -1,16 +1,64 @@
@base_url = http://localhost:8080


### Create a contact
### Create Contact – Valid Request
POST {{base_url}}/api/contacts
Content-Type: application/json

{
"first_name": "Rakesh",
"last_name": "K",
"email": "rakesh@codersgyan.com",
"last_name": "K",
"email": "rakesh@codersgyan.com",
"phone": "+919876543210",
"tags": [
{ "text": "purchased:mern" },
{"text": "purchased:devops"}
{ "text": "purchased:devops" }
]
}
}

> {%
client.test("Request executed successfully", function() {
client.assert(response.status === 201, "Expected 201 Created");
client.assert(response.body.includes('"id":'), "Response should contain created contact ID");
});
%}

### Create Contact – Validation Errors (400)
POST {{base_url}}/api/contacts
Content-Type: application/json

{
"first_name": "",
"last_name": "D",
"email": "not-an-email",
"phone": "12345",
"tags": []
}

> {%
client.test("Returns 400 with validation errors", function() {
client.assert(response.status === 400, "Expected 400 Bad Request");
const body = response.body;
client.assert(body.includes("first_name") && body.includes("required"), "Missing first_name required error");
client.assert(body.includes("invalid email format"), "Missing email format error");
client.assert(body.includes("phone"), "Missing phone validation error");
});
%}

### POST /api/contacts – Validation Rules
# Required fields:
# - first_name → max 100 characters
# - last_name → max 100 characters
# - email → valid format, max 255 characters
#
# Optional:
# - phone → if provided, must be valid international number
# → automatically normalized to E.164 (e.g. +919876543210)
# - tags → array of { "text": "your-tag" }
#
# Error response format (400):
# {
# "errors": [
# { "field": "email", "message": "required" },
# { "field": "email", "message": "invalid email format" }
# ]
# }
29 changes: 24 additions & 5 deletions internal/contact/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,38 @@ func NewHandler(repo *Repository) *Handler {
}

func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var payload ContactCreateRequest
var repoTags []Tag

// todo: request validation
var contactBody Contact

if err := json.NewDecoder(r.Body).Decode(&contactBody); err != nil {
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
fmt.Println(err)
http.Error(w, "invalid json", http.StatusBadRequest)
return
}

if errs := payload.Validate(); len(errs) > 0 {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string][]ValidationError{"errors": errs})
return
}
// conversion from request type to model type
for _, t := range payload.Tags {
repoTags = append(repoTags, Tag{
Text: t.Text,
})
}

contact := Contact{
FirstName: payload.FirstName,
LastName: payload.LastName,
Email: payload.Email,
Phone: payload.Phone,
Tags: repoTags,
}

w.Header().Set("Content-Type", "application/json")

createdId, err := h.repo.CreateContactOrUpsertTags(&contactBody)
createdId, err := h.repo.CreateContactOrUpsertTags(&contact)
if err != nil {
resp := map[string]string{
"message": "Internal server error",
Expand Down
103 changes: 103 additions & 0 deletions internal/contact/handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package contact

import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/codersgyan/camp/internal/database"
)

func TestCreateContactHandler_Validation(t *testing.T) {
// Real in-memory DB + migrations — exactly like production
db, _ := database.Connect(":memory:")
if err := database.RunMigration(db); err != nil {
t.Fatalf("migration failed: %v", err)
}
defer db.Close()

repo := NewRepository(db)
handler := NewHandler(repo)

tests := []struct {
name string
payload map[string]any
wantStatus int
wantBody string // substring check
}{
// Success case — we only check that we get 201 and a valid JSON with "id"
{
name: "valid request → 201 with id",
payload: map[string]any{
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com",
},
wantStatus: http.StatusCreated,
wantBody: `"id":`,
},
{
name: "valid with phone → 201",
payload: map[string]any{
"first_name": "Alice",
"last_name": "Smith",
"email": "alice@test.com",
"phone": "+911472583695",
},
wantStatus: http.StatusCreated,
wantBody: `"id":`,
},
// Validation failures
{
name: "missing email → 400",
payload: map[string]any{
"first_name": "John",
"last_name": "Doe",
},
wantStatus: http.StatusBadRequest,
wantBody: `"field":"email","message":"required"`,
},
{
name: "invalid email → 400",
payload: map[string]any{
"first_name": "John",
"last_name": "Doe",
"email": "bademail",
},
wantStatus: http.StatusBadRequest,
wantBody: `"invalid email format"`,
},
{
name: "invalid phone → 400",
payload: map[string]any{
"first_name": "A",
"last_name": "B",
"email": "a@b.com",
"phone": "12345",
},
wantStatus: http.StatusBadRequest,
wantBody: `"field":"phone"`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, _ := json.Marshal(tt.payload)
req := httptest.NewRequest("POST", "/api/contacts", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()

handler.Create(w, req)

if w.Code != tt.wantStatus {
t.Fatalf("status: got %d, want %d\nbody: %s", w.Code, tt.wantStatus, w.Body.String())
}

if tt.wantBody != "" && !bytes.Contains(w.Body.Bytes(), []byte(tt.wantBody)) {
t.Errorf("body missing expected substring %q\ngot: %s", tt.wantBody, w.Body.String())
}
})
}
}
17 changes: 17 additions & 0 deletions internal/contact/types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package contact

// Payload struture for contact create
type ContactCreateRequest struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
Phone string `json:"phone,omitempty"`
Tags []struct {
Text string `json:"text"`
} `json:"tags,omitempty"`
}

type ValidationError struct {
Field string `json:"field"`
Message string `json:"message"`
}
81 changes: 81 additions & 0 deletions internal/contact/validator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package contact

import (
"fmt"
"regexp"
"strings"
)

var (
emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
phoneRegex = regexp.MustCompile(`^\+[1-9]\d{1,14}$`)
// allowed max lengths for fields
emailMaxLength = 255
// used for both first name and last name
NameMaxLength = 100
)

func (r *ContactCreateRequest) Validate() []ValidationError {
var errs []ValidationError

r.trimFields()

errs = append(errs, r.validateFirstName()...)
errs = append(errs, r.validateLastName()...)
errs = append(errs, r.validateEmail()...)
errs = append(errs, r.validatePhone()...)

return errs
}

// Private helper methods
func (r *ContactCreateRequest) trimFields() {
r.FirstName = strings.TrimSpace(r.FirstName)
r.LastName = strings.TrimSpace(r.LastName)
r.Email = strings.TrimSpace(r.Email)
r.Phone = strings.TrimSpace(r.Phone)
}

func (r *ContactCreateRequest) validateFirstName() []ValidationError {
if r.FirstName == "" {
return []ValidationError{{Field: "first_name", Message: "required"}}
}
if len(r.FirstName) > NameMaxLength {
return []ValidationError{{Field: "first_name", Message: fmt.Sprintf("max %d characters", NameMaxLength)}}
}
return nil
}

func (r *ContactCreateRequest) validateLastName() []ValidationError {
if r.LastName == "" {
return []ValidationError{{Field: "last_name", Message: "required"}}
}
if len(r.LastName) > NameMaxLength {
return []ValidationError{{Field: "last_name", Message: fmt.Sprintf("max %d characters", NameMaxLength)}}
}
return nil
}

func (r *ContactCreateRequest) validateEmail() []ValidationError {
if r.Email == "" {
return []ValidationError{{Field: "email", Message: "required"}}
}
if len(r.Email) > emailMaxLength || !emailRegex.MatchString(r.Email) {
return []ValidationError{{Field: "email", Message: "invalid email format"}}
}
return nil
}

func (r *ContactCreateRequest) validatePhone() []ValidationError {
if r.Phone == "" {
// optional field
return nil
}
r.Phone = strings.TrimSpace(r.Phone)

if !phoneRegex.MatchString(r.Phone) {
return []ValidationError{{Field: "phone", Message: "must be valid E.164 format (e.g. +919876543210)"}}
}

return nil
}
Loading