package main import ( "context" "encoding/base64" "fmt" "io" "net/http" "net/url" "strings" "time" ) type AnzoGraphClient struct { cfg Config endpoint string authHeader string client *http.Client } func NewAnzoGraphClient(cfg Config) *AnzoGraphClient { endpoint := cfg.EffectiveSparqlEndpoint() authHeader := "" user := strings.TrimSpace(cfg.SparqlUser) pass := strings.TrimSpace(cfg.SparqlPass) if user != "" && pass != "" { token := base64.StdEncoding.EncodeToString([]byte(user + ":" + pass)) authHeader = "Basic " + token } return &AnzoGraphClient{ cfg: cfg, endpoint: endpoint, authHeader: authHeader, client: &http.Client{}, } } func (c *AnzoGraphClient) Startup(ctx context.Context) error { if err := c.waitReady(ctx); err != nil { return err } if c.cfg.SparqlClearOnStart { if err := c.update(ctx, "CLEAR ALL"); err != nil { return err } if err := c.waitReady(ctx); err != nil { return err } } if c.cfg.SparqlLoadOnStart { df := strings.TrimSpace(c.cfg.SparqlDataFile) if df == "" { return fmt.Errorf("SPARQL_LOAD_ON_START=true but SPARQL_DATA_FILE is not set") } giri := strings.TrimSpace(c.cfg.SparqlGraphIRI) if giri != "" { if err := c.update(ctx, fmt.Sprintf("LOAD <%s> INTO GRAPH <%s>", df, giri)); err != nil { return err } } else { if err := c.update(ctx, fmt.Sprintf("LOAD <%s>", df)); err != nil { return err } } if err := c.waitReady(ctx); err != nil { return err } } return nil } func (c *AnzoGraphClient) Shutdown(ctx context.Context) error { _ = ctx return nil } func (c *AnzoGraphClient) Query(ctx context.Context, query string) ([]byte, error) { return c.queryWithTimeout(ctx, query, c.cfg.SparqlTimeout) } func (c *AnzoGraphClient) queryWithTimeout(ctx context.Context, query string, timeout time.Duration) ([]byte, error) { ctx2, cancel := context.WithTimeout(ctx, timeout) defer cancel() form := url.Values{} form.Set("query", query) req, err := http.NewRequestWithContext(ctx2, http.MethodPost, c.endpoint, strings.NewReader(form.Encode())) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/sparql-results+json") if c.authHeader != "" { req.Header.Set("Authorization", c.authHeader) } resp, err := c.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("sparql query failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) } return body, nil } func (c *AnzoGraphClient) update(ctx context.Context, update string) error { ctx2, cancel := context.WithTimeout(ctx, c.cfg.SparqlTimeout) defer cancel() req, err := http.NewRequestWithContext(ctx2, http.MethodPost, c.endpoint, strings.NewReader(update)) if err != nil { return err } req.Header.Set("Content-Type", "application/sparql-update") req.Header.Set("Accept", "application/json") if c.authHeader != "" { req.Header.Set("Authorization", c.authHeader) } resp, err := c.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("sparql update failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) } return nil } func (c *AnzoGraphClient) waitReady(ctx context.Context) error { var lastErr error for i := 0; i < c.cfg.SparqlReadyRetries; i++ { select { case <-ctx.Done(): if lastErr != nil { return fmt.Errorf("anzograph not ready at %s: %w", c.endpoint, lastErr) } return ctx.Err() default: } body, err := c.queryWithTimeout(ctx, "ASK WHERE { ?s ?p ?o }", c.cfg.SparqlReadyTimeout) if err == nil { // Ensure it's JSON, not HTML/text during boot. if strings.HasPrefix(strings.TrimSpace(string(body)), "{") { return nil } err = fmt.Errorf("unexpected readiness response: %s", strings.TrimSpace(string(body))) } lastErr = err time.Sleep(c.cfg.SparqlReadyDelay) } return fmt.Errorf("anzograph not ready at %s: %w", c.endpoint, lastErr) }