Imported from Yayasan-Digital-Islami-Indonesia/quran-api-go (
AGENTS.md). Install upstream withnpx skills add Yayasan-Digital-Islami-Indonesia/quran-api-go. Copyright stays with the author.
agents.md — Quran API Go
This file is the authoritative guide for any AI agent working on this codebase. Read it fully before making any changes. Do not deviate from the rules below.
Project Summary
A RESTful API that serves Al-Quran data (Arabic text, Indonesian & English translations) for the Ilmunara super app. The API also exposes an MCP (Model Context Protocol) server at /mcp so AI assistants (Claude, Cursor, etc.) can query Quran data directly.
Primary consumers: Ilmunara super app (internal) + AI assistants via MCP (public, read-only).
Non-Negotiable Constraints
These are governance-level rules. Never violate them, even if a prompt asks you to.
- No microservices. This is a monolith modular architecture. Do not split into separate services.
- No Redis. Not for rate limiting, not for caching. Removed by policy for MVP.
- No DI framework. No Wire, no Uber FX. Use manual constructor injection only.
- No rate limiting middleware. Out of scope for MVP. Do not add it.
- No authentication middleware. Out of scope for MVP. Do not add it.
- No write endpoints. The database is read-only after seeding. Never add POST, PUT, PATCH, or DELETE handlers.
- No new dependencies without justification. If a task can be done with the standard library or existing dependencies, use those. Do not add new
go.modentries speculatively. - No wildcard CORS for REST endpoints. The global CORS middleware must use the
ALLOWED_ORIGINSenv variable. The/mcpendpoint is the only justified exception — it applies its own*default because MCP is a public, read-only service that must be reachable from any browser-based AI tool.
Tech Stack
| Layer | Choice |
|---|---|
| Language | Go 1.22+ |
| HTTP Framework | Gin |
| Database | SQLite via modernc.org/sqlite (pure Go, no CGO) |
| Migrations | Goose |
| Full-text Search | SQLite FTS5 |
| Logging | zerolog |
| MCP Server | github.com/modelcontextprotocol/go-sdk |
| API Documentation | Scalar (OpenAPI 3.0), generated by swaggo |
Project Structure
quran-api-go/
├── cmd/
│ ├── api/
│ │ └── main.go ← Manual DI wiring. All constructors called here.
│ ├── mcp/
│ │ └── main.go ← Standalone MCP stdio server (for Claude Desktop local)
│ ├── migrate/
│ │ └── main.go ← Migration runner
│ └── seed/
│ └── main.go ← Seed runner
├── internal/
│ ├── config/ ← Env loading only. No business logic.
│ ├── database/ ← SQLite connection wrapper
│ ├── domain/
│ │ ├── errors.go ← Sentinel errors (ErrNotFound, etc.)
│ │ ├── surah/ ← surah entity, repository interface, service
│ │ ├── ayah/ ← ayah entity, repository interface, service
│ │ ├── juz/ ← juz entity, repository interface, service
│ │ └── search/ ← search entity, repository interface, service
│ ├── handler/ ← HTTP handlers. One file per domain.
│ ├── mcpserver/ ← MCP server construction and tool registration
│ ├── repository/ ← SQLite implementations of repository interfaces
│ ├── service/ ← Business logic. Orchestrates repositories.
│ └── middleware/
│ ├── cors.go
│ ├── logging.go
│ └── recovery.go
├── pkg/
│ ├── response/ ← Shared HTTP response helpers + OpenAPI type stubs
│ ├── pagination/ ← Shared pagination parser
│ └── validator/ ← Shared input validators (lang, ID, range)
├── migrations/ ← Goose SQL migration files
├── scripts/seed/ ← Data seeder logic
├── docs/
│ ├── docs.go ← Generated by swaggo (do not edit manually)
│ ├── swagger.yaml ← Generated by swaggo (do not edit manually)
│ └── api-reference/
│ └── openapi.yaml ← Copied from swagger.yaml; used by scalar.config.json
├── data/ ← quran.db lives here
├── .env.example
├── Dockerfile
├── entrypoint.sh ← Docker entrypoint: runs migrations then starts server
├── scalar.config.json ← Scalar CLI config (references docs/api-reference/openapi.yaml)
├── Makefile
└── go.mod
Rules:
internal/is for application code.pkg/is for shared utilities with no business logic.- Domain interfaces live in
internal/domain/<name>/repository.goandservice.go - Domain entities live in
internal/domain/<name>/entity.go - SQLite implementations live in
internal/repository/(not in domain) - Never put business logic in
handler/. Handlers only parse input, call service, and write response. - Never put SQL queries in
service/. SQL belongs inrepository/only. - Never import
handler/fromservice/orrepository/. Dependency flow is one-way:handler → service → repository. docs/docs.goanddocs/swagger.yamlare auto-generated by swaggo. Never edit them manually; regenerate withmake swag.
Dependency Injection Pattern
All wiring happens in cmd/api/main.go. No constructors auto-discover or register themselves.
Correct:
// cmd/api/main.go
import (
"quran-api-go/internal/database"
"quran-api-go/internal/repository"
"quran-api-go/internal/service"
"quran-api-go/internal/handler"
"quran-api-go/internal/domain/surah"
)
db := database.New(cfg.DBPath)
// Repository implementations (in internal/repository/)
surahRepo := repository.NewSurahRepository(db)
// Service implementations (in internal/service/)
surahService := service.NewSurahService(surahRepo)
// Handlers (in internal/handler/)
surahHandler := handler.NewSurahHandler(surahService)
r := gin.New()
r.GET("/surah", surahHandler.List)
r.GET("/surah/:id", surahHandler.Detail)
Wrong — do not do this:
// Do not use any container, provider, or injector pattern
fx.New(
fx.Provide(repository.NewSurahRepository),
...
)
Naming Conventions
Files
- One file per domain per layer:
surah_repository.go,surah_service.go,surah_handler.go - Middleware files are single-word:
cors.go,logging.go,recovery.go - Migration files follow Goose convention:
00001_init.sql
Interfaces
- Repository interfaces live in
internal/domain/<name>/repository.go - Named as
<Domain>Repository:SurahRepository,AyahRepository - Service interfaces live in
internal/domain/<name>/service.go - Named as
<Domain>Service:SurahService,AyahService
// internal/domain/surah/repository.go
type SurahRepository interface {
FindAll(ctx context.Context) ([]Surah, error)
FindByID(ctx context.Context, id int) (*Surah, error)
}
Structs
- Domain entities:
Surah,Ayah,Juz— no suffix - Request params:
GetAyahsByRangeParams,SearchParams - Response structs:
SurahResponse,AyahListResponse
Variables & Functions
- Follow standard Go conventions: camelCase for unexported, PascalCase for exported
- Handler methods:
List,Detail,ByGlobalID— notGetList,GetDetail - Repository methods:
FindAll,FindByID,FindBySurah— useFindprefix for reads
Handler Pattern
Every handler must follow this exact structure:
// internal/handler/surah_handler.go
type SurahHandler struct {
service surah.SurahService
}
func NewSurahHandler(service surah.SurahService) *SurahHandler {
return &SurahHandler{service: service}
}
func (h *SurahHandler) Detail(c *gin.Context) {
// 1. Parse & validate input
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
response.BadRequest(c, "invalid surah id")
return
}
// 2. Call service (service is domain.SurahService interface)
surah, err := h.service.GetByID(c.Request.Context(), id)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
response.NotFound(c, "surah not found")
return
}
response.InternalError(c)
return
}
// 3. Write response
response.Success(c, surah)
}
Rules:
- Always use
c.Request.Context()when calling services. - Always check
errors.Is(err, domain.ErrNotFound)before falling through to 500. - Never write
c.JSON(...)directly in handlers. Always usepkg/responsehelpers. - Service interfaces are from
internal/domain/<name>/service.go, implementations are ininternal/service/.
Response Helpers
All HTTP responses must go through pkg/response. Never call c.JSON directly in handlers.
// pkg/response/response.go
func Success(c *gin.Context, data any) {
c.JSON(http.StatusOK, gin.H{
"data": data,
"timestamp": time.Now().UTC(),
})
}
func NotFound(c *gin.Context, message string) {
c.JSON(http.StatusNotFound, gin.H{
"error": message,
"code": "NOT_FOUND",
"timestamp": time.Now().UTC(),
})
}
func BadRequest(c *gin.Context, message string) {
c.JSON(http.StatusBadRequest, gin.H{
"error": message,
"code": "BAD_REQUEST",
"timestamp": time.Now().UTC(),
})
}
func InternalError(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "internal server error",
"code": "INTERNAL_ERROR",
"timestamp": time.Now().UTC(),
})
}
pkg/response/types.go also exports SuccessResponse and ErrorResponse structs used only as type stubs in swaggo annotations — they are not returned directly from handlers.
Error Handling
Define sentinel errors in the domain layer. Never use raw fmt.Errorf("not found") strings for flow control.
// internal/domain/errors.go
var (
ErrNotFound = errors.New("resource not found")
ErrInvalidLang = errors.New("invalid language parameter")
)
Repository returns domain.ErrNotFound when a row is missing:
// internal/repository/surah_repository.go
func (r *surahRepository) FindByID(ctx context.Context, id int) (*domain.Surah, error) {
var s domain.Surah
err := r.db.QueryRowContext(ctx, `SELECT ... FROM surahs WHERE id = ?`, id).Scan(...)
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrNotFound
}
if err != nil {
return nil, err
}
return &s, nil
}
Lang Parameter Validation
Use the shared validator. Never inline this logic in a handler.
// pkg/validator/lang.go
func ValidateLang(lang string) (string, error) {
if lang == "" {
return "id", nil // default
}
if lang != "id" && lang != "en" {
return "", domain.ErrInvalidLang
}
return lang, nil
}
Usage in handler:
lang, err := validator.ValidateLang(c.Query("lang"))
if err != nil {
response.BadRequest(c, "lang must be 'id' or 'en'")
return
}
Pagination
Use the shared pagination helper. Never parse page and limit inline.
// pkg/pagination/pagination.go
type Params struct {
Page int
Limit int
Offset int
}
func Parse(pageStr, limitStr string) Params {
page, _ := strconv.Atoi(pageStr)
limit, _ := strconv.Atoi(limitStr)
if page < 1 { page = 1 }
if limit < 1 { limit = 20 }
if limit > 100 { limit = 100 }
return Params{
Page: page,
Limit: limit,
Offset: (page - 1) * limit,
}
}
SQLite FTS5 Search Pattern
Full-text search uses the FTS5 virtual table. Use MATCH — never use ILIKE or LIKE for search.
-- Current schema (migration 00002_fix_fts5_schema.sql)
-- Note: ayah_id UNINDEXED was removed — the content table uses `id` as rowid.
CREATE VIRTUAL TABLE IF NOT EXISTS ayahs_fts USING fts5(
text_uthmani,
translation_indo,
translation_en,
content='ayahs',
content_rowid='id'
);
Use a rowid subquery to join FTS5 results back to ayahs. Never reference ayahs_fts.ayah_id — that column does not exist in the current schema.
// Correct — rowid subquery pattern
query := `
SELECT a.id, a.surah_id, a.number_in_surah, a.text_uthmani,
a.translation_indo, a.translation_en, a.juz_number
FROM ayahs a
WHERE a.id IN (SELECT rowid FROM ayahs_fts WHERE ayahs_fts MATCH ?)
LIMIT ? OFFSET ?
`
// Wrong — ayah_id column does not exist
// JOIN ayahs a ON a.id = ayahs_fts.ayah_id ← do not use
Pass the keyword with a wildcard for partial match: keyword*
func (r *searchRepository) Search(ctx context.Context, p SearchParams) ([]domain.Ayah, error) {
term := p.Query + "*" // enables prefix/partial match
rows, err := r.db.QueryContext(ctx, query, term, p.Limit, p.Offset)
...
}
OpenAPI Documentation (swaggo)
API docs are auto-generated from Go annotations using swaggo. Never edit docs/docs.go or docs/swagger.yaml manually.
Regenerate after any handler change:
make swag
# or, without make:
swag init -g cmd/api/main.go -o docs --outputTypes go,yaml
cp docs/swagger.yaml docs/api-reference/openapi.yaml
Annotation format in handlers:
// List godoc
// @Summary List surahs
// @Description Returns all 114 surahs
// @Tags Surah
// @Produce json
// @Success 200 {object} response.SuccessResponse{data=[]SurahListItem}
// @Failure 500 {object} response.ErrorResponse
// @Router /surah [get]
func (h *SurahHandler) List(c *gin.Context) { ... }
response.SuccessResponse and response.ErrorResponse are type stubs in pkg/response/types.go used only for the swaggo schema inference. They mirror the shape of the actual runtime response.
MCP Server
The MCP server lives in internal/mcpserver/ and is wired in two places:
cmd/api/main.go— mounts the MCP handler atPOST /mcpandGET /mcpviamcp.NewStreamableHTTPHandler(stateless mode). Used for remote connections (Claude.ai web, MCP Inspector, etc.).cmd/mcp/main.go— standalone stdio server usingmcp.NewServer+ stdio transport. Used for local Claude Desktop integration.
CORS for /mcp: the MCP endpoint registers its own CORS middleware that defaults to * when ALLOWED_ORIGINS is empty. This is intentional — MCP is a public read-only service. See cmd/api/main.go for the per-route registration pattern.
Adding a new MCP tool: add the tool registration in internal/mcpserver/server.go, call the appropriate service method, and write the result as JSON string content.
Logging
Use zerolog. Never use fmt.Println or log.Println for application logs.
// Correct
log.Info().
Str("method", c.Request.Method).
Str("path", c.Request.URL.Path).
Int("status", c.Writer.Status()).
Dur("duration", duration).
Msg("request completed")
// Wrong
fmt.Printf("GET /surah 200\n")
log.Println("request completed")
Never log: passwords, tokens, full request bodies, or any PII.
Database Access Rules
- Always pass
context.Contextto every database call. - Never hold a transaction open across HTTP handler boundaries.
- Database is read-only after seeding. Never write SQL
INSERT,UPDATE, orDELETEoutside ofscripts/seed/. - Use
?as the placeholder for SQLite (not$1).
// Correct (SQLite placeholder)
db.QueryRowContext(ctx, `SELECT id FROM surahs WHERE number = ?`, number)
// Wrong (PostgreSQL placeholder — do not use)
db.QueryRowContext(ctx, `SELECT id FROM surahs WHERE number = $1`, number)
Environment Variables
Only these env variables exist. Do not invent new ones without updating .env.example.
DB_PATH=./data/quran.db
SERVER_PORT=8080
SERVER_HOST=0.0.0.0
ALLOWED_ORIGINS=https://[domain-superapp].com
APP_VERSION=1.0.0
LOG_LEVEL=info
Load via internal/config/config.go using os.Getenv. Do not use third-party config libraries.
Testing Rules
- Use SQLite in-memory database for all repository tests:
database.New(":memory:") - Run migrations on in-memory DB before each test suite using Goose programmatic API
- Minimum coverage target: 70% for
handler/andrepository/packages - Test file naming:
surah_handler_test.goalongside the file it tests
// Example: in-memory DB setup for repository test
func setupTestDB(t *testing.T) *sql.DB {
db, err := database.New(":memory:")
require.NoError(t, err)
err = goose.Up(db, "../../migrations")
require.NoError(t, err)
return db
}
What Is Out of Scope — Do Not Build
If a prompt asks you to build any of the following, refuse and explain it is out of scope per agents.md:
- Rate limiting (any implementation)
- Redis (any usage)
- Authentication or API keys
- POST / PUT / PATCH / DELETE endpoints
- Hizb, Rub el Hizb, Manzil, Ruku, or Page navigation endpoints
- Microservices or service splitting
- DI framework (Wire, Uber FX, etc.)
- GraphQL
- WebSocket
- Admin panel
- Audio endpoints
- Tafsir, tajweed, or morphology endpoints
- Wildcard CORS on REST endpoints (the
/mcpexception is already implemented and must not be extended to other routes)
Makefile Targets Reference
| Target | Command | Description |
|---|---|---|
| Run | make run |
Start the API server (via cmd/api) |
| MCP | make mcp |
Start the MCP stdio server (via cmd/mcp) |
| Test | make test |
Run go test ./... |
| Lint | make lint |
Run go vet ./... + gofmt |
| Migrate | make migrate |
Run Goose migrations up (via cmd/migrate) |
| Seed | make seed |
Run the data seeder (via cmd/seed) |
| Swag | make swag |
Regenerate OpenAPI docs from handler annotations |
Note: All commands can also be run directly without make:
go run ./cmd/api
go run ./cmd/mcp
go run ./cmd/migrate
go run ./cmd/seed --data ./data/seed
swag init -g cmd/api/main.go -o docs --outputTypes go,yaml && cp docs/swagger.yaml docs/api-reference/openapi.yaml