llm browser
This commit is contained in:
1 parent
afed023152
commit
a4dbb75195
39 files changed
+3709
-58
No files matched your search
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user