feat: output format support via ?format= param

All search endpoints accept ?format=json|markdown|text|ndjson (default:
json). Accept header negotiation also works (text/markdown, text/plain,
application/x-ndjson). Non-JSON formats skip the response cache to
avoid polluting the JSON cache key. Markdown output is optimised for
n8n Slack/email nodes; text output reduces token count ~25-30% vs JSON
for LLM grounding pipelines; NDJSON emits one result per line.
This commit is contained in:
Rustem Kamalov
2026-04-24 05:23:49 +03:00
parent 16d3a844ad
commit ee42231a09
4 changed files with 278 additions and 12 deletions

72
core/format_markdown.go Normal file
View File

@@ -0,0 +1,72 @@
package core
import (
"fmt"
"strings"
)
// RenderMarkdown formats an Envelope as a Markdown document suitable for
// Slack/Discord/email nodes in n8n workflows.
func RenderMarkdown(env *Envelope) []byte {
var b strings.Builder
enginesStr := strings.Join(env.Meta.EnginesResponded, ", ")
if enginesStr == "" {
enginesStr = strings.Join(env.Query.EnginesRequested, ", ")
}
fmt.Fprintf(&b, "# Search results for %q\n\n", env.Query.Text)
fmt.Fprintf(&b, "**Query:** %s · **Engines:** %s · **Took:** %dms\n\n",
env.Query.Text, enginesStr, env.Meta.TookMs)
if len(env.Meta.EnginesFailed) > 0 {
fmt.Fprintf(&b, "> ⚠️ Engines that failed: %s\n\n", strings.Join(env.Meta.EnginesFailed, ", "))
}
for i, r := range env.Results {
fmt.Fprintf(&b, "## %d. %s\n\n", i+1, escapeMarkdown(r.Title))
typeLabel := string(r.Type)
if r.IsAd {
typeLabel = "ad"
}
fmt.Fprintf(&b, "**%s** · %s\n\n", r.DisplayURL, typeLabel)
if r.Snippet != "" {
fmt.Fprintf(&b, "%s\n\n", r.Snippet)
}
fmt.Fprintf(&b, "→ %s\n\n", r.URL)
}
return []byte(b.String())
}
// RenderMarkdownImage formats an ImageEnvelope as Markdown.
func RenderMarkdownImage(env *ImageEnvelope) []byte {
var b strings.Builder
enginesStr := strings.Join(env.Meta.EnginesResponded, ", ")
if enginesStr == "" {
enginesStr = strings.Join(env.Query.EnginesRequested, ", ")
}
fmt.Fprintf(&b, "# Image results for %q\n\n", env.Query.Text)
fmt.Fprintf(&b, "**Query:** %s · **Engines:** %s · **Took:** %dms\n\n",
env.Query.Text, enginesStr, env.Meta.TookMs)
for i, r := range env.Results {
fmt.Fprintf(&b, "## %d. %s\n\n", i+1, escapeMarkdown(r.Title))
fmt.Fprintf(&b, "**Source:** %s\n\n", r.Source.Domain)
fmt.Fprintf(&b, "→ Image: %s\n", r.Image.URL)
fmt.Fprintf(&b, "→ Page: %s\n\n", r.Source.PageURL)
}
return []byte(b.String())
}
func escapeMarkdown(s string) string {
replacer := strings.NewReplacer(
"*", `\*`,
"_", `\_`,
"`", "\\`",
"[", `\[`,
"]", `\]`,
)
return replacer.Replace(s)
}

77
core/format_text.go Normal file
View File

