diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e96f1b1..42d3549 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,19 @@ jobs: # `make build`, not `make check`: linting belongs to the `lint` job. - run: make build + test: + name: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version-file: backend/go.mod + cache-dependency-path: | + backend/go.sum + cli/go.sum + - run: make test + generated: name: generated code is current runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index 0a37265..a975164 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ export GOOSE_DRIVER := postgres export GOOSE_DBSTRING := $(DATABASE_URL) export GOOSE_MIGRATION_DIR := backend/db/migrations -.PHONY: help api rebuild-api watch-api stop-api logs psql install tools generate build check lint verify-gen migration +.PHONY: help api rebuild-api watch-api stop-api logs psql install tools generate build check test lint verify-gen migration help: ## Show available targets @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk -F':.*?## ' '{printf " \033[36m%-11s\033[0m %s\n", $$1, $$2}' @@ -54,10 +54,15 @@ build: ## Build and vet both modules cd backend && go build ./... && go vet ./... cd cli && go build ./... && go vet ./... -check: build ## Build, vet, lint, and verify generated code (mirrors CI) +check: build ## Build, vet, test, lint, and verify generated code (mirrors CI) + $(MAKE) test $(MAKE) lint $(MAKE) verify-gen +test: ## Run the unit tests for both modules + cd backend && go test ./... + cd cli && go test ./... + lint: ## Run golangci-lint on both modules cd backend && golangci-lint run cd cli && golangci-lint run diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 1c583b4..944e8f5 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -2,9 +2,10 @@ package main import ( "context" - "fmt" + "log" "github.com/jackjustus/credittracker/backend/internal/data" + "github.com/jackjustus/credittracker/backend/internal/embed" api "github.com/jackjustus/credittracker/backend/internal/gen/openapi" "github.com/jackjustus/credittracker/backend/internal/handler" "github.com/labstack/echo/v4" @@ -14,10 +15,15 @@ func main() { ctx := context.Background() dao, err := data.NewDAO(ctx) if err != nil { - fmt.Print(err) + log.Fatal(err) return } - r := handler.NewServer(dao) + embedder, err := embed.NewClient() + if err != nil { + log.Fatal(err) + return + } + r := handler.NewServer(dao, embedder) e := echo.New() e.HTTPErrorHandler = func(err error, c echo.Context) { c.Logger().Errorf("%s %s: %v", c.Request().Method, c.Path(), err) @@ -30,7 +36,7 @@ func main() { )) err = e.Start(":8080") if err != nil { - fmt.Print(err) + log.Fatal(err) return } } diff --git a/backend/cmd/embedder/main.go b/backend/cmd/embedder/main.go new file mode 100644 index 0000000..b1838f2 --- /dev/null +++ b/backend/cmd/embedder/main.go @@ -0,0 +1,64 @@ +package main + +import ( + "context" + "fmt" + "log" + "strings" + + "github.com/jackjustus/credittracker/backend/internal/data" + "github.com/jackjustus/credittracker/backend/internal/embed" + "github.com/jackjustus/credittracker/backend/internal/models" + "github.com/life4/genesis/slices" +) + +func main() { + ctx := context.Background() + dao, err := data.NewDAO(ctx) + if err != nil { + log.Fatal(err) + return + } + coasters, err := dao.ListEmbeddingTargets(ctx, embed.Model, 700) + if err != nil { + log.Fatal(err) + return + } + + docs := slices.Map(coasters, func(c *models.CoasterWithPark) string { + doc, _ := CoasterDoc(*c) + return doc + }) + + client, err := embed.NewClient() + if err != nil { + log.Fatal(err) + return + } + + vecs, err := client.Embed(ctx, docs) + if err != nil { + log.Fatal(err) + return + } + + // Embed guarantees one vector per doc, so these indexes stay aligned. + for i, c := range coasters { + if err := dao.SetCoasterEmbedding(ctx, c.Coaster().ID(), docs[i], embed.Model, vecs[i]); err != nil { + log.Fatalf("writing %s: %v", c.Coaster().Name(), err) + } + } + + fmt.Printf("embedded %d coasters with %s\n", len(coasters), embed.Model) +} + +func CoasterDoc(c models.CoasterWithPark) (string, error) { + var b strings.Builder + + _, err := fmt.Fprintf(&b, "%s is a coaster at %s", c.Coaster().Name(), c.Park().Name()) + if err != nil { + return "", err + } + + return b.String(), nil +} diff --git a/backend/cmd/embedder/main_test.go b/backend/cmd/embedder/main_test.go new file mode 100644 index 0000000..b00351c --- /dev/null +++ b/backend/cmd/embedder/main_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackjustus/credittracker/backend/internal/gen/db" + "github.com/jackjustus/credittracker/backend/internal/models" +) + +func TestCoasterDoc(t *testing.T) { + now := pgtype.Timestamptz{Time: time.Now(), Valid: true} + parkID := uuid.New() + + c := models.NewCoaster(db.Coaster{ + ID: uuid.New(), + ParkID: parkID, + Name: "Kingda Ka", + ManufacturedAt: now, + CreatedAt: now, + UpdatedAt: now, + }) + p := models.NewPark(db.Park{ + ID: parkID, + Name: "Six Flags Great Adventure", + City: "Jackson", + Country: "United States", + CreatedAt: now, + UpdatedAt: now, + }) + cwp, err := models.NewCoasterWithPark(c, p) + if err != nil { + t.Fatalf("NewCoasterWithPark: %v", err) + } + + doc, err := CoasterDoc(*cwp) + if err != nil { + t.Fatalf("CoasterDoc: %v", err) + } + + // Pinned exactly: this string is what gets embedded, so a wording change + // invalidates every vector already stored under the old phrasing. + want := "Kingda Ka is a coaster at Six Flags Great Adventure" + if doc != want { + t.Errorf("CoasterDoc = %q, want %q", doc, want) + } +} diff --git a/backend/compose.yaml b/backend/compose.yaml index 70ae457..387ff5b 100644 --- a/backend/compose.yaml +++ b/backend/compose.yaml @@ -1,6 +1,6 @@ services: db: - image: postgres:18 + image: pgvector/pgvector:pg18-trixie restart: unless-stopped environment: POSTGRES_PASSWORD: testtest @@ -56,6 +56,14 @@ services: path: ./go.sum environment: DATABASE_URL: postgres://postgres:testtest@db:5432/postgres?sslmode=disable + # Ollama runs natively on the host, not in a container: Docker Desktop on + # macOS has no GPU passthrough, so a containerized Ollama would be + # CPU-only. The host daemon must bind 0.0.0.0 (OLLAMA_HOST) to accept + # this; on loopback it only answers processes on the Mac itself. + OLLAMA_URL: http://host.docker.internal:11434 + # Automatic on Docker Desktop; this makes it work on Linux hosts too. + extra_hosts: + - "host.docker.internal:host-gateway" depends_on: migrate: condition: service_completed_successfully diff --git a/backend/db/migrations/20260812232147_add_pgvector.sql b/backend/db/migrations/20260812232147_add_pgvector.sql new file mode 100644 index 0000000..c68c0bc --- /dev/null +++ b/backend/db/migrations/20260812232147_add_pgvector.sql @@ -0,0 +1,17 @@ +-- +goose Up +CREATE EXTENSION IF NOT EXISTS vector; + +ALTER TABLE coasters + ADD COLUMN search_profile text, -- text passed to embedding model + ADD COLUMN profile_embedding vector(768), + ADD COLUMN profile_embedding_model text, -- text literal of embedding model used, ex: nomic-embed-text + ADD COLUMN profile_embedded_at timestamptz; + +-- +goose Down +ALTER TABLE coasters + DROP COLUMN search_profile , + DROP COLUMN profile_embedding , + DROP COLUMN profile_embedding_model , + DROP COLUMN profile_embedded_at ; + +DROP EXTENSION IF EXISTS vector; \ No newline at end of file diff --git a/backend/db/queries/coaster.sql b/backend/db/queries/coaster.sql index fc9fd9e..0e10e0a 100644 --- a/backend/db/queries/coaster.sql +++ b/backend/db/queries/coaster.sql @@ -2,3 +2,38 @@ SELECT sqlc.embed(coasters) FROM coasters WHERE coasters.id = @id; + +-- Candidates for (re-)embedding.. either never embedded, or embedded under a different +-- model than the parameterized one. +-- +-- Park is embedded so that the doc producer as as much metadata abt the coaster as possible to +-- improve embedding outcomes. +-- +-- name: ListCoastersNeedingEmbedding :many +SELECT sqlc.embed(coasters), sqlc.embed(parks) +FROM coasters +JOIN parks ON parks.id = coasters.park_id +WHERE coasters.profile_embedding IS NULL + OR coasters.profile_embedding_model IS DISTINCT FROM @model::text +ORDER BY coasters.id +LIMIT @row_limit; + +-- Nearest neighbours to an embedded query vector, closest first. +-- <=> is cosine distance. non-embedded coasters are excluded. +-- name: SearchCoasters :many +SELECT sqlc.embed(coasters) +FROM coasters +WHERE profile_embedding IS NOT NULL +ORDER BY profile_embedding <=> @query_embedding +LIMIT @row_limit; + +-- Stores the vector alongside the exact text and model that produced it, so a +-- later run can tell whether a row is stale. +-- rather than changing updated_at, we write to embedded_at. (core coaster metadata isn't changing) +-- name: SetCoasterEmbedding :exec +UPDATE coasters +SET search_profile = @search_profile::text, + profile_embedding = @profile_embedding, + profile_embedding_model = @profile_embedding_model::text, + profile_embedded_at = now() +WHERE id = @id; diff --git a/backend/go.mod b/backend/go.mod index 989c0e1..e81cd07 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -8,6 +8,7 @@ require ( github.com/labstack/echo/v4 v4.15.4 github.com/life4/genesis v1.10.3 github.com/oapi-codegen/runtime v1.6.0 + github.com/pgvector/pgvector-go v0.4.1 ) require ( @@ -20,9 +21,9 @@ require ( github.com/mattn/go-isatty v0.0.24 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - golang.org/x/crypto v0.54.0 // indirect - golang.org/x/net v0.57.0 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect + golang.org/x/text v0.41.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 6c58707..39a16c6 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -30,6 +30,8 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/oapi-codegen/runtime v1.6.0 h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU= github.com/oapi-codegen/runtime v1.6.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= +github.com/pgvector/pgvector-go v0.4.1 h1:Oaj0mC0Ky8KaTweNHHpLwyFlN6a0nUFoo1vgSFTEhPI= +github.com/pgvector/pgvector-go v0.4.1/go.mod h1:4fSXyjl1TYAIdByAql6JazKWRr2s7J0g4hcRY5cBFCk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= @@ -44,14 +46,20 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/backend/internal/data/dao.go b/backend/internal/data/dao.go index 7f055ce..7e3d8ca 100644 --- a/backend/internal/data/dao.go +++ b/backend/internal/data/dao.go @@ -11,6 +11,8 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/jackjustus/credittracker/backend/internal/gen/db" "github.com/jackjustus/credittracker/backend/internal/models" + "github.com/life4/genesis/slices" + "github.com/pgvector/pgvector-go" ) // SentinelUserID is the fixed user ID used for the single local user until @@ -41,6 +43,55 @@ func (dao *DAO) HydrateCoaster(ctx context.Context, coasterID uuid.UUID) (*model return models.NewCoaster(row.Coaster), nil } +func (dao *DAO) ListEmbeddingTargets(ctx context.Context, model string, limit int32) ([]*models.CoasterWithPark, error) { + rows, err := dao.dbc.ListCoastersNeedingEmbedding(ctx, db.ListCoastersNeedingEmbeddingParams{ + Model: model, + RowLimit: limit, + }) + if err != nil { + return nil, err + } + + coasters := make([]*models.CoasterWithPark, 0, len(rows)) + for _, row := range rows { + cwp, err := models.NewCoasterWithPark(models.NewCoaster(row.Coaster), models.NewPark(row.Park)) + if err != nil { + return nil, err + } + coasters = append(coasters, cwp) + } + return coasters, nil +} + +// SearchCoasters returns the coasters whose embeddings sit closest to the given query vector. +// The caller supplies an already embedded query. +func (dao *DAO) SearchCoasters(ctx context.Context, query []float32, limit int32) (models.Coasters, error) { + embedding := pgvector.NewVector(query) + rows, err := dao.dbc.SearchCoasters(ctx, db.SearchCoastersParams{ + QueryEmbedding: &embedding, + RowLimit: limit, + }) + if err != nil { + return nil, err + } + + return slices.Map(rows, func(row db.SearchCoastersRow) *models.Coaster { + return models.NewCoaster(row.Coaster) + }), nil +} + +// SetCoasterEmbedding writes a vector along with the document text and model +// that produced it. +func (dao *DAO) SetCoasterEmbedding(ctx context.Context, coasterID uuid.UUID, doc, model string, vec []float32) error { + embedding := pgvector.NewVector(vec) + return dao.dbc.SetCoasterEmbedding(ctx, db.SetCoasterEmbeddingParams{ + ID: coasterID, + SearchProfile: doc, + ProfileEmbedding: &embedding, + ProfileEmbeddingModel: model, + }) +} + func (dao *DAO) GetCredits(ctx context.Context, userID uuid.UUID) (*models.Credits, error) { events, err := dao.dbc.GetRideEvents(ctx, userID) if err != nil { diff --git a/backend/internal/embed/client.go b/backend/internal/embed/client.go new file mode 100644 index 0000000..2ed9b17 --- /dev/null +++ b/backend/internal/embed/client.go @@ -0,0 +1,104 @@ +package embed + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "time" +) + +// Ollama's default listen address, which is right for anything run on the host +// (`go run ./cmd/embedder`). Containers cannot reach the daemon this way and +// must set OLLAMA_URL to host.docker.internal, as compose.yaml does. +const defaultURL = "http://localhost:11434" + +const ( + Model = "nomic-embed-text" + Dimensions = 768 +) + +type Client struct { + url string + httpc *http.Client +} + +func NewClient() (*Client, error) { + url := os.Getenv("OLLAMA_URL") + if url == "" { + url = defaultURL + } + return &Client{ + url: url, + httpc: &http.Client{Timeout: 5 * time.Second}, + }, nil +} + +type embedRequest struct { + Model string `json:"model"` + Input []string `json:"input"` +} + +type embedResponse struct { + Embeddings [][]float32 `json:"embeddings"` + Error string `json:"error"` +} + +// Embed takes in a list of strings, and returns vectors for each input string +func (c *Client) Embed(ctx context.Context, docs []string) ([][]float32, error) { + if len(docs) == 0 { + return nil, nil + } + + body, err := json.Marshal(embedRequest{Model: Model, Input: docs}) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url+"/api/embed", bytes.NewReader(body)) + if err != nil { + return nil, err + } + + res, err := c.httpc.Do(req) + if err != nil { + return nil, err + } + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + log.Fatal(err) + return + } + }(res.Body) + + raw, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + var parsed embedResponse + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, fmt.Errorf("ollama returned %s: %s", res.Status, raw) + } + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ollama returned %s: %s", res.Status, parsed.Error) + } + + if len(parsed.Embeddings) != len(docs) { + return nil, fmt.Errorf("asked for %d embeddings, got %d", len(docs), len(parsed.Embeddings)) + } + return parsed.Embeddings, nil +} + +func (c *Client) EmbedOne(ctx context.Context, doc string) ([]float32, error) { + vecs, err := c.Embed(ctx, []string{doc}) + if err != nil { + return nil, err + } + return vecs[0], nil +} diff --git a/backend/internal/gen/db/coaster.sql.go b/backend/internal/gen/db/coaster.sql.go index ac5aac7..8db36fb 100644 --- a/backend/internal/gen/db/coaster.sql.go +++ b/backend/internal/gen/db/coaster.sql.go @@ -9,10 +9,11 @@ import ( "context" "github.com/google/uuid" + "github.com/pgvector/pgvector-go" ) const hydrateCoaster = `-- name: HydrateCoaster :one -SELECT coasters.id, coasters.park_id, coasters.name, coasters.manufactured_at, coasters.external_id, coasters.external_source, coasters.created_at, coasters.updated_at +SELECT coasters.id, coasters.park_id, coasters.name, coasters.manufactured_at, coasters.external_id, coasters.external_source, coasters.created_at, coasters.updated_at, coasters.search_profile, coasters.profile_embedding, coasters.profile_embedding_model, coasters.profile_embedded_at FROM coasters WHERE coasters.id = $1 ` @@ -33,6 +34,157 @@ func (q *Queries) HydrateCoaster(ctx context.Context, id uuid.UUID) (HydrateCoas &i.Coaster.ExternalSource, &i.Coaster.CreatedAt, &i.Coaster.UpdatedAt, + &i.Coaster.SearchProfile, + &i.Coaster.ProfileEmbedding, + &i.Coaster.ProfileEmbeddingModel, + &i.Coaster.ProfileEmbeddedAt, ) return i, err } + +const listCoastersNeedingEmbedding = `-- name: ListCoastersNeedingEmbedding :many +SELECT coasters.id, coasters.park_id, coasters.name, coasters.manufactured_at, coasters.external_id, coasters.external_source, coasters.created_at, coasters.updated_at, coasters.search_profile, coasters.profile_embedding, coasters.profile_embedding_model, coasters.profile_embedded_at, parks.id, parks.name, parks.city, parks.country, parks.external_id, parks.external_source, parks.created_at, parks.updated_at +FROM coasters +JOIN parks ON parks.id = coasters.park_id +WHERE coasters.profile_embedding IS NULL + OR coasters.profile_embedding_model IS DISTINCT FROM $1::text +ORDER BY coasters.id +LIMIT $2 +` + +type ListCoastersNeedingEmbeddingParams struct { + Model string + RowLimit int32 +} + +type ListCoastersNeedingEmbeddingRow struct { + Coaster Coaster + Park Park +} + +// Candidates for (re-)embedding.. either never embedded, or embedded under a different +// model than the parameterized one. +// +// Park is embedded so that the doc producer as as much metadata abt the coaster as possible to +// improve embedding outcomes. +func (q *Queries) ListCoastersNeedingEmbedding(ctx context.Context, arg ListCoastersNeedingEmbeddingParams) ([]ListCoastersNeedingEmbeddingRow, error) { + rows, err := q.db.Query(ctx, listCoastersNeedingEmbedding, arg.Model, arg.RowLimit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListCoastersNeedingEmbeddingRow{} + for rows.Next() { + var i ListCoastersNeedingEmbeddingRow + if err := rows.Scan( + &i.Coaster.ID, + &i.Coaster.ParkID, + &i.Coaster.Name, + &i.Coaster.ManufacturedAt, + &i.Coaster.ExternalID, + &i.Coaster.ExternalSource, + &i.Coaster.CreatedAt, + &i.Coaster.UpdatedAt, + &i.Coaster.SearchProfile, + &i.Coaster.ProfileEmbedding, + &i.Coaster.ProfileEmbeddingModel, + &i.Coaster.ProfileEmbeddedAt, + &i.Park.ID, + &i.Park.Name, + &i.Park.City, + &i.Park.Country, + &i.Park.ExternalID, + &i.Park.ExternalSource, + &i.Park.CreatedAt, + &i.Park.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const searchCoasters = `-- name: SearchCoasters :many +SELECT coasters.id, coasters.park_id, coasters.name, coasters.manufactured_at, coasters.external_id, coasters.external_source, coasters.created_at, coasters.updated_at, coasters.search_profile, coasters.profile_embedding, coasters.profile_embedding_model, coasters.profile_embedded_at +FROM coasters +WHERE profile_embedding IS NOT NULL +ORDER BY profile_embedding <=> $1 +LIMIT $2 +` + +type SearchCoastersParams struct { + QueryEmbedding *pgvector.Vector + RowLimit int32 +} + +type SearchCoastersRow struct { + Coaster Coaster +} + +// Nearest neighbours to an embedded query vector, closest first. +// <=> is cosine distance. non-embedded coasters are excluded. +func (q *Queries) SearchCoasters(ctx context.Context, arg SearchCoastersParams) ([]SearchCoastersRow, error) { + rows, err := q.db.Query(ctx, searchCoasters, arg.QueryEmbedding, arg.RowLimit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SearchCoastersRow{} + for rows.Next() { + var i SearchCoastersRow + if err := rows.Scan( + &i.Coaster.ID, + &i.Coaster.ParkID, + &i.Coaster.Name, + &i.Coaster.ManufacturedAt, + &i.Coaster.ExternalID, + &i.Coaster.ExternalSource, + &i.Coaster.CreatedAt, + &i.Coaster.UpdatedAt, + &i.Coaster.SearchProfile, + &i.Coaster.ProfileEmbedding, + &i.Coaster.ProfileEmbeddingModel, + &i.Coaster.ProfileEmbeddedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const setCoasterEmbedding = `-- name: SetCoasterEmbedding :exec +UPDATE coasters +SET search_profile = $1::text, + profile_embedding = $2, + profile_embedding_model = $3::text, + profile_embedded_at = now() +WHERE id = $4 +` + +type SetCoasterEmbeddingParams struct { + SearchProfile string + ProfileEmbedding *pgvector.Vector + ProfileEmbeddingModel string + ID uuid.UUID +} + +// Stores the vector alongside the exact text and model that produced it, so a +// later run can tell whether a row is stale. +// rather than changing updated_at, we write to embedded_at. (core coaster metadata isn't changing) +func (q *Queries) SetCoasterEmbedding(ctx context.Context, arg SetCoasterEmbeddingParams) error { + _, err := q.db.Exec(ctx, setCoasterEmbedding, + arg.SearchProfile, + arg.ProfileEmbedding, + arg.ProfileEmbeddingModel, + arg.ID, + ) + return err +} diff --git a/backend/internal/gen/db/models.go b/backend/internal/gen/db/models.go index 3f405cf..8d84ee3 100644 --- a/backend/internal/gen/db/models.go +++ b/backend/internal/gen/db/models.go @@ -7,17 +7,22 @@ package db import ( "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" + "github.com/pgvector/pgvector-go" ) type Coaster struct { - ID uuid.UUID - ParkID uuid.UUID - Name string - ManufacturedAt pgtype.Timestamptz - ExternalID *string - ExternalSource *string - CreatedAt pgtype.Timestamptz - UpdatedAt pgtype.Timestamptz + ID uuid.UUID + ParkID uuid.UUID + Name string + ManufacturedAt pgtype.Timestamptz + ExternalID *string + ExternalSource *string + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + SearchProfile *string + ProfileEmbedding *pgvector.Vector + ProfileEmbeddingModel *string + ProfileEmbeddedAt pgtype.Timestamptz } type Park struct { diff --git a/backend/internal/gen/db/querier.go b/backend/internal/gen/db/querier.go index 5260e1d..37a83e8 100644 --- a/backend/internal/gen/db/querier.go +++ b/backend/internal/gen/db/querier.go @@ -14,6 +14,20 @@ type Querier interface { CreateRideEvent(ctx context.Context, arg CreateRideEventParams) error GetRideEvents(ctx context.Context, userID uuid.UUID) ([]GetRideEventsRow, error) HydrateCoaster(ctx context.Context, id uuid.UUID) (HydrateCoasterRow, error) + // Candidates for (re-)embedding.. either never embedded, or embedded under a different + // model than the parameterized one. + // + // Park is embedded so that the doc producer as as much metadata abt the coaster as possible to + // improve embedding outcomes. + // + ListCoastersNeedingEmbedding(ctx context.Context, arg ListCoastersNeedingEmbeddingParams) ([]ListCoastersNeedingEmbeddingRow, error) + // Nearest neighbours to an embedded query vector, closest first. + // <=> is cosine distance. non-embedded coasters are excluded. + SearchCoasters(ctx context.Context, arg SearchCoastersParams) ([]SearchCoastersRow, error) + // Stores the vector alongside the exact text and model that produced it, so a + // later run can tell whether a row is stale. + // rather than changing updated_at, we write to embedded_at. (core coaster metadata isn't changing) + SetCoasterEmbedding(ctx context.Context, arg SetCoasterEmbeddingParams) error } var _ Querier = (*Queries)(nil) diff --git a/backend/internal/gen/db/ride_events.sql.go b/backend/internal/gen/db/ride_events.sql.go index ae7a756..1f67a80 100644 --- a/backend/internal/gen/db/ride_events.sql.go +++ b/backend/internal/gen/db/ride_events.sql.go @@ -33,7 +33,7 @@ func (q *Queries) CreateRideEvent(ctx context.Context, arg CreateRideEventParams } const getRideEvents = `-- name: GetRideEvents :many -SELECT ride_events.user_id, ride_events.coaster_id, ride_events.created_at, coasters.id, coasters.park_id, coasters.name, coasters.manufactured_at, coasters.external_id, coasters.external_source, coasters.created_at, coasters.updated_at +SELECT ride_events.user_id, ride_events.coaster_id, ride_events.created_at, coasters.id, coasters.park_id, coasters.name, coasters.manufactured_at, coasters.external_id, coasters.external_source, coasters.created_at, coasters.updated_at, coasters.search_profile, coasters.profile_embedding, coasters.profile_embedding_model, coasters.profile_embedded_at FROM ride_events JOIN coasters ON ride_events.coaster_id = coasters.id WHERE ride_events.user_id = $1 @@ -66,6 +66,10 @@ func (q *Queries) GetRideEvents(ctx context.Context, userID uuid.UUID) ([]GetRid &i.Coaster.ExternalSource, &i.Coaster.CreatedAt, &i.Coaster.UpdatedAt, + &i.Coaster.SearchProfile, + &i.Coaster.ProfileEmbedding, + &i.Coaster.ProfileEmbeddingModel, + &i.Coaster.ProfileEmbeddedAt, ); err != nil { return nil, err } diff --git a/backend/internal/gen/openapi/api.gen.go b/backend/internal/gen/openapi/api.gen.go index 835ec96..2268416 100644 --- a/backend/internal/gen/openapi/api.gen.go +++ b/backend/internal/gen/openapi/api.gen.go @@ -22,6 +22,9 @@ type Coaster struct { Name string `json:"name"` } +// Coasters defines model for Coasters. +type Coasters = []Coaster + // Credit A coaster the rider has ridden at least once, aggregated over every ride of it. The id is the coaster's id. type Credit struct { FirstRiddenAt time.Time `json:"firstRiddenAt"` @@ -59,6 +62,12 @@ type WithName struct { Name string `json:"name"` } +// SearchCoasterParams defines parameters for SearchCoaster. +type SearchCoasterParams struct { + // Q the query which users are searching for, returns a coaster reference + Q string `form:"q" json:"q"` +} + // LogRideParams defines parameters for LogRide. type LogRideParams struct { // Timestamp the time at which the views is being recorded @@ -76,6 +85,9 @@ type ListRidesParams struct { // ServerInterface represents all server handlers. type ServerInterface interface { + // SearchCoaster List coaster & park, based on search + // (GET /coasters) + SearchCoaster(ctx echo.Context, params SearchCoasterParams) error // ListCredits List distinct coasters ridden, one entry per coaster // (GET /credits) ListCredits(ctx echo.Context) error @@ -92,6 +104,24 @@ type ServerInterfaceWrapper struct { Handler ServerInterface } +// SearchCoaster converts echo context to params. +func (w *ServerInterfaceWrapper) SearchCoaster(ctx echo.Context) error { + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params SearchCoasterParams + // ------------- Required query parameter "q" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, true, "q", ctx.QueryParams(), ¶ms.Q, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter q: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.SearchCoaster(ctx, params) + return err +} + // ListCredits converts echo context to params. func (w *ServerInterfaceWrapper) ListCredits(ctx echo.Context) error { var err error @@ -201,7 +231,30 @@ func RegisterHandlersWithOptions(router EchoRouter, si ServerInterface, options router.GET(options.BaseURL+"/credits", wrapper.ListCredits, options.OperationMiddlewares["listCredits"]...) router.GET(options.BaseURL+"/rides", wrapper.ListRides, options.OperationMiddlewares["listRides"]...) router.POST(options.BaseURL+"/log/:coasterID", wrapper.LogRide, options.OperationMiddlewares["logRide"]...) + router.GET(options.BaseURL+"/coasters", wrapper.SearchCoaster, options.OperationMiddlewares["searchCoaster"]...) + +} + +type SearchCoasterRequestObject struct { + Params SearchCoasterParams +} +type SearchCoasterResponseObject interface { + VisitSearchCoasterResponse(w http.ResponseWriter) error +} + +type SearchCoaster200JSONResponse Coasters + +func (response SearchCoaster200JSONResponse) VisitSearchCoasterResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err } type ListCreditsRequestObject struct { @@ -272,6 +325,9 @@ func (response ListRides200JSONResponse) VisitListRidesResponse(w http.ResponseW // StrictServerInterface represents all server handlers. type StrictServerInterface interface { + // SearchCoaster List coaster & park, based on search + // (GET /coasters) + SearchCoaster(ctx context.Context, request SearchCoasterRequestObject) (SearchCoasterResponseObject, error) // ListCredits List distinct coasters ridden, one entry per coaster // (GET /credits) ListCredits(ctx context.Context, request ListCreditsRequestObject) (ListCreditsResponseObject, error) @@ -295,6 +351,31 @@ type strictHandler struct { middlewares []StrictMiddlewareFunc } +// SearchCoaster operation middleware +func (sh *strictHandler) SearchCoaster(ctx echo.Context, params SearchCoasterParams) error { + var request SearchCoasterRequestObject + + request.Params = params + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.SearchCoaster(ctx.Request().Context(), request.(SearchCoasterRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "SearchCoaster") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(SearchCoasterResponseObject); ok { + return validResponse.VisitSearchCoasterResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + // ListCredits operation middleware func (sh *strictHandler) ListCredits(ctx echo.Context) error { var request ListCreditsRequestObject diff --git a/backend/internal/handler/coasters.go b/backend/internal/handler/coasters.go new file mode 100644 index 0000000..7623a40 --- /dev/null +++ b/backend/internal/handler/coasters.go @@ -0,0 +1,24 @@ +package handler + +import ( + "context" + + api "github.com/jackjustus/credittracker/backend/internal/gen/openapi" +) + +const searchLimit = 10 + +// SearchCoaster embeds the query returns the nearest coasters. +func (s Server) SearchCoaster(ctx context.Context, req api.SearchCoasterRequestObject) (api.SearchCoasterResponseObject, error) { + query, err := s.embedder.EmbedOne(ctx, req.Params.Q) + if err != nil { + return nil, err + } + + coasters, err := s.dao.SearchCoasters(ctx, query, searchLimit) + if err != nil { + return nil, err + } + + return api.SearchCoaster200JSONResponse(coasters.ToAPI()), nil +} diff --git a/backend/internal/handler/root.go b/backend/internal/handler/root.go index 9917f81..388abf1 100644 --- a/backend/internal/handler/root.go +++ b/backend/internal/handler/root.go @@ -2,12 +2,14 @@ package handler import ( "github.com/jackjustus/credittracker/backend/internal/data" + "github.com/jackjustus/credittracker/backend/internal/embed" ) type Server struct { - dao *data.DAO + dao *data.DAO + embedder *embed.Client } -func NewServer(dao *data.DAO) Server { - return Server{dao} +func NewServer(dao *data.DAO, embedder *embed.Client) Server { + return Server{dao, embedder} } diff --git a/backend/internal/models/coaster.go b/backend/internal/models/coaster.go index 0fa868c..0c1064b 100644 --- a/backend/internal/models/coaster.go +++ b/backend/internal/models/coaster.go @@ -1,11 +1,13 @@ package models import ( + "errors" "time" "github.com/google/uuid" "github.com/jackjustus/credittracker/backend/internal/gen/db" api "github.com/jackjustus/credittracker/backend/internal/gen/openapi" + "github.com/life4/genesis/slices" ) type Coaster struct { @@ -15,6 +17,21 @@ type Coaster struct { manufacturedAt time.Time } +func (c *Coaster) ID() uuid.UUID { + return c.id +} + +func (c *Coaster) Name() string { + return c.name +} + +func (c *Coaster) ToAPI() api.Coaster { + return api.Coaster{ + Id: c.id, + Name: c.name, + } +} + func (c *Coaster) ToRide(time time.Time) api.Ride { return api.Ride{ Coaster: api.Coaster{ @@ -33,3 +50,34 @@ func NewCoaster(coaster db.Coaster) *Coaster { manufacturedAt: coaster.ManufacturedAt.Time, } } + +type Coasters []*Coaster + +func (cs Coasters) ToAPI() api.Coasters { + return slices.Map(cs, func(c *Coaster) api.Coaster { + return c.ToAPI() + }) +} + +type CoasterWithPark struct { + coaster *Coaster + park *Park +} + +func (c *CoasterWithPark) Coaster() *Coaster { + return c.coaster +} + +func (c *CoasterWithPark) Park() *Park { + return c.park +} + +func NewCoasterWithPark(coaster *Coaster, park *Park) (*CoasterWithPark, error) { + if coaster.parkID != park.id { + return nil, errors.New("cannot create coaster and park without relationship") + } + return &CoasterWithPark{ + coaster: coaster, + park: park, + }, nil +} diff --git a/backend/internal/models/park.go b/backend/internal/models/park.go new file mode 100644 index 0000000..a4a563e --- /dev/null +++ b/backend/internal/models/park.go @@ -0,0 +1,30 @@ +package models + +import ( + "github.com/google/uuid" + "github.com/jackjustus/credittracker/backend/internal/gen/db" +) + +type Park struct { + id uuid.UUID + name string + city string + country string +} + +func (p *Park) ID() uuid.UUID { + return p.id +} + +func (p *Park) Name() string { + return p.name +} + +func NewPark(row db.Park) *Park { + return &Park{ + id: row.ID, + name: row.Name, + city: row.City, + country: row.Country, + } +} diff --git a/cli/internal/gen/client/client.gen.go b/cli/internal/gen/client/client.gen.go index 629a73b..96d9a88 100644 --- a/cli/internal/gen/client/client.gen.go +++ b/cli/internal/gen/client/client.gen.go @@ -23,6 +23,9 @@ type Coaster struct { Name string `json:"name"` } +// Coasters defines model for Coasters. +type Coasters = []Coaster + // Credit A coaster the rider has ridden at least once, aggregated over every ride of it. The id is the coaster's id. type Credit struct { FirstRiddenAt time.Time `json:"firstRiddenAt"` @@ -60,6 +63,12 @@ type WithName struct { Name string `json:"name"` } +// SearchCoasterParams defines parameters for SearchCoaster. +type SearchCoasterParams struct { + // Q the query which users are searching for, returns a coaster reference + Q string `form:"q" json:"q"` +} + // LogRideParams defines parameters for LogRide. type LogRideParams struct { // Timestamp the time at which the views is being recorded @@ -149,6 +158,11 @@ func WithRequestEditorFn(fn RequestEditorFn) ClientOption { // The interface specification for the client above. type ClientInterface interface { + // SearchCoaster List coaster & park, based on search + // + // Corresponds with GET /coasters (the `SearchCoaster` operationId). + SearchCoaster(ctx context.Context, params *SearchCoasterParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // ListCredits List distinct coasters ridden, one entry per coaster // // Corresponds with GET /credits (the `ListCredits` operationId). @@ -165,6 +179,21 @@ type ClientInterface interface { ListRides(ctx context.Context, params *ListRidesParams, reqEditors ...RequestEditorFn) (*http.Response, error) } +// SearchCoaster List coaster & park, based on search +// +// Corresponds with GET /coasters (the `SearchCoaster` operationId). +func (c *Client) SearchCoaster(ctx context.Context, params *SearchCoasterParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSearchCoasterRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + // ListCredits List distinct coasters ridden, one entry per coaster // // Corresponds with GET /credits (the `ListCredits` operationId). @@ -210,6 +239,56 @@ func (c *Client) ListRides(ctx context.Context, params *ListRidesParams, reqEdit return c.Client.Do(req) } +// NewSearchCoasterRequest constructs an http.Request for the SearchCoaster method +func NewSearchCoasterRequest(server string, params *SearchCoasterParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/coasters") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "q", params.Q, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewListCreditsRequest constructs an http.Request for the ListCredits method func NewListCreditsRequest(server string) (*http.Request, error) { var err error @@ -404,6 +483,13 @@ func WithBaseURL(baseURL string) ClientOption { // ClientWithResponsesInterface is the interface specification for the client with responses above. type ClientWithResponsesInterface interface { + // SearchCoasterWithResponse List coaster & park, based on search + // + // Returns a wrapper object for the known response body format(s). + // + // Corresponds with GET /coasters (the `SearchCoaster` operationId). + SearchCoasterWithResponse(ctx context.Context, params *SearchCoasterParams, reqEditors ...RequestEditorFn) (*SearchCoasterResponse, error) + // ListCreditsWithResponse List distinct coasters ridden, one entry per coaster // // Returns a wrapper object for the known response body format(s). @@ -426,6 +512,47 @@ type ClientWithResponsesInterface interface { ListRidesWithResponse(ctx context.Context, params *ListRidesParams, reqEditors ...RequestEditorFn) (*ListRidesResponse, error) } +type SearchCoasterResponse struct { + Body []byte + HTTPResponse *http.Response + // JSON200 the response for an HTTP 200 `application/json` response + JSON200 *Coasters +} + +// GetJSON200 returns the response for an HTTP 200 `application/json` response +func (r SearchCoasterResponse) GetJSON200() *Coasters { + return r.JSON200 +} + +// GetBody returns the raw response body bytes +func (r SearchCoasterResponse) GetBody() []byte { + return r.Body +} + +// Status returns HTTPResponse.Status +func (r SearchCoasterResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r SearchCoasterResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r SearchCoasterResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type ListCreditsResponse struct { Body []byte HTTPResponse *http.Response @@ -549,6 +676,19 @@ func (r ListRidesResponse) ContentType() string { return "" } +// SearchCoasterWithResponse List coaster & park, based on search +// +// Returns a wrapper object for the known response body format(s). +// +// Corresponds with GET /coasters (the `SearchCoaster` operationId). +func (c *ClientWithResponses) SearchCoasterWithResponse(ctx context.Context, params *SearchCoasterParams, reqEditors ...RequestEditorFn) (*SearchCoasterResponse, error) { + rsp, err := c.SearchCoaster(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseSearchCoasterResponse(rsp) +} + // ListCreditsWithResponse List distinct coasters ridden, one entry per coaster // // Returns a wrapper object for the known response body format(s). @@ -588,6 +728,32 @@ func (c *ClientWithResponses) ListRidesWithResponse(ctx context.Context, params return ParseListRidesResponse(rsp) } +// ParseSearchCoasterResponse parses an HTTP response from a SearchCoasterWithResponse call +func ParseSearchCoasterResponse(rsp *http.Response) (*SearchCoasterResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &SearchCoasterResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest Coasters + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + } + + return response, nil +} + // ParseListCreditsResponse parses an HTTP response from a ListCreditsWithResponse call func ParseListCreditsResponse(rsp *http.Response) (*ListCreditsResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/openapi.yaml b/openapi.yaml index 3518929..58f8f78 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -75,6 +75,28 @@ paths: schema: $ref: "#/components/schemas/Ride" + /coasters: + get: + operationId: searchCoaster + summary: List coaster & park, based on search + parameters: + - name: q + in: query + required: true + description: the query which users are searching for, returns a coaster reference + example: kingda ka + schema: + type: string + minLength: 1 + maxLength: 256 + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/Coasters" + components: schemas: