feat: initialize nuntius project with full server implementation
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
//go:build linux || freebsd
|
||||
|
||||
// Package contactform provides reusable types and validation
|
||||
// for a JSON contact form. Import it into any Go project that
|
||||
// needs a contact endpoint.
|
||||
package contactform
|
||||
|
||||
// Request is the JSON body sent to the contact endpoint.
|
||||
type Request struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Service string `json:"service,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Honeypot string `json:"-"`
|
||||
}
|
||||
|
||||
// Response is the success response.
|
||||
type Response struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
// FieldError describes a single validation failure.
|
||||
type FieldError struct {
|
||||
Field string `json:"field"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ErrorResponse is returned for any non-2xx response.
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Details []FieldError `json:"details,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//go:build linux || freebsd
|
||||
|
||||
package contactform
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// validServices is the allow-list for contact-type forms.
|
||||
var validServices = map[string]bool{
|
||||
"": true,
|
||||
"architecture": true,
|
||||
"ai": true,
|
||||
"infrastructure": true,
|
||||
"software": true,
|
||||
"unix": true,
|
||||
"other": true,
|
||||
}
|
||||
|
||||
// MinNameRunes is the minimum allowed length of a name.
|
||||
const MinNameRunes = 2
|
||||
|
||||
// MaxNameRunes is the maximum allowed length of a name.
|
||||
const MaxNameRunes = 100
|
||||
|
||||
// MinMessageRunes is the minimum allowed length of a message body.
|
||||
const MinMessageRunes = 10
|
||||
|
||||
// MaxMessageRunes is the maximum allowed length of a message body.
|
||||
const MaxMessageRunes = 5000
|
||||
|
||||
// Validate dispatches to the correct type-specific validator.
|
||||
// Supported types: contact, feedback, newsletter, generic. Default is "contact".
|
||||
func Validate(r *Request, formType string) []FieldError {
|
||||
switch formType {
|
||||
case "newsletter":
|
||||
return validateNewsletter(r)
|
||||
case "feedback", "generic":
|
||||
return validateGeneric(r)
|
||||
default:
|
||||
return validateContact(r)
|
||||
}
|
||||
}
|
||||
|
||||
func validateContact(r *Request) []FieldError {
|
||||
r.Name = strings.TrimSpace(r.Name)
|
||||
r.Email = strings.TrimSpace(r.Email)
|
||||
r.Service = strings.TrimSpace(r.Service)
|
||||
r.Message = strings.TrimSpace(r.Message)
|
||||
|
||||
var errs []FieldError
|
||||
|
||||
if n := utf8.RuneCountInString(r.Name); n < MinNameRunes {
|
||||
errs = append(errs, FieldError{Field: "name", Message: "name must be at least 2 characters"})
|
||||
} else if n > MaxNameRunes {
|
||||
errs = append(errs, FieldError{Field: "name", Message: "name must be at most 100 characters"})
|
||||
}
|
||||
|
||||
if _, err := mail.ParseAddress(r.Email); err != nil {
|
||||
errs = append(errs, FieldError{Field: "email", Message: "email is invalid"})
|
||||
}
|
||||
|
||||
if !validServices[r.Service] {
|
||||
errs = append(errs, FieldError{Field: "service", Message: "service is not a recognised value"})
|
||||
}
|
||||
|
||||
if n := utf8.RuneCountInString(r.Message); n < MinMessageRunes {
|
||||
errs = append(errs, FieldError{Field: "message", Message: "message must be at least 10 characters"})
|
||||
} else if n > MaxMessageRunes {
|
||||
errs = append(errs, FieldError{Field: "message", Message: "message must be at most 5000 characters"})
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateNewsletter(r *Request) []FieldError {
|
||||
r.Email = strings.TrimSpace(r.Email)
|
||||
|
||||
if _, err := mail.ParseAddress(r.Email); err != nil {
|
||||
return []FieldError{{Field: "email", Message: "email is invalid"}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateGeneric(r *Request) []FieldError {
|
||||
r.Name = strings.TrimSpace(r.Name)
|
||||
r.Email = strings.TrimSpace(r.Email)
|
||||
r.Message = strings.TrimSpace(r.Message)
|
||||
|
||||
var errs []FieldError
|
||||
|
||||
if n := utf8.RuneCountInString(r.Name); n < MinNameRunes {
|
||||
errs = append(errs, FieldError{Field: "name", Message: "name must be at least 2 characters"})
|
||||
} else if n > MaxNameRunes {
|
||||
errs = append(errs, FieldError{Field: "name", Message: "name must be at most 100 characters"})
|
||||
}
|
||||
|
||||
if _, err := mail.ParseAddress(r.Email); err != nil {
|
||||
errs = append(errs, FieldError{Field: "email", Message: "email is invalid"})
|
||||
}
|
||||
|
||||
if n := utf8.RuneCountInString(r.Message); n < MinMessageRunes {
|
||||
errs = append(errs, FieldError{Field: "message", Message: "message must be at least 10 characters"})
|
||||
} else if n > MaxMessageRunes {
|
||||
errs = append(errs, FieldError{Field: "message", Message: "message must be at most 5000 characters"})
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//go:build linux || freebsd
|
||||
|
||||
package contactform
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateContact_Happy(t *testing.T) {
|
||||
r := &Request{
|
||||
Name: "Jane Doe",
|
||||
Email: "jane@example.com",
|
||||
Service: "architecture",
|
||||
Message: "Hello, I would like to discuss a project.",
|
||||
}
|
||||
if errs := Validate(r, "contact"); len(errs) > 0 {
|
||||
t.Fatalf("expected no errors, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateContact_DefaultType(t *testing.T) {
|
||||
// Unknown / empty type falls back to contact validation.
|
||||
r := &Request{Name: "Jane Doe", Email: "jane@example.com", Message: "Hello there."}
|
||||
if errs := Validate(r, ""); len(errs) > 0 {
|
||||
t.Fatalf("expected no errors with empty type, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateContact_RejectsInvalidEmail(t *testing.T) {
|
||||
r := &Request{Name: "Jane", Email: "not-an-email", Message: "A message long enough."}
|
||||
errs := Validate(r, "contact")
|
||||
if len(errs) == 0 {
|
||||
t.Fatal("expected error for invalid email")
|
||||
}
|
||||
if errs[0].Field != "email" {
|
||||
t.Errorf("expected field=email, got %q", errs[0].Field)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateContact_RejectsBadService(t *testing.T) {
|
||||
r := &Request{Name: "Jane", Email: "jane@example.com", Service: "hairstyling", Message: "A message long enough."}
|
||||
errs := Validate(r, "contact")
|
||||
if len(errs) == 0 || errs[0].Field != "service" {
|
||||
t.Fatalf("expected service error, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFeedback_OmitsService(t *testing.T) {
|
||||
// Feedback validates like generic: name, email, message; no service field.
|
||||
r := &Request{Name: "Jane", Email: "jane@example.com", Message: "A message long enough."}
|
||||
if errs := Validate(r, "feedback"); len(errs) > 0 {
|
||||
t.Fatalf("expected no errors, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGeneric_RejectsShortMessage(t *testing.T) {
|
||||
r := &Request{Name: "Jane", Email: "jane@example.com", Message: "short"}
|
||||
errs := Validate(r, "generic")
|
||||
if len(errs) == 0 || errs[0].Field != "message" {
|
||||
t.Fatalf("expected message error, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNewsletter_Happy(t *testing.T) {
|
||||
r := &Request{Email: "jane@example.com"}
|
||||
if errs := Validate(r, "newsletter"); len(errs) > 0 {
|
||||
t.Fatalf("expected no errors, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNewsletter_RequiresEmail(t *testing.T) {
|
||||
r := &Request{Email: "garbage"}
|
||||
errs := Validate(r, "newsletter")
|
||||
if len(errs) == 0 {
|
||||
t.Fatal("expected error for missing/invalid email")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNewsletter_IgnoresNameAndMessage(t *testing.T) {
|
||||
// Newsletter type only cares about the email field.
|
||||
r := &Request{Email: "jane@example.com", Name: "", Message: ""}
|
||||
if errs := Validate(r, "newsletter"); len(errs) > 0 {
|
||||
t.Fatalf("newsletter should ignore empty name/message, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsUnknownTypeOnlyWhenTypeExplicitlyInvalid(t *testing.T) {
|
||||
// Sanity: an unsupported form type (e.g. "foo") falls back to contact
|
||||
// validation, not to a hard error. The hard error is enforced at the
|
||||
// config layer, not in Validate itself.
|
||||
r := &Request{Name: "Jane", Email: "jane@example.com", Message: "Hello there."}
|
||||
if errs := Validate(r, "foo"); len(errs) > 0 {
|
||||
t.Fatalf("Validate with unknown type should fall back to contact, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTrimsWhitespace(t *testing.T) {
|
||||
r := &Request{
|
||||
Name: " Jane ",
|
||||
Email: " jane@example.com ",
|
||||
Service: " architecture ",
|
||||
Message: " hello there ",
|
||||
}
|
||||
if errs := Validate(r, "contact"); len(errs) > 0 {
|
||||
t.Fatalf("expected no errors, got %v", errs)
|
||||
}
|
||||
if r.Name != "Jane" || r.Email != "jane@example.com" || r.Message != "hello there" {
|
||||
t.Errorf("expected whitespace to be trimmed, got %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorMessageHasMinLengthPlaceholder(t *testing.T) {
|
||||
// Sanity: the minLength error message in the en.json UI mirror should
|
||||
// remain a simple string. Here we just check that error messages are
|
||||
// human-readable, not empty.
|
||||
r := &Request{Name: "J", Email: "jane@example.com", Message: "hi"}
|
||||
errs := Validate(r, "contact")
|
||||
if len(errs) < 2 {
|
||||
t.Fatalf("expected at least 2 errors, got %v", errs)
|
||||
}
|
||||
for _, e := range errs {
|
||||
if strings.TrimSpace(e.Message) == "" {
|
||||
t.Errorf("error message is empty for field %q", e.Field)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user