@@ -0,0 +1,77 @@
package core
import (
"encoding/json"
"fmt"
"strings"
)
// RenderText formats an Envelope as a minimal plain-text block optimised for
// LLM context windows (~25-30% fewer tokens than JSON for the same data).
func RenderText(env *Envelope) []byte {
var b strings.Builder
fmt.Fprintf(&b, "Search: %s\n", env.Query.Text)
enginesStr := strings.Join(env.Meta.EnginesResponded, ", ")
if enginesStr != "" {
fmt.Fprintf(&b, "Engines: %s\n", enginesStr)
}
if len(env.Meta.EnginesFailed) > 0 {
fmt.Fprintf(&b, "Failed: %s\n", strings.Join(env.Meta.EnginesFailed, ", "))
}
b.WriteString("\n")
for i, r := range env.Results {
fmt.Fprintf(&b, "[%d] %s (%s)\n", i+1, r.Title, r.Domain)
if r.Snippet != "" {
fmt.Fprintf(&b, "%s\n", r.Snippet)
}
fmt.Fprintf(&b, "URL: %s\n\n", r.URL)
}
return []byte(b.String())
}
// RenderTextImage formats an ImageEnvelope as plain text.
func RenderTextImage(env *ImageEnvelope) []byte {
var b strings.Builder
fmt.Fprintf(&b, "Image search: %s\n\n", env.Query.Text)
for i, r := range env.Results {
fmt.Fprintf(&b, "[%d] %s (%s)\n", i+1, r.Title, r.Source.Domain)
fmt.Fprintf(&b, "Image: %s\n", r.Image.URL)
fmt.Fprintf(&b, "Page: %s\n\n", r.Source.PageURL)
}
return []byte(b.String())
}
// RenderNDJSON formats an Envelope as newline-delimited JSON (one Result per line).
// The envelope meta is omitted from the body; clients should read response headers.
func RenderNDJSON(env *Envelope) []byte {
var b strings.Builder
for _, r := range env.Results {
data, err := json.Marshal(r)
if err != nil {
continue
}
b.Write(data)
b.WriteByte('\n')
}
return []byte(b.String())
}
// RenderNDJSONImage formats an ImageEnvelope as newline-delimited JSON.
func RenderNDJSONImage(env *ImageEnvelope) []byte {
var b strings.Builder
for _, r := range env.Results {
data, err := json.Marshal(r)
if err != nil {
continue
}
b.Write(data)
b.WriteByte('\n')
}
return []byte(b.String())
}

View File

