Add a comprehensive guide for developers transitioning from Node/PHP and implement a new middleware to handle IP-based rate limiting with temporary banning functionality. Also refactors configuration loading to use a singleton pattern for better resource management.
28 KiB
EMLy API — Documentazione per sviluppatori Node/PHP
Questa guida spiega l'intera architettura dell'API emly-api-go assumendo che tu conosca Node.js (Express/Fastify) o PHP (Laravel/Slim), ma che non abbia mai scritto Go.
Indice
- Go per chi viene da Node/PHP
- Struttura del progetto
- Setup e avvio
- go-chi: il router
- Middleware
- Handler (i controller)
- Modelli e database
- Sistema di autenticazione
- Migrazioni del database
- Endpoints API completi
- Come aggiungere un nuovo endpoint
1. Go per chi viene da Node/PHP
Analogie rapide
| Concetto | Node/Express | PHP/Laravel | Go (questo progetto) |
|---|---|---|---|
| Entry point | index.js |
public/index.php |
main.go |
| Router | Express, Fastify | Router Laravel | go-chi/chi |
| Middleware | app.use(...) |
Middleware Laravel | r.Use(...) |
| Controller | req, res function |
Controller class | http.HandlerFunc |
| ORM / Query builder | Sequelize, Knex | Eloquent | sqlx (query raw con struct) |
.env |
dotenv |
vlucas/phpdotenv |
joho/godotenv |
| Tipi / interfacce | TypeScript interfaces | PHP types | struct |
package.json |
package.json |
composer.json |
go.mod |
| Hot reload | nodemon |
N/A | air |
Differenze chiave da tenere a mente
Go compila tutto in un singolo binario. Non c'e' un runtime separato come Node. Il risultato di go build e' un .exe (o file ELF su Linux) che contiene tutto: il server, i template, le query SQL — tutto embedded.
Niente null implicito. In Go ogni variabile ha sempre un valore di default (0, "", false, nil). Gli errori si gestiscono con valori di ritorno, non con eccezioni:
// Node
try {
const result = await db.query(sql)
} catch (err) {
console.error(err)
}
// Go
result, err := db.QueryContext(ctx, sql)
if err != nil {
// gestisci l'errore
return
}
I package sono come i moduli ES. Il nome del package in cima al file (es. package handlers) e' l'equivalente di un namespace. Le funzioni con la prima lettera maiuscola (CreateBugReport) sono pubbliche (exported); quelle minuscole (jsonError) sono private al package.
Le funzioni ritornano valori multipli. In Go e' normale avere (result, error) come return type. Non ci sono Promise; il codice e' sincrono (il parallelismo si fa con goroutine, non usate direttamente in questo progetto).
I struct sono i "model". Non ci sono classi in Go. Uno struct con tag db:"..." e json:"..." funziona sia da schema DB (come un Model Eloquent) che da DTO JSON.
2. Struttura del progetto
emly-api-go/
├── main.go # Entry point: boot server, DB, middleware globali
├── go.mod # Dipendenze (come package.json)
├── .env # Variabili d'ambiente locali
│
└── internal/ # Codice privato dell'applicazione
├── config/
│ └── config.go # Carica env vars in una struct Config
│
├── database/
│ ├── database.go # Apre la connessione MySQL (pool)
│ └── schema/
│ ├── migrator.go # Sistema di migration condizionali
│ ├── init.sql # Schema base (CREATE TABLE IF NOT EXISTS)
│ └── migrations/
│ ├── tasks.json # Definisce le migration con condizioni
│ ├── 1_bug_reports.sql
│ └── 2_users.sql
│
├── handlers/ # I "controller" — una funzione per endpoint
│ ├── response.go # Helper: jsonOK, jsonCreated, jsonError
│ ├── health.route.go # GET /v1/health
│ ├── bug_report.route.go # Tutti gli endpoint /bug-reports
│ ├── admin_auth.route.go # Login, validate, logout
│ ├── admin_users.route.go # CRUD utenti admin
│ └── templates/
│ └── report.txt.tmpl # Template testo per il file ZIP
│
├── middleware/
│ ├── apikey.go # Verifica header X-API-Key
│ └── adminKey.go # Verifica header X-Admin-Key
│
├── models/ # Struct che mappano le tabelle DB e i JSON
│ ├── bug_report.go
│ ├── bug_report_file.go
│ ├── user.go
│ ├── session.go
│ └── rate_limit_hwid.go
│
└── routes/
├── routes.go # Monta i sub-router sul router root
└── v1/
├── v1.go # Crea il router /v1 con middleware globale v1
├── bug_reports.go # Registra le rotte /bug-reports
└── admin.go # Registra le rotte /admin
Perche' internal/?
In Go, tutto cio' che sta dentro internal/ non puo' essere importato da progetti esterni. E' una convenzione per dire "questo codice e' implementazione privata, non una libreria pubblica". Equivale a non esportare un modulo in Node.
3. Setup e avvio
Prerequisiti
- Go 1.21+
- MySQL 8+
airper hot-reload in sviluppo:go install github.com/air-verse/air@latest
Configurazione .env
Copia .env.example in .env:
PORT=8080
# DSN MySQL — DEVE includere parseTime=true&loc=UTC
DB_DSN=root:secret@tcp(127.0.0.1:3306)/emly?parseTime=true&loc=UTC
DATABASE_NAME=emly
# Chiavi di autenticazione
API_KEY=la-tua-api-key
ADMIN_KEY=la-tua-admin-key
# Pool di connessioni (opzionali, hanno default)
DB_MAX_OPEN_CONNS=30
DB_MAX_IDLE_CONNS=5
DB_CONN_MAX_LIFETIME=5
Comandi
# Sviluppo con hot-reload (come nodemon)
air
# Build binario di produzione
go build -o ./build/emly-api.exe .
# Avvio diretto senza build
go run .
# Test
go test ./...
go test ./internal/... -run NomeTest -v
Cosa succede all'avvio
- Carica
.env(se presente) - Legge la config dalle env vars
- Apre il pool di connessioni MySQL
- Esegue le migrazioni (vedi sezione 9)
- Crea il router chi e registra tutti i middleware e le rotte
- Avvia il server HTTP su
PORT
4. go-chi: il router
go-chi/chi e' l'equivalente di Express.js per Go. E' un router HTTP leggero e componibile.
Analogia Express → Chi
// Express
const app = express()
app.use(morgan('dev'))
app.get('/users/:id', (req, res) => { ... })
// Chi
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Get("/users/{id}", func(w http.ResponseWriter, r *http.Request) { ... })
Differenze nella sintassi dei parametri
| Express | Chi |
|---|---|
/users/:id |
/users/{id} |
req.params.id |
chi.URLParam(r, "id") |
req.query.page |
r.URL.Query().Get("page") |
req.body |
r.Body (letto con json.NewDecoder) |
req.headers |
r.Header.Get("X-API-Key") |
API principali di chi
chi.NewRouter()
Crea un nuovo router. Equivale a express() o new Slim\App().
r := chi.NewRouter()
r.Use(middleware)
Aggiunge un middleware globale al router (o al gruppo corrente). L'ordine conta: vengono eseguiti nell'ordine di registrazione.
r.Use(middleware.Logger) // loga ogni richiesta
r.Use(middleware.Recoverer) // cattura i panic (come un try/catch globale)
Metodi HTTP: r.Get, r.Post, r.Patch, r.Delete
r.Get("/health", handlers.Health(db))
r.Post("/bug-reports/", handlers.CreateBugReport(db))
r.Patch("/bug-reports/{id}/status", handlers.PatchBugReportStatus(db))
r.Delete("/bug-reports/{id}", handlers.DeleteBugReportByID(db))
r.Route(pattern, fn) — Gruppo con prefisso
Crea un sotto-gruppo con un prefisso comune. Equivale a express.Router() o ai route group di Laravel.
// Tutte le rotte dentro la funzione avranno il prefisso /bug-reports
r.Route("/bug-reports", func(r chi.Router) {
r.Get("/", handlers.GetAllBugReports(db))
r.Get("/{id}", handlers.GetBugReportByID(db))
})
r.Group(fn) — Gruppo senza prefisso
Crea un gruppo di rotte che condividono middleware, ma senza aggiungere un prefisso al path. Utile per applicare auth diversa a rotte dello stesso livello.
r.Route("/bug-reports", func(r chi.Router) {
// Gruppo 1: solo API key
r.Group(func(r chi.Router) {
r.Use(apimw.APIKeyAuth(db))
r.Get("/count", handlers.GetReportsCount(db))
r.Post("/", handlers.CreateBugReport(db))
})
// Gruppo 2: API key + Admin key
r.Group(func(r chi.Router) {
r.Use(apimw.APIKeyAuth(db))
r.Use(apimw.AdminKeyAuth(db))
r.Get("/", handlers.GetAllBugReports(db))
r.Delete("/{id}", handlers.DeleteBugReportByID(db))
})
})
r.Mount(pattern, handler) — Monta un sub-router
Incolla un router separato su un prefisso. Permette di dividere le rotte in file diversi mantenendo tutto componibile. Equivale a app.use('/v1', v1Router) in Express.
// routes.go
r.Mount("/v1", v1.NewRouter(db))
chi.URLParam(r, "name") — Legge i parametri di percorso
// Rotta: /bug-reports/{id}
id := chi.URLParam(r, "id") // es. "42"
Middleware built-in di chi (go-chi/chi/v5/middleware)
Questi sono tutti usati in main.go:
| Middleware | Equivalente Node/Laravel | Cosa fa |
|---|---|---|
middleware.RequestID |
express-request-id |
Aggiunge un UUID univoco X-Request-Id ad ogni richiesta |
middleware.RealIP |
express-ip / TrustProxies |
Legge l'IP reale da X-Forwarded-For o X-Real-IP |
middleware.Logger |
morgan |
Loga metodo, path, status, durata su stdout |
middleware.Recoverer |
Express error handler / rescue_from |
Cattura i panic e ritorna 500 invece di crashare |
middleware.Timeout(30s) |
connect-timeout |
Cancella la richiesta se supera i 30 secondi |
Rate limiting (go-chi/httprate)
// main.go — globale: max 100 req/min per IP
r.Use(httprate.LimitByIP(100, time.Minute))
// nelle rotte — per gruppo: max 30 req/min per IP
r.Use(httprate.LimitByIP(30, time.Minute))
5. Middleware
In questo progetto i middleware custom si trovano in internal/middleware/. Un middleware in Go e' una funzione che prende un http.Handler e restituisce un http.Handler.
Concettualmente identico a Express:
// Express
function apiKeyAuth(req, res, next) {
if (req.headers['x-api-key'] !== process.env.API_KEY) {
return res.status(401).json({ error: 'unauthorized' })
}
next()
}
// Go/Chi
func APIKeyAuth(_ *sqlx.DB) func(http.Handler) http.Handler {
// Questo blocco gira UNA VOLTA sola all'avvio (come costruttore)
allowed := map[string]struct{}{cfg.APIKey: {}}
return func(next http.Handler) http.Handler {
// Questo ritorna il middleware vero e proprio
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("X-API-Key")
if _, ok := allowed[key]; !ok {
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
return // equivale a NON chiamare next()
}
next.ServeHTTP(w, r) // equivale a next()
})
}
}
Middleware disponibili
| Middleware | Header richiesto | Applicato a |
|---|---|---|
APIKeyAuth |
X-API-Key |
Tutti gli endpoint /v1/api/bug-reports/* |
AdminKeyAuth |
X-Admin-Key |
Endpoint admin bug-reports + /v1/api/admin/users/* |
6. Handler (i controller)
Gli handler sono in internal/handlers/. Ogni file corrisponde a una risorsa (es. bug_report.route.go).
Pattern factory function
Gli handler NON sono funzioni dirette. Sono factory functions che ricevono *sqlx.DB e restituiscono l'handler vero. Questo permette di iniettare la dipendenza del database senza variabili globali.
// Definizione
func CreateBugReport(db *sqlx.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// qui hai accesso a `db` tramite closure
}
}
// Utilizzo nel router
r.Post("/", handlers.CreateBugReport(db))
Analogo Node sarebbe:
function createBugReport(db) {
return async (req, res) => {
// uso db
}
}
app.post('/', createBugReport(db))
Response helpers (response.go)
Tre funzioni usate in tutti gli handler per rispondere in JSON:
jsonOK(w, payload) // HTTP 200 + JSON
jsonCreated(w, payload) // HTTP 201 + JSON
jsonError(w, status, msg) // HTTP <status> + { "error": "msg" }
Leggere il body JSON
// In Go (come json.parse in Node)
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
Leggere query string
page := r.URL.Query().Get("page") // ?page=2
status := r.URL.Query().Get("status") // ?status=new
Upload multipart (form-data)
// Legge fino a 32MB in memoria
r.ParseMultipartForm(32 << 20)
name := r.FormValue("name") // campo testo
file, header, err := r.FormFile("screenshot") // file upload
7. Modelli e database
I modelli (internal/models/)
I modelli sono semplici struct con tag speciali. Non c'e' ORM: le query sono SQL raw.
type BugReport struct {
ID uint64 `db:"id" json:"id"`
Name string `db:"name" json:"name"`
Email string `db:"email" json:"email"`
// json:"-" significa: NON includere mai questo campo nella risposta JSON
PasswordHash string `db:"password_hash" json:"-"`
}
db:"nome_colonna"— mappa la colonna SQL al campo Go (usato dasqlx)json:"nome_campo"— mappa il campo Go alla chiave JSON (usato daencoding/json)json:"-"— campo nascosto nelle risposte JSON (es.password_hash,datadei file)json:"campo,omitempty"— il campo viene omesso dal JSON se e' zero/nil
sqlx — Query database
sqlx e' un wrapper sottile su database/sql. Non e' un ORM: si scrive SQL a mano e si mappa il risultato su struct.
// Legge UNA riga in una struct (GetContext)
// Equivale a: db.query('SELECT ... WHERE id = ?', [id]).then(rows => rows[0])
var report models.BugReport
err := db.GetContext(r.Context(), &report,
"SELECT * FROM bug_reports WHERE id = ?", id)
if errors.Is(err, sql.ErrNoRows) {
// nessun risultato trovato
}
// Legge PIU' righe in uno slice (SelectContext)
// Equivale a: db.query('SELECT ...').then(rows => rows)
var reports []models.BugReport
err := db.SelectContext(r.Context(), &reports,
"SELECT * FROM bug_reports ORDER BY created_at DESC")
// Esegue INSERT/UPDATE/DELETE (ExecContext)
result, err := db.ExecContext(r.Context(),
"INSERT INTO bug_reports (name, email) VALUES (?, ?)", name, email)
reportID, _ := result.LastInsertId()
rowsAffected, _ := result.RowsAffected()
Perche' r.Context()? Il context trasporta la deadline di timeout (30s impostata in main.go). Se la richiesta viene cancellata (timeout o client disconnect), la query SQL viene interrotta automaticamente. E' come AbortController in Node ma automatico.
Tipi custom (enum-like)
Go non ha enum. Si usa un type su string con costanti:
type BugReportStatus string
const (
BugReportStatusNew BugReportStatus = "new"
BugReportStatusInReview BugReportStatus = "in_review"
BugReportStatusResolved BugReportStatus = "resolved"
BugReportStatusClosed BugReportStatus = "closed"
)
8. Sistema di autenticazione
L'API ha due livelli di autenticazione separati, entrambi header-based (niente cookie, niente JWT per le rotte API).
Livello 1: API Key (X-API-Key)
Usata da client esterni (es. l'applicazione desktop) per inviare bug report.
X-API-Key: la-tua-api-key
Il valore e' configurato in .env come API_KEY. Il middleware APIKeyAuth carica la chiave in una mappa all'avvio e fa un lookup O(1) ad ogni richiesta.
Livello 2: Admin Key (X-Admin-Key)
Usata per accedere agli endpoint di sola lettura/scrittura amministrativa.
X-Admin-Key: la-tua-admin-key
Il valore e' configurato in .env come ADMIN_KEY.
Livello 3: Sessione utente (X-Session-Token)
Gli endpoint /v1/api/admin/auth/* gestiscono login/logout di utenti admin con sessioni su database.
Flusso login:
POST /v1/api/admin/auth/login
Body: { "username": "...", "password": "..." }
Response: {
"session_id": "<64-char hex token>",
"user": { "id": "...", "username": "...", "role": "admin", ... }
}
Flusso validate:
GET /v1/api/admin/auth/validate
Headers: X-Session-Token: <session_id>
Response: { "success": true, "user": { ... } }
Flusso logout:
POST /v1/api/admin/auth/logout
Headers: X-Session-Token: <session_id>
Dettagli implementativi:
- Password hashed con argon2id (formato PHC, compatibile con
@node-rs/argon2) - Sessioni salvate in tabella
sessioncon scadenza a 30 giorni - Le sessioni scadute vengono eliminate automaticamente alla prima validazione fallita
- UUID utenti generati con
crypto/rand(UUID v4) - Session ID: 32 byte random, encoded come hex (64 caratteri)
9. Migrazioni del database
Il sistema di migration e' custom e si trova in internal/database/schema/. Non usa librerie esterne come golang-migrate.
Come funziona
All'avvio, schema.Migrate(db, cfg.Database) esegue questi passi:
-
Controlla se il database e' vuoto — Se non ci sono tabelle, esegue
init.sqlche crea tutto lo schema base conCREATE TABLE IF NOT EXISTS. -
Controlla le tabelle attese — Se il DB non e' vuoto ma mancano tabelle, riesegue
init.sql. -
Esegue le migration condizionali — Legge
migrations/tasks.jsone per ogni task valuta le condizioni. Un task viene eseguito solo se almeno una condizione e' vera.
tasks.json — Definizione migration
{
"tasks": [
{
"id": "1_bug_reports",
"sql_file": "1_bug_reports.sql",
"description": "Add hostname, os_user columns and their indexes to bug_reports.",
"conditions": [
{ "type": "column_not_exists", "table": "bug_reports", "column": "hostname" },
{ "type": "column_not_exists", "table": "bug_reports", "column": "os_user" }
]
}
]
}
Tipi di condizione supportati
| Tipo | Esegui la migration se... |
|---|---|
column_not_exists |
la colonna non esiste nella tabella |
column_exists |
la colonna esiste nella tabella |
index_not_exists |
l'indice non esiste nella tabella |
index_exists |
l'indice esiste nella tabella |
table_not_exists |
la tabella non esiste nel database |
table_exists |
la tabella esiste nel database |
Come aggiungere una migration
- Crea il file SQL in
internal/database/schema/migrations/3_nome.sql - Aggiungi il task in
tasks.jsoncon le condizioni appropriate
{
"id": "3_nome",
"sql_file": "3_nome.sql",
"description": "Descrizione della migration.",
"conditions": [
{ "type": "column_not_exists", "table": "bug_reports", "column": "nuova_colonna" }
]
}
10. Endpoints API completi
Base URL: http://localhost:8080
Header di autenticazione
| Header | Richiesto per |
|---|---|
X-API-Key |
Tutti gli endpoint /v1/api/bug-reports/* |
X-Admin-Key |
Endpoint admin bug-reports + /v1/api/admin/users/* |
X-Session-Token |
/v1/api/admin/auth/validate e /logout |
Pubblici
GET /
Ping. Ritorna il testo emly-api-go.
GET /v1/health
{ "status": "ok", "db": "ok" }
Bug Reports — Solo X-API-Key
POST /v1/api/bug-reports/
Crea un nuovo bug report. Content-Type: multipart/form-data.
| Campo | Tipo | Obbligatorio | Descrizione |
|---|---|---|---|
name |
string | si | Nome del reporter |
email |
string | si | Email del reporter |
description |
string | si | Descrizione del bug |
hwid |
string | no | Hardware ID |
hostname |
string | no | Nome macchina |
os_user |
string | no | Utente OS |
system_info |
string | no | JSON serializzato con info sistema |
attachment |
file | no | File allegato generico |
screenshot |
file | no | Screenshot del bug |
log |
file | no | File di log |
Response 201:
{ "success": true, "report_id": 42, "message": "Bug report submitted successfully" }
GET /v1/api/bug-reports/count
{ "count": 128 }
Bug Reports — X-API-Key + X-Admin-Key
GET /v1/api/bug-reports/
Lista paginata con filtri.
| Query param | Default | Descrizione |
|---|---|---|
page |
1 | Numero pagina |
page_size |
20 | Risultati per pagina (max 100) |
status |
- | Filtra per stato: new, in_review, resolved, closed |
search |
- | Cerca in hostname, os_user, name, email |
Response 200:
{
"data": [ /* array di BugReportListItem */ ],
"total": 128,
"page": 1,
"page_size": 20,
"total_pages": 7
}
GET /v1/api/bug-reports/{id}
Dettaglio singolo report.
GET /v1/api/bug-reports/{id}/status
{ "status": "new" }
PATCH /v1/api/bug-reports/{id}/status
Body: stringa raw (non JSON) con il nuovo stato.
in_review
GET /v1/api/bug-reports/{id}/files
Lista dei file allegati al report (senza dati binari).
GET /v1/api/bug-reports/{id}/files/{file_id}
Scarica il file specifico. Response con Content-Type originale e Content-Disposition: attachment.
GET /v1/api/bug-reports/{id}/download
Scarica un file .zip contenente:
report.txt— report formattato dal templatescreenshot/nome.png,log/nome.log,attachment/nome.bin— file allegati organizzati per ruolo
DELETE /v1/api/bug-reports/{id}
{ "message": "bug report deleted successfully" }
Auth Admin — Nessuna chiave richiesta (gestisce le proprie credenziali)
POST /v1/api/admin/auth/login
// Request
{ "username": "admin", "password": "secret" }
// Response 200
{
"session_id": "a3f9...<64 chars>",
"user": { "id": "uuid", "username": "admin", "displayname": "Admin", "role": "admin", "enabled": true }
}
GET /v1/api/admin/auth/validate
Header: X-Session-Token: <session_id>
{ "success": true, "user": { ... } }
POST /v1/api/admin/auth/logout
Header: X-Session-Token: <session_id>
{ "logged_out": true }
Gestione Utenti — Solo X-Admin-Key
GET /v1/api/admin/users/
Lista tutti gli utenti.
POST /v1/api/admin/users/
// Request
{ "username": "mario", "displayname": "Mario Rossi", "password": "secret", "role": "user" }
// Response 201: oggetto User
GET /v1/api/admin/users/{id}
PATCH /v1/api/admin/users/{id}
Aggiorna solo i campi forniti. Campi modificabili: displayname, enabled.
{ "displayname": "Nuovo Nome", "enabled": false }
POST /v1/api/admin/users/{id}/reset-password
{ "password": "nuova-password" }
DELETE /v1/api/admin/users/{id}
Modello BugReport
{
"id": 42,
"name": "Mario Rossi",
"email": "mario@example.com",
"description": "L'app crasha all'avvio",
"hwid": "ABC123",
"hostname": "DESKTOP-XYZ",
"os_user": "mario",
"submitter_ip": "192.168.1.1",
"system_info": { "os": "Windows 11", "ram": "16GB" },
"status": "new",
"created_at": "2026-03-23T10:00:00Z",
"updated_at": "2026-03-23T10:00:00Z"
}
Stati possibili: new → in_review → resolved / closed
Modello BugReportFile
{
"id": 1,
"report_id": 42,
"file_role": "screenshot",
"filename": "crash.png",
"mime_type": "image/png",
"file_size": 204800,
"created_at": "2026-03-23T10:00:00Z"
}
Ruoli file: attachment, screenshot, log
Modello User
{
"id": "uuid-v4",
"username": "mario",
"displayname": "Mario Rossi",
"role": "admin",
"enabled": true,
"created_at": "2026-03-23T10:00:00Z"
}
Nota: password_hash non viene mai esposto nelle risposte JSON (json:"-").
11. Come aggiungere un nuovo endpoint
Esempio: aggiungere GET /v1/api/bug-reports/{id}/summary.
Step 1 — Scrivi l'handler in internal/handlers/bug_report.route.go
func GetBugReportSummary(db *sqlx.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
jsonError(w, http.StatusBadRequest, "missing id parameter")
return
}
// esegui la query...
jsonOK(w, map[string]string{"summary": "..."})
}
}
Step 2 — Registra la rotta in internal/routes/v1/bug_reports.go
Aggiungila nel gruppo con le permission corrette:
r.Group(func(r chi.Router) {
r.Use(apimw.APIKeyAuth(db))
r.Use(apimw.AdminKeyAuth(db))
r.Use(httprate.LimitByIP(30, time.Minute))
r.Get("/", handlers.GetAllBugReports(db))
r.Get("/{id}", handlers.GetBugReportByID(db))
r.Get("/{id}/summary", handlers.GetBugReportSummary(db)) // <-- aggiunto qui
// ...
})
Step 3 — Build e test
go build ./... # verifica che compili
go test ./... # esegui i test
air # hot-reload in sviluppo
Non serve nessun file di routing separato, nessun decoratore, nessuna annotation. La rotta e' attiva immediatamente alla ricompilazione.