// SPDX-FileCopyrightText: 2021 Luke Granger-Brown // // SPDX-License-Identifier: Apache-2.0 package minicheddar import ( "bytes" "context" "encoding/json" "errors" "fmt" "net" "net/http" "os/exec" "time" "github.com/google/safehtml" "github.com/google/safehtml/uncheckedconversions" ) const startupMax = 10 * time.Second type Cheddar struct { remote string stop func() } type markdownReqResp struct { Data string `json:"data"` } func (c *Cheddar) Markdown(ctx context.Context, text string) (safehtml.HTML, error) { buf := new(bytes.Buffer) if err := json.NewEncoder(buf).Encode(markdownReqResp{Data: text}); err != nil { return safehtml.HTML{}, fmt.Errorf("encoding markdown rendering request: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s/markdown", c.remote), buf) if err != nil { return safehtml.HTML{}, fmt.Errorf("making markdown request object: %w", err) } req.Header.Set("Content-type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return safehtml.HTML{}, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return safehtml.HTML{}, fmt.Errorf("markdown rendering request returned status %d; not OK", resp.StatusCode) } var respData markdownReqResp if err := json.NewDecoder(resp.Body).Decode(&respData); err != nil { return safehtml.HTML{}, fmt.Errorf("decoding markdown rendering response: %w", err) } return uncheckedconversions.HTMLFromStringKnownToSatisfyTypeContract(respData.Data), nil } type codeReq struct { Filepath string `json:"filepath"` Theme string `json:"theme"` Code string `json:"code"` } type codeResp struct { IsPlaintext bool `json:"is_plaintext"` Data string `json:"data"` } func (c *Cheddar) Code(ctx context.Context, filename, theme, text string) (safehtml.HTML, error) { buf := new(bytes.Buffer) if err := json.NewEncoder(buf).Encode(codeReq{ Filepath: filename, Theme: theme, Code: text, }); err != nil { return safehtml.HTML{}, fmt.Errorf("encoding code rendering request: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s", c.remote), buf) if err != nil { return safehtml.HTML{}, fmt.Errorf("making code request object: %w", err) } req.Header.Set("Content-type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return safehtml.HTML{}, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return safehtml.HTML{}, fmt.Errorf("code rendering request returned status %d; not OK", resp.StatusCode) } var respData codeResp if err := json.NewDecoder(resp.Body).Decode(&respData); err != nil { return safehtml.HTML{}, fmt.Errorf("decoding code rendering response: %w", err) } if respData.IsPlaintext { return safehtml.HTMLEscaped(respData.Data), nil } return uncheckedconversions.HTMLFromStringKnownToSatisfyTypeContract(respData.Data), nil } func (c *Cheddar) healthCheck(ctx context.Context) error { _, err := c.Markdown(ctx, "") return err } func (c *Cheddar) Close() { if c.stop != nil { c.stop() } } // Remote connects to an already running instance of Cheddar. func Remote(url string) *Cheddar { return &Cheddar{ remote: url, } } // Spawn spawns a new instance of Cheddar listening on some port. // This is potentially race-y, because it relies on binding, then unbinding and spawning Cheddar. // But... it should be good enough. func Spawn(ctx context.Context, cpath string) (*Cheddar, error) { l, err := net.Listen("tcp", "[::1]:0") if err != nil { return nil, fmt.Errorf("finding a port for cheddar: %w", err) } addr := l.Addr().(*net.TCPAddr) port := addr.Port if err := l.Close(); err != nil { return nil, fmt.Errorf("closing port-sentinel listener: %w", err) } ctx, cancel := context.WithCancel(context.Background()) cmd := exec.CommandContext(ctx, cpath, "--sourcegraph-server", "--listen", fmt.Sprintf("[::1]:%d", port)) if err := cmd.Start(); err != nil { return nil, fmt.Errorf("launching cheddar: %w", err) } c := &Cheddar{ remote: fmt.Sprintf("http://[::1]:%d", port), stop: cancel, } startupCtx, startupCancel := context.WithTimeout(ctx, startupMax) defer startupCancel() // Give cheddar a bit to start up before we start healthchecking it. time.Sleep(1 * time.Millisecond) for { if err := c.healthCheck(startupCtx); errors.Is(err, context.DeadlineExceeded) { cancel() // Kill the subprocess. return nil, err } else if err == nil { break } time.Sleep(10 * time.Millisecond) } return c, nil }