llm browser

This commit is contained in:
developer committed 2026-10-19 15:25:27 +00:00
1 parent afed023152
commit a4dbb75195
39 files changed
+3709 -58

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
package api
import (
"context"
"errors"
"log/slog"
"net/http"
"strings"
"git.k6n.net/mats/fullstack-template/internal/browse"
)
// browseHandler adapts HTTP requests to the browse service: read the url
// query parameter, call Inspect, map errors to the wire. No fetching or
// conversion logic lives here.
type browseHandler struct {
svc *browse.Service
logger *slog.Logger
}
func (h *browseHandler) get(w http.ResponseWriter, r *http.Request) {
raw := strings.TrimSpace(r.URL.Query().Get("url"))
snap, err := h.svc.Inspect(r.Context(), raw)
if err != nil {
h.fail(w, err)
return
}
writeJSON(w, http.StatusOK, snap)
}
// fail is the translation point from browse errors to the wire:
// *ValidationError → 400, *FetchError → 502 (the site is the problem),
// context deadline → 504, anything else → 500 (logged).
func (h *browseHandler) fail(w http.ResponseWriter, err error) {
var vErr *browse.ValidationError
var fErr *browse.FetchError
switch {
case errors.As(err, &vErr):
writeError(w, http.StatusBadRequest, "validation_failed", vErr.Error())
case errors.As(err, &fErr):
h.logger.Info("browse fetch failed", "url", fErr.URL, "err", fErr.Err)
writeError(w, http.StatusBadGateway, "unavailable", "could not fetch "+fErr.URL)
case errors.Is(err, context.DeadlineExceeded):
writeError(w, http.StatusGatewayTimeout, "timeout", "the site took too long to answer")
default:
h.logger.Error("browse failed", "err", err)
writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
}
}
+8
View File
@@ -9,6 +9,7 @@ import (
"strings"
"time"
"git.k6n.net/mats/fullstack-template/internal/browse"
"git.k6n.net/mats/fullstack-template/internal/items"
)
@@ -21,6 +22,7 @@ type Pinger interface {
type Deps struct {
Logger *slog.Logger
Items *items.Service
Browse *browse.Service
DB Pinger
AllowedOrigins []string
RequestTimeout time.Duration // zero means the 30s default
@@ -54,6 +56,7 @@ func New(deps Deps) http.Handler {
logger: deps.Logger,
db: deps.DB,
items: &itemHandler{svc: deps.Items, logger: deps.Logger},
browse: &browseHandler{svc: deps.Browse, logger: deps.Logger},
auth: authH,
}
@@ -80,6 +83,10 @@ func New(deps Deps) http.Handler {
mux.HandleFunc("PATCH /api/v1/items/{id}", s.items.update)
mux.HandleFunc("DELETE /api/v1/items/{id}", s.items.delete)
// Agent view: renders a remote page the way a text-only agent sees it.
// The URL comes in via query parameter, so the response is a plain GET.
mux.HandleFunc("GET /api/v1/browse", s.browse.get)
// The wrapper keeps the mux's automatic 405 + Allow responses for
// wrong-method requests and renders all unmatched routes as JSON 404s
// (the mux default is plain text).
@@ -99,6 +106,7 @@ type server struct {
logger *slog.Logger
db Pinger
items *itemHandler
browse *browseHandler
auth *authHandler
}
+98
View File
@@ -15,6 +15,7 @@ import (
"time"
"git.k6n.net/mats/fullstack-template/internal/api"
"git.k6n.net/mats/fullstack-template/internal/browse"
"git.k6n.net/mats/fullstack-template/internal/items"
)
@@ -293,3 +294,100 @@ func TestCORSPreflight(t *testing.T) {
t.Errorf("Allow-Origin for unknown origin = %q, want none", got)
}
}
// fakeFetcher is the same in-memory browse.Fetcher as
// internal/browse/service_test.go, duplicated on purpose: test fakes stay
// package-local. A nil response simulates a connection failure.
type fakeFetcher struct {
pages map[string]*browse.Response
}
func (f fakeFetcher) Fetch(_ context.Context, url string) (*browse.Response, error) {
r, ok := f.pages[url]
if !ok {
return &browse.Response{Status: 404, FinalURL: url, ContentType: "text/plain", Body: []byte("not found")}, nil
}
if r == nil {
return nil, &browse.FetchError{URL: url, Err: errors.New("dial tcp: connection refused")}
}
return r, nil
}
func newBrowseTestServer(t *testing.T) (http.Handler, fakeFetcher) {
t.Helper()
pages := map[string]*browse.Response{}
add := func(url, ct, body string) {
pages[url] = &browse.Response{Status: 200, FinalURL: url, ContentType: ct, Body: []byte(body)}
}
add("https://site.test/", "text/html; charset=utf-8",
`<!doctype html><html><head><title>Site</title><meta name="description" content="d"></head>`+
`<body><h1>Hello</h1><p><a href="/docs">docs</a></p></body></html>`)
add("https://site.test/llms.txt", "text/markdown", "# Site\n\n> summary\n\n## Docs\n\n- [Docs](https://site.test/docs): the docs\n")
add("https://site.test/robots.txt", "text/plain", "User-agent: *\nAllow: /\n")
return api.New(api.Deps{
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
Items: items.NewService(newFakeRepo()),
Browse: browse.NewService(fakeFetcher{pages: pages}),
DB: stubPinger{},
AllowedOrigins: []string{"http://localhost:5173"},
}), fakeFetcher{pages: pages}
}
func TestBrowseEndpoint(t *testing.T) {
h, _ := newBrowseTestServer(t)
rec := do(t, h, http.MethodGet, `/api/v1/browse?url=https%3A%2F%2Fsite.test%2F`, "")
if rec.Code != http.StatusOK {
t.Fatalf("browse = %d: %s", rec.Code, rec.Body.String())
}
var snap browse.Snapshot
decodeBody(t, rec, &snap)
if snap.FinalURL != "https://site.test/" || snap.Status != 200 {
t.Errorf("snapshot head = %d %s", snap.Status, snap.FinalURL)
}
if snap.Page == nil || snap.Page.Title != "Site" {
t.Errorf("page = %+v", snap.Page)
}
if !strings.Contains(snap.Markdown, "# Hello") || !strings.Contains(snap.Markdown, "[docs](https://site.test/docs)") {
t.Errorf("markdown = %q", snap.Markdown)
}
if snap.Files.LlmsTxt == nil || !strings.Contains(snap.Files.LlmsTxt.Content, "# Site") {
t.Errorf("llms.txt = %+v", snap.Files.LlmsTxt)
}
if len(snap.Checks) == 0 {
t.Error("checks empty")
}
// Bare-domain input normalizes to https on the wire.
rec = do(t, h, http.MethodGet, `/api/v1/browse?url=site.test`, "")
if rec.Code != http.StatusOK {
t.Fatalf("bare domain = %d: %s", rec.Code, rec.Body.String())
}
}
func TestBrowseEndpointErrors(t *testing.T) {
h, fetcher := newBrowseTestServer(t)
// Missing and malformed input map to validation_failed.
for _, target := range []string{"/api/v1/browse", "/api/v1/browse?url=", "/api/v1/browse?url=ftp://x.test"} {
rec := do(t, h, http.MethodGet, target, "")
if rec.Code != http.StatusBadRequest {
t.Errorf("GET %s = %d, want 400: %s", target, rec.Code, rec.Body.String())
}
}
// A dead site maps to 502 unavailable.
fetcher.pages["https://dead.test/"] = nil
rec := do(t, h, http.MethodGet, `/api/v1/browse?url=https%3A%2F%2Fdead.test%2F`, "")
if rec.Code != http.StatusBadGateway {
t.Errorf("dead site = %d, want 502: %s", rec.Code, rec.Body.String())
}
var errBody struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
decodeBody(t, rec, &errBody)
if errBody.Error.Code != "unavailable" {
t.Errorf("error code = %q, want unavailable", errBody.Error.Code)
}
}