232 lines
7.1 KiB
Markdown
232 lines
7.1 KiB
Markdown
# Library Usage — `pkg/contactform`
|
||
|
||
`pkg/contactform` is the public, reusable Go package that the nuntius HTTP server uses for validation. It has no third-party dependencies and no awareness of HTTP, SMTP, or the filesystem — drop it into any Go project that needs to validate a contact-style form payload.
|
||
|
||
## Installation
|
||
|
||
```bash
|
||
go get codeberg.org/petrbalvin/nuntius/pkg/contactform
|
||
```
|
||
|
||
Because nuntius ships with no `go.sum` entries from third-party modules, this import pulls in only stdlib transitively.
|
||
|
||
## Types
|
||
|
||
### `Request`
|
||
|
||
The JSON body sent to the contact endpoint.
|
||
|
||
```go
|
||
type Request struct {
|
||
Name string `json:"name"`
|
||
Email string `json:"email"`
|
||
Service string `json:"service,omitempty"`
|
||
Message string `json:"message"`
|
||
Honeypot string `json:"-"`
|
||
}
|
||
```
|
||
|
||
| Field | JSON tag | Notes |
|
||
|---|---|---|
|
||
| `Name` | `name` | Required for `contact`, `feedback`, `generic` |
|
||
| `Email` | `email` | Required for all four types |
|
||
| `Service` | `service,omitempty` | Optional. Validated against an allow-list for `contact` only. |
|
||
| `Message` | `message` | Required for `contact`, `feedback`, `generic` |
|
||
| `Honeypot` | `-` | Never marshalled to / from JSON. Read it from a separate field in your own request struct, or wire it through your favourite JSON tag. |
|
||
|
||
The handler-side mapping (used inside the nuntius server) reads the honeypot value into `Request.Honeypot` based on the form's `honeypot_field` config. If you embed `contactform.Request` directly in your own API, you are responsible for reading the honeypot field separately.
|
||
|
||
### `Response`
|
||
|
||
```go
|
||
type Response struct {
|
||
OK bool `json:"ok"`
|
||
}
|
||
```
|
||
|
||
A successful submission returns `{"ok": true}`.
|
||
|
||
### `FieldError`
|
||
|
||
```go
|
||
type FieldError struct {
|
||
Field string `json:"field"`
|
||
Message string `json:"message"`
|
||
}
|
||
```
|
||
|
||
One entry in the `details[]` array of a `validation` error.
|
||
|
||
### `ErrorResponse`
|
||
|
||
```go
|
||
type ErrorResponse struct {
|
||
Error string `json:"error"`
|
||
Message string `json:"message,omitempty"`
|
||
Details []FieldError `json:"details,omitempty"`
|
||
}
|
||
```
|
||
|
||
Returned for any non-2xx response. The `Error` field is a short, machine-readable code (`validation`, `invalid_json`, `origin_not_allowed`, `rate_limited`, `send_failed`, `storage_failed`). `Message` is a human-readable summary; `Details` carries the per-field validation failures.
|
||
|
||
## Functions
|
||
|
||
### `Validate`
|
||
|
||
```go
|
||
func Validate(r *Request, formType string) []FieldError
|
||
```
|
||
|
||
Dispatches to the right per-type validator. Returns a slice of field errors; an empty (or nil) slice means the request is valid.
|
||
|
||
| `formType` | What is checked |
|
||
|---|---|
|
||
| `"contact"` | `name` (2–100 runes), `email` (RFC 5322), `service` (allow-list), `message` (10–5,000 runes) |
|
||
| `"feedback"` | `name`, `email`, `message` (no `service`) |
|
||
| `"newsletter"` | `email` only |
|
||
| `"generic"` | `name`, `email`, `message` (no `service`) |
|
||
| `""` (empty) | Falls back to `contact` |
|
||
| anything else | Falls back to `contact` — the strict allow-list is enforced at the config layer, not here |
|
||
|
||
Whitespace is trimmed from `name`, `email`, `service`, and `message` **before** validation. The trimmed values are what the validator sees and what the email body sees.
|
||
|
||
### Constants
|
||
|
||
| Name | Value | Used by |
|
||
|---|---|---|
|
||
| `MinNameRunes` | `2` | `name` minimum length |
|
||
| `MaxNameRunes` | `100` | `name` maximum length |
|
||
| `MinMessageRunes` | `10` | `message` minimum length |
|
||
| `MaxMessageRunes` | `5000` | `message` maximum length |
|
||
|
||
## Standalone Example
|
||
|
||
The `examples/minimal/` directory contains a runnable demo. From the repo root:
|
||
|
||
```bash
|
||
go run ./examples/minimal
|
||
```
|
||
|
||
Source (`examples/minimal/main.go`):
|
||
|
||
```go
|
||
// Minimal example showing how to use the contactform package
|
||
// standalone (without the HTTP server).
|
||
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
|
||
"codeberg.org/petrbalvin/nuntius/pkg/contactform"
|
||
)
|
||
|
||
func main() {
|
||
req := &contactform.Request{
|
||
Name: "Jane Doe",
|
||
Email: "jane@example.com",
|
||
Service: "architecture",
|
||
Message: "Hi, I'd like to discuss a possible engagement.",
|
||
}
|
||
|
||
if errs := contactform.Validate(req, "contact"); len(errs) > 0 {
|
||
fmt.Fprintln(os.Stderr, "validation failed:")
|
||
for _, e := range errs {
|
||
fmt.Fprintf(os.Stderr, " - %s: %s\n", e.Field, e.Message)
|
||
}
|
||
os.Exit(1)
|
||
}
|
||
|
||
fmt.Println("Request is valid:")
|
||
fmt.Printf(" name: %s\n", req.Name)
|
||
fmt.Printf(" email: %s\n", req.Email)
|
||
fmt.Printf(" service: %s\n", req.Service)
|
||
fmt.Printf(" message: %s\n", req.Message)
|
||
}
|
||
```
|
||
|
||
## Custom Validators
|
||
|
||
If you need to extend the allow-list of `service` values, edit `validServices` in `pkg/contactform/validate.go`:
|
||
|
||
```go
|
||
var validServices = map[string]bool{
|
||
"": true,
|
||
"architecture": true,
|
||
"ai": true,
|
||
"infrastructure": true,
|
||
"software": true,
|
||
"unix": true,
|
||
"other": true,
|
||
// add yours here, e.g.:
|
||
// "security": true,
|
||
}
|
||
```
|
||
|
||
If you need different length limits, edit the four `Min*` / `Max*` constants in the same file. Or fork the package — it is intentionally small (~150 LOC) so a copy-into-your-project is also reasonable.
|
||
|
||
## Using `Validate` From Your Own HTTP Handler
|
||
|
||
```go
|
||
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"log"
|
||
"net/http"
|
||
|
||
"codeberg.org/petrbalvin/nuntius/pkg/contactform"
|
||
)
|
||
|
||
func contactHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
defer r.Body.Close()
|
||
|
||
var req contactform.Request
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, contactform.ErrorResponse{
|
||
Error: "invalid_json",
|
||
Message: "Could not parse JSON body.",
|
||
})
|
||
return
|
||
}
|
||
|
||
if errs := contactform.Validate(&req, "contact"); len(errs) > 0 {
|
||
writeJSON(w, http.StatusBadRequest, contactform.ErrorResponse{
|
||
Error: "validation",
|
||
Details: errs,
|
||
})
|
||
return
|
||
}
|
||
|
||
// ... your SMTP / persistence / webhook code here ...
|
||
|
||
writeJSON(w, http.StatusOK, contactform.Response{OK: true})
|
||
}
|
||
|
||
func writeJSON(w http.ResponseWriter, code int, body any) {
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.WriteHeader(code)
|
||
_ = json.NewEncoder(w).Encode(body)
|
||
}
|
||
|
||
func main() {
|
||
http.HandleFunc("/api/contact", contactHandler)
|
||
log.Fatal(http.ListenAndServe(":8080", nil))
|
||
}
|
||
```
|
||
|
||
The example deliberately omits CORS, rate limiting, and the honeypot — copy those from `internal/handler/contact.go` if you need them.
|
||
|
||
## Why a Separate Package?
|
||
|
||
`pkg/contactform` is the one piece of nuntius that is **not** tied to HTTP, SMTP, or the filesystem. Keeping it in a separate, dependency-free package:
|
||
|
||
- Makes it trivially embeddable in any Go service.
|
||
- Makes it cheap to unit-test (see `pkg/contactform/validate_test.go`).
|
||
- Makes the public API obvious — there is one entry point (`Validate`) and four types, all in one file pair.
|
||
- Keeps `internal/handler` focused on HTTP, `internal/email` on SMTP, and `internal/storage` on disk.
|