@@ -188,6 +188,11 @@ func (s *Server) handleDedicatedEndpoint(c *fiber.Ctx, engine SearchEngine, isIm
return err
}
format, err := resolveFormat(c)
if err != nil {
return err
}
requestCtx = WithQueryHash(c.UserContext(), QueryHashFromQuery(q))
c.SetUserContext(requestCtx)
@@ -239,15 +244,17 @@ func (s *Server) handleDedicatedEndpoint(c *fiber.Ctx, engine SearchEngine, isIm
}
env.Finalize(startedAt, q)
cacheStatus := s.cacheEnvelopeIfEligible(engine.Name(), usedEngine, action, q, env)
if cacheStatus != "" {
c.Set("X-Cache", cacheStatus)
if format == "json" {
cacheStatus := s.cacheEnvelopeIfEligible(engine.Name(), usedEngine, action, q, env)
if cacheStatus != "" {
c.Set("X-Cache", cacheStatus)
}
}
if usedEngine != "" && usedEngine != engine.Name() {
c.Set("X-Fallback-Engine", usedEngine)
}
WithRequest(requestCtx).WithFields(logrus.Fields{"action": action, "results_count": len(res)}).Info("Search completed")
return c.JSON(env)
return sendImageEnvelope(c, format, env)
}
var (
@@ -276,9 +283,11 @@ func (s *Server) handleDedicatedEndpoint(c *fiber.Ctx, engine SearchEngine, isIm
}
env.Finalize(startedAt, q)
cacheStatus := s.cacheEnvelopeIfEligible(engine.Name(), usedEngine, action, q, env)
if cacheStatus != "" {
c.Set("X-Cache", cacheStatus)
if format == "json" {
cacheStatus := s.cacheEnvelopeIfEligible(engine.Name(), usedEngine, action, q, env)
if cacheStatus != "" {
c.Set("X-Cache", cacheStatus)
}
}
if usedEngine != "" && usedEngine != engine.Name() {
c.Set("X-Fallback-Engine", usedEngine)
@@ -289,7 +298,7 @@ func (s *Server) handleDedicatedEndpoint(c *fiber.Ctx, engine SearchEngine, isIm
completionCtx = WithEngine(completionCtx, usedEngine)
}
WithRequest(completionCtx).WithFields(logrus.Fields{"action": action, "results_count": len(res)}).Info("Search completed")
return c.JSON(env)
return sendEnvelope(c, format, env)
}
// classifySearchError maps internal sentinel errors to user-facing messages.
@@ -638,6 +647,12 @@ func (s *Server) handleMegaEndpoint(c *fiber.Ctx, action string, run func(contex
WithRequest(c.UserContext()).WithError(err).Warn("Invalid query parameters")
return err
}
format, err := resolveFormat(c)
if err != nil {
return err
}
requestCtx = WithQueryHash(c.UserContext(), QueryHashFromQuery(q))
c.SetUserContext(requestCtx)
@@ -688,13 +703,13 @@ func (s *Server) handleMegaEndpoint(c *fiber.Ctx, action string, run func(contex
}
env.Finalize(startedAt, q)
if s.cache != nil {
if format == "json" && s.cache != nil {
c.Set("X-Cache", s.cacheMegaImageResults(action, enginesToUse, q, env))
}
WithRequest(requestCtx).WithFields(logrus.Fields{
"action": action, "engines_count": len(enginesToUse), "results_count": len(env.Results),
}).Info("Mega search completed")
return c.JSON(env)
return sendImageEnvelope(c, format, env)
}
// Web search: enrich all raw results, build clusters from full set, then dedup flat list.
@@ -721,7 +736,7 @@ func (s *Server) handleMegaEndpoint(c *fiber.Ctx, action string, run func(contex
env.Clusters = &clusters
}
if s.cache != nil {
if format == "json" && s.cache != nil {
c.Set("X-Cache", s.cacheMegaEnvelopeResults(action, enginesToUse, q, env))
}
@@ -730,7 +745,7 @@ func (s *Server) handleMegaEndpoint(c *fiber.Ctx, action string, run func(contex
"engines_count": len(enginesToUse),
"results_count": len(env.Results),
}).Info("Mega search completed")
return c.JSON(env)
return sendEnvelope(c, format, env)
}
func (s *Server) handleListEngines(c *fiber.Ctx) error {
@@ -986,6 +1001,67 @@ func (s *Server) handleSwaggerUI(c *fiber.Ctx) error {
return c.SendString(page)
}
// resolveFormat returns the output format from ?format= or Accept header.
// Supported values: "json" (default), "markdown", "text", "ndjson".
func resolveFormat(c *fiber.Ctx) (string, error) {
raw := strings.ToLower(strings.TrimSpace(c.Query("format", "")))
if raw == "" {
accept := strings.ToLower(c.Get("Accept"))
switch {
case strings.Contains(accept, "text/markdown"):
raw = "markdown"
case strings.Contains(accept, "text/plain"):
raw = "text"
case strings.Contains(accept, "application/x-ndjson"):
raw = "ndjson"
default:
raw = "json"
}
}
switch raw {
case "json", "markdown", "text", "ndjson":
return raw, nil
}
return "", &APIError{HTTPStatus: 400, Reason: ReasonUnknownFormat,
Message: fmt.Sprintf("unknown format %q: accepted values are json, markdown, text, ndjson", raw)}
}
// sendEnvelope serialises env according to the requested format and writes the
// response. For non-JSON formats the envelope is NOT cached because format
// variants would pollute the JSON cache.
func sendEnvelope(c *fiber.Ctx, format string, env *Envelope) error {
switch format {
case "markdown":
c.Set("Content-Type", "text/markdown; charset=utf-8")
return c.Send(RenderMarkdown(env))
case "text":
c.Set("Content-Type", "text/plain; charset=utf-8")
return c.Send(RenderText(env))
case "ndjson":
c.Set("Content-Type", "application/x-ndjson; charset=utf-8")
return c.Send(RenderNDJSON(env))
default:
return c.JSON(env)
}
}
// sendImageEnvelope is sendEnvelope for ImageEnvelope.
func sendImageEnvelope(c *fiber.Ctx, format string, env *ImageEnvelope) error {
switch format {
case "markdown":
c.Set("Content-Type", "text/markdown; charset=utf-8")
return c.Send(RenderMarkdownImage(env))
case "text":
c.Set("Content-Type", "text/plain; charset=utf-8")
return c.Send(RenderTextImage(env))
case "ndjson":
c.Set("Content-Type", "application/x-ndjson; charset=utf-8")
return c.Send(RenderNDJSONImage(env))
default:
return c.JSON(env)
}
}
// SetDraining controls readiness state exposed by /ready.
func (s *Server) SetDraining(draining bool) {
s.draining.Store(draining)

View File

@@ -1619,6 +1619,47 @@ func TestMegaSearchClustersGroupSameURL(t *testing.T) {
}
}
func TestFormatParamReturnsCorrectContentType(t *testing.T) {
engine := &engineMock{name: "google", initialized: true}
opts := DefaultServerOptions()
opts.Resilience.Retry.MaxRetries = 0
srv := NewServerWithOptions("127.0.0.1", 7204, opts, engine)
tests := []struct {
format string
wantCT string
wantContains string
}{
{"json", "application/json", `"version"`},
{"markdown", "text/markdown", "# Search results"},
{"text", "text/plain", "Search:"},
{"ndjson", "application/x-ndjson", `"id"`},
}
for i, tt := range tests {
t.Run(tt.format, func(t *testing.T) {
// Use a unique query per subtest to avoid cache cross-contamination.
resp := request(t, srv, fmt.Sprintf("/google/search?text=query%d&format=%s", i, tt.format))
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, tt.wantCT) {
t.Fatalf("expected Content-Type to contain %q, got %q", tt.wantCT, ct)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), tt.wantContains) {
t.Fatalf("expected body to contain %q, got: %s", tt.wantContains, string(body)[:min(200, len(body))])
}
})
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func TestResultIDIsStableAcrossRequests(t *testing.T) {
engine := &engineMock{name: "google", initialized: true}
opts := DefaultServerOptions()