Documentation
¶
Overview ¶
Package service holds the business logic between handlers and storage.
Index ¶
- Constants
- Variables
- func CurlSnippet(m *model.Mock, baseURL string) string
- func GenerateAdminToken() (plain, hash string, err error)
- func GenerateSlug(ctx context.Context, checker SlugChecker) (string, error)
- func HighlightJSON(s string) template.HTML
- func IsReservedResponseHeader(name string) bool
- func LoadSpamPatterns(path string) ([]string, error)
- func MockURL(m *model.Mock, baseURL string) string
- func ParseCurl(input string) (model.MockInput, error)
- func ParseOpenAPI(raw string) ([]model.MockRoute, error)
- func RenderResponseBodyForRequest(body string, req *RequestData) string
- func SafeRedirectLocation(raw string) bool
- func VerifyAdminToken(plain, hash string) bool
- type CodeSnippet
- type LogWriter
- type MockService
- func (s *MockService) AuthorizeSlug(ctx context.Context, slug, adminToken string) (*model.Mock, error)
- func (s *MockService) ClearLogs(ctx context.Context, slug, adminToken string) error
- func (s *MockService) Create(ctx context.Context, in model.MockInput, creatorIP string) (*model.Mock, error)
- func (s *MockService) Delete(ctx context.Context, slug, adminToken string) error
- func (s *MockService) Extend(ctx context.Context, slug, adminToken string) (*model.Mock, error)
- func (s *MockService) Get(ctx context.Context, slug string) (*model.Mock, error)
- func (s *MockService) Update(ctx context.Context, slug string, in model.MockInput, ...) (*model.Mock, error)
- type MockStore
- type RequestData
- type SlugChecker
- type SpamFilter
- type StatsCache
- type ValidationError
Constants ¶
const ( MaxNamedVariants = 20 MaxResponseRules = 20 MaxRuleConditions = 8 MaxMockRoutes = 50 )
const ( StatMocksCreated = "mocks_created" StatRequestsServed = "requests_served" )
Stat keys used across the codebase. Constants keep us from typo-bumping the wrong counter and silently writing to a new row.
const MaxOpenAPISpecBytes = 1024 * 1024
const MaxSequenceSteps = 10
MaxSequenceSteps caps the EXTRA sequence steps per mock (the main response is step 1 on top of these). The create-form UI mirrors this cap.
const PathSuffixMaxLen = 255
PathSuffixMaxLen is the documented hard limit. 255 fits practical REST paths with plenty of headroom (a real `/api/v2/orgs/.../users/...` is usually < 100 chars).
const SpamPatternsOff = "off"
SpamPatternsOff is the QUICKMOCK_SPAM_PATTERNS_FILE value that disables the filter entirely.
Variables ¶
var ( ErrBodyTooLarge = errors.New("body too large") ErrMockLimitReached = errors.New("mock limit reached") ErrNotFound = errors.New("not found") ErrSpamBlocked = errors.New("content blocked by spam filter") ErrPaymentBlocked = errors.New("payment request blocked") ErrTokenRequired = errors.New("admin token required") ErrTokenInvalid = errors.New("admin token invalid") ErrTTLCapReached = errors.New("ttl cap reached") )
Errors returned by the service layer. Handlers map these to HTTP status codes + i18n message keys; nothing else should switch on them.
var MockTokens = []string{
"{{mock.url}}",
}
MockTokens lists the {{mock.*}} tokens, which describe the mock serving the request rather than the request itself. Like RequestTokens they need live serving context and therefore stay out of SupportedTokens.
var RequestTokens = []string{
"{{request.method}}",
"{{request.path}}",
"{{request.host}}",
"{{request.ip}}",
"{{request.query.id}}",
"{{request.header.x-request-id}}",
"{{request.body}}",
"{{request.body.user.name}}",
}
RequestTokens lists the request.* echo tokens in the illustrative form the UI docs and README show them. query/header/body paths are user-defined, so these are examples, not an exhaustive set. Keep in sync with the UI tooltip and the README.
var SupportedTokens = []string{
"{{faker.name}}",
"{{faker.firstname}}",
"{{faker.lastname}}",
"{{faker.username}}",
"{{faker.email}}",
"{{faker.phone}}",
"{{faker.url}}",
"{{faker.ipv4}}",
"{{faker.uuid}}",
"{{faker.int}}",
"{{faker.bool}}",
"{{faker.word}}",
"{{faker.sentence}}",
"{{faker.color}}",
"{{faker.company}}",
"{{faker.city}}",
"{{faker.price}}",
"{{faker.lorem}}",
"{{now.iso8601}}",
"{{now.unix}}",
"{{now.unix_ms}}",
"{{now.date}}",
"{{now.time}}",
"{{now.rfc1123}}",
}
SupportedTokens lists the fixed-form faker/now tokens, every one of which resolves with no context at all — TestRenderResponseBody_AllSupportedTokensResolve asserts exactly that, so tokens needing context ({{seq}}) or carrying an argument ({{random.pick:a|b|c}}) stay out of this list even though RenderResponseBody substitutes them too. Keep in sync with the UI tooltip and the README.
Functions ¶
func CurlSnippet ¶
CurlSnippet returns a one-liner cURL command for the detail page's "Copy as cURL" button. Always uses GET when the mock allows ANY.
func GenerateAdminToken ¶
GenerateAdminToken returns a new one-time admin token pair: plain is the value shown to the user exactly once (never persisted), hash is its SHA-256 hex digest stored in mocks.admin_token_hash.
func GenerateSlug ¶
func GenerateSlug(ctx context.Context, checker SlugChecker) (string, error)
GenerateSlug returns a slugLength-character slug not currently in use, retrying up to 5 times on collision. Failing after 5 attempts is treated as a real problem (alphabet exhausted or DB acting up) and surfaced to the caller.
func HighlightJSON ¶
HighlightJSON tokenises s as JSON and returns HTML where each token is wrapped in a <span> with a short class name (jk/js/jn/jb/jl/jp). CSS in style.css colours each class.
If s isn't valid JSON we don't try to be clever — we just escape the raw text so the caller's <pre> still renders cleanly without crashing.
Done server-side so the detail page works with JavaScript disabled and the rendered output is search-engine / share-preview friendly.
func IsReservedResponseHeader ¶
IsReservedResponseHeader reports whether name is in reservedHeaders. Exposed so the mock router can apply the same filter on the serve path as defence in depth against legacy DB rows written before the filter was tightened.
func LoadSpamPatterns ¶
LoadSpamPatterns resolves the pattern list for a QUICKMOCK_SPAM_PATTERNS_FILE value: "off" → nil (disabled), "" → embedded defaults, anything else → that file (one regex per line, # comments).
func MockURL ¶
MockURL returns the canonical public URL of a mock, including the user's optional path suffix. Use this everywhere a URL is shown to a user — it keeps the labelled path consistent across the detail page, share view, cURL snippet, and code samples.
func ParseCurl ¶
ParseCurl extracts method, headers, and body from a curl command. It understands the flags users actually paste: -X / --request, -H / --header, -d / --data / --data-raw / --data-binary, and -G. It deliberately ignores auth, cookie jars, output files, and certificate options.
The URL is detected but not retained — we only care about how the user would call the future mock, not where the curl was pointed.
func ParseOpenAPI ¶
ParseOpenAPI converts an OpenAPI 3.x JSON or YAML document into the multi-route shape accepted by POST /api/mocks. It intentionally focuses on response examples/schemas: security and server definitions describe the upstream API, not the anonymous Quickmock workspace.
func RenderResponseBodyForRequest ¶
func RenderResponseBodyForRequest(body string, req *RequestData) string
RenderResponseBodyForRequest substitutes {{faker.*}} and {{now.*}} tokens (unknown tokens are left untouched), plus {{request.*}} echo tokens resolved against req. Request values are substituted after the faker/now pass and inserted verbatim — token-looking text inside a query param or body field is never re-expanded.
func SafeRedirectLocation ¶
SafeRedirectLocation reports whether raw is safe to serve as a mock's Location header. A mock is a trusted quickmock.dev URL to third-party fetchers (AI agents, CI jobs, webhook validators); an absolute Location turns that trust into an open redirect to any host, internal or external. Only a plain relative path is accepted:
- exactly one leading '/' — "//host/path" and "/\host/path" are protocol-relative and backslash variants that browsers resolve to a host, so they are rejected even though url.Parse alone would not flag them;
- no scheme and no host once parsed;
- no control characters, DEL, or spaces, which would let the value smuggle extra response headers;
- bounded length, to keep it a path rather than an encoded payload.
Exported so the mock router can re-run the same check on the serve path against rows written before this validation existed (see IsReservedResponseHeader for the same defence-in-depth pattern).
func VerifyAdminToken ¶
VerifyAdminToken reports whether plain hashes to hash, using a constant-time comparison. Any invalid input (empty plain/hash) returns false without panicking.
Types ¶
type CodeSnippet ¶
type CodeSnippet struct {
Lang string // "js" / "python" / "go" / "php"
Label string // display label
Code string
}
CodeSnippet is one entry shown in the "Use this mock from your code" tabs.
func GenerateSnippets ¶
func GenerateSnippets(m *model.Mock, baseURL string) []CodeSnippet
GenerateSnippets returns ready-to-paste client code for the four supported languages. The mock's URL is built from baseURL + slug; method defaults to GET when the mock allows ANY.
type LogWriter ¶
type LogWriter struct {
// contains filtered or unexported fields
}
LogWriter accepts request log rows on a buffered channel and writes them to Postgres in the background. If the channel is full, the row is dropped — the public mock router must never block on logging.
func NewLogWriter ¶
func NewLogWriter(repo *repository.LogRepo, mocks *repository.MockRepo, stats *StatsCache, capacity int, logger *slog.Logger, broker *sse.Broker) *LogWriter
NewLogWriter creates a writer with `capacity` buffered slots. A capacity of ~1024 absorbs reasonable bursts while remaining tiny in memory.
func (*LogWriter) Start ¶
Start spins up one consumer goroutine. Cancel ctx to stop accepting new items; Shutdown drains the remaining queue.
func (*LogWriter) Submit ¶
func (w *LogWriter) Submit(l model.RequestLog)
Submit enqueues a log row. Drops on full queue.
type MockService ¶
type MockService struct {
// contains filtered or unexported fields
}
MockService is the only object handlers should call to manipulate mocks.
func NewMockService ¶
func NewMockService(repo MockStore, logs *repository.LogRepo, stats *StatsCache, maxBody, maxMocks int, defaultTTL, maxTTL time.Duration, spam *SpamFilter) *MockService
func (*MockService) AuthorizeSlug ¶
func (s *MockService) AuthorizeSlug(ctx context.Context, slug, adminToken string) (*model.Mock, error)
AuthorizeSlug fetches a mock by slug and checks adminToken against it, without mutating anything. It's the same Get-then-authorize sequence ClearLogs uses right above, exposed for read-only paths outside this file's own mutations — namely the log export handler, which needs the exact same token rule but isn't itself a MockService method.
func (*MockService) ClearLogs ¶
func (s *MockService) ClearLogs(ctx context.Context, slug, adminToken string) error
ClearLogs purges the request_logs for a mock and resets the counter.
func (*MockService) Create ¶
func (s *MockService) Create(ctx context.Context, in model.MockInput, creatorIP string) (*model.Mock, error)
Create validates input, applies defaults, generates a slug, and inserts.
creatorIP is required so we can enforce per-IP limits and rate-limit future requests. It is never shown in the UI.
func (*MockService) Delete ¶
func (s *MockService) Delete(ctx context.Context, slug, adminToken string) error
Delete removes a mock and its logs.
func (*MockService) Extend ¶
Extend pushes a mock's expiry one more defaultTTL into the future, capped at created_at + maxTTL. Returns ErrTTLCapReached without writing to storage if the mock is already at (or past) that cap — the caller keeps whatever expires_at the mock already has.
type MockStore ¶
type MockStore interface {
Create(ctx context.Context, m *model.Mock) error
BySlug(ctx context.Context, slug string) (*model.Mock, error)
Update(ctx context.Context, m *model.Mock) error
DeleteBySlug(ctx context.Context, slug string) error
CountActiveByCreatorIP(ctx context.Context, ip string) (int, error)
SlugExists(ctx context.Context, slug string) (bool, error)
}
MockStore is the subset of *repository.MockRepo the service needs. It is the same DI seam GenerateSlug already uses via SlugChecker above, and it is exported because NewMockService accepts it: tests in other packages — handler's, in particular — need to build a MockService over an in-memory fake, and without that the success path of creating a mock (POST / and POST /templates/:slug/create) could only be exercised against a live Postgres, which is to say never in CI.
*repository.MockRepo satisfies it, so production wiring is unchanged.
type RequestData ¶
type RequestData struct {
Method string
Path string
IP string
Query url.Values
Header http.Header
Body []byte
// Host is the authority the request was addressed to — Go keeps it in
// r.Host, never in r.Header, so {{request.header.host}} cannot stand in
// for it. Like every other request.* value it is echoed verbatim, and
// like every other one it is client-controlled: a caller can send any
// Host header it likes. Response bodies that must name the mock's own
// address want MockURL below instead.
Host string
// MockURL is the mock's own public base URL (BaseURL + "/m/" + slug),
// resolving {{mock.url}}. It comes from the server's configured base URL
// rather than from the request, so it is the same address the UI offers
// for copy-paste and cannot be steered by a forged Host header. It exists
// for bodies that have to reference themselves — an OIDC discovery
// document's issuer, a pagination "next" link — which is impossible to
// write by hand because the slug only exists after creation.
MockURL string
// Seq supplies the {{seq}} token's next value. Callers wire it to the
// shared repository.SeqCounter already used for response-sequence
// stepping — nil leaves {{seq}} tokens untouched, the same fallback
// every other token here uses when the data it needs isn't available.
Seq func() uint64
}
RequestData carries the parts of an incoming request that request.* tokens can echo back into the response body, plus one per-hit extra unrelated to the request itself: Seq. Header values are substituted verbatim, including ones the inspector redacts in storage: nothing here is persisted, and the value goes only to the caller who sent it.
type SlugChecker ¶
SlugChecker is satisfied by *repository.MockRepo.SlugExists.
type SpamFilter ¶
type SpamFilter struct {
// contains filtered or unexported fields
}
SpamFilter blocks mock create/update when user-controlled content matches a known spam pattern (M4.4). Patterns are RE2 — linear-time matching, so user input can never ReDoS the service. A nil *SpamFilter is a no-op.
func NewSpamFilter ¶
func NewSpamFilter(patterns []string, allowIPs []netip.Prefix, logger *slog.Logger) (*SpamFilter, error)
NewSpamFilter compiles patterns; an invalid one is a hard error — fail fast at startup. allowIPs is parsed by config.
type StatsCache ¶
type StatsCache struct {
// contains filtered or unexported fields
}
StatsCache wraps StatsRepo with a process-local cache. The home page reads the snapshot on every render — without the cache that's one DB round-trip per home page view. With the cache it's amortised to one query per `ttl` per process.
func NewStatsCache ¶
func NewStatsCache(repo *repository.StatsRepo, ttl time.Duration, log *slog.Logger) *StatsCache
func (*StatsCache) BumpAsync ¶
func (c *StatsCache) BumpAsync(key string, n int64)
BumpAsync increments key by n in a goroutine, never blocking the caller. Errors are logged. Use this on hot paths (mock creation, request hit).
func (*StatsCache) Snapshot ¶
func (c *StatsCache) Snapshot(ctx context.Context) map[string]int64
Snapshot returns the current view of all counters. If the cache is fresh enough, returns the cached map; otherwise queries the DB and refreshes. Errors are logged and the stale snapshot is returned — never blocking the home page on a transient DB hiccup.
type ValidationError ¶
type ValidationError struct {
Field string
Message string // human-readable English fallback; not shown to users
}
ValidationError carries the offending field name. Handlers translate the field into a localized message.
func (*ValidationError) Error ¶
func (e *ValidationError) Error() string