package microcosm import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" atpclient "github.com/bluesky-social/indigo/atproto/atclient" "github.com/bluesky-social/indigo/atproto/syntax" ) type MicrocosmClient struct { // Inner HTTP client. May be customized after the overall [MicrocosmClient] struct is created; for example to set a default request timeout. Client *http.Client // Host URL prefix: scheme, hostname, and port. This field is required. Host string // Optional HTTP headers which will be included in all requests. Only a single value per key is included; request-level headers will override any client-level defaults. Headers http.Header } func NewMicrocosm(host string) *MicrocosmClient { // validate cfg if needed return &MicrocosmClient{ Client: http.DefaultClient, Host: host, Headers: map[string][]string{ "User-Agent": []string{"microcosm-red-dwarf-server"}, }, } } func (c *MicrocosmClient) LexDo(ctx context.Context, method string, inputEncoding string, endpoint string, params map[string]any, bodyData any, out any) error { // some of the code here is copied from indigo:xrpc/xrpc.go nsid, err := syntax.ParseNSID(endpoint) if err != nil { return err } var body io.Reader if bodyData != nil { if rr, ok := bodyData.(io.Reader); ok { body = rr } else { b, err := json.Marshal(bodyData) if err != nil { return err } body = bytes.NewReader(b) if inputEncoding == "" { inputEncoding = "application/json" } } } req := NewAPIRequest(method, nsid, body) if inputEncoding != "" { req.Headers.Set("Content-Type", inputEncoding) } if params != nil { qp, err := atpclient.ParseParams(params) if err != nil { return err } req.QueryParams = qp } resp, err := c.Do(ctx, req) if err != nil { return err } defer resp.Body.Close() if !(resp.StatusCode >= 200 && resp.StatusCode < 300) { var eb atpclient.ErrorBody if err := json.NewDecoder(resp.Body).Decode(&eb); err != nil { return &atpclient.APIError{StatusCode: resp.StatusCode} } return eb.APIError(resp.StatusCode) } if out == nil { // drain body before returning io.ReadAll(resp.Body) return nil } if buf, ok := out.(*bytes.Buffer); ok { if resp.ContentLength < 0 { _, err := io.Copy(buf, resp.Body) if err != nil { return fmt.Errorf("reading response body: %w", err) } } else { n, err := io.CopyN(buf, resp.Body, resp.ContentLength) if err != nil { return fmt.Errorf("reading length delimited response body (%d < %d): %w", n, resp.ContentLength, err) } } } else { if err := json.NewDecoder(resp.Body).Decode(out); err != nil { return fmt.Errorf("failed decoding JSON response body: %w", err) } } return nil } func (c *MicrocosmClient) Do(ctx context.Context, req *atpclient.APIRequest) (*http.Response, error) { if c.Client == nil { c.Client = http.DefaultClient } httpReq, err := req.HTTPRequest(ctx, c.Host, c.Headers) if err != nil { return nil, err } var resp *http.Response resp, err = c.Client.Do(httpReq) if err != nil { return nil, err } return resp, nil } func NewAPIRequest(method string, endpoint syntax.NSID, body io.Reader) *atpclient.APIRequest { req := atpclient.APIRequest{ Method: method, Endpoint: endpoint, Headers: map[string][]string{}, QueryParams: map[string][]string{}, } // logic to turn "whatever io.Reader we are handed" in to something relatively re-tryable (using GetBody) if body != nil { // NOTE: http.NewRequestWithContext already handles GetBody() as well as ContentLength for specific types like bytes.Buffer and strings.Reader. We just want to add io.Seeker here, for things like files-on-disk. switch v := body.(type) { case io.Seeker: req.Body = io.NopCloser(body) req.GetBody = func() (io.ReadCloser, error) { v.Seek(0, 0) return io.NopCloser(body), nil } default: req.Body = body } } return &req } // PathDo performs an HTTP request to an arbitrary path (no /xrpc/ prefix enforcement). func (c *MicrocosmClient) PathDo(ctx context.Context, method string, inputEncoding string, path string, params map[string]any, bodyData any, out any) error { // 1. Prepare the request body var body io.Reader if bodyData != nil { if rr, ok := bodyData.(io.Reader); ok { body = rr } else { b, err := json.Marshal(bodyData) if err != nil { return err } body = bytes.NewReader(b) if inputEncoding == "" { inputEncoding = "application/json" } } } // 2. Construct the full URL // We handle slash joining carefully to avoid double slashes or missing slashes baseURL := strings.TrimRight(c.Host, "/") cleanPath := strings.TrimLeft(path, "/") fullURLStr := fmt.Sprintf("%s/%s", baseURL, cleanPath) // 3. Prepare Query Parameters // We reuse the indigo parser for convenience, but we must apply them manually to the URL var queryParams url.Values if params != nil { qp, err := atpclient.ParseParams(params) if err != nil { return err } queryParams = make(url.Values) for k, v := range qp { for _, val := range v { queryParams.Add(k, val) } } } if len(queryParams) > 0 { // Verify the URL parses correctly so we can attach params u, err := url.Parse(fullURLStr) if err != nil { return fmt.Errorf("invalid url: %w", err) } u.RawQuery = queryParams.Encode() fullURLStr = u.String() } // 4. Create the Standard HTTP Request req, err := http.NewRequestWithContext(ctx, method, fullURLStr, body) if err != nil { return err } // 5. Apply Headers // Apply default client headers for k, v := range c.Headers { for _, val := range v { req.Header.Add(k, val) } } // Apply specific content type if set if inputEncoding != "" { req.Header.Set("Content-Type", inputEncoding) } // 6. Execute Request // Note: We access c.Client directly because c.Do() expects an XRPC-specific APIRequest if c.Client == nil { c.Client = http.DefaultClient } resp, err := c.Client.Do(req) if err != nil { return err } defer resp.Body.Close() // 7. Handle Response (Mirrors LexDo logic) if !(resp.StatusCode >= 200 && resp.StatusCode < 300) { // Try to decode generic error body, fallback to status code error var eb atpclient.ErrorBody if err := json.NewDecoder(resp.Body).Decode(&eb); err != nil { return fmt.Errorf("http request failed: %s", resp.Status) } return eb.APIError(resp.StatusCode) } if out == nil { io.ReadAll(resp.Body) return nil } if buf, ok := out.(*bytes.Buffer); ok { if resp.ContentLength < 0 { _, err := io.Copy(buf, resp.Body) if err != nil { return fmt.Errorf("reading response body: %w", err) } } else { n, err := io.CopyN(buf, resp.Body, resp.ContentLength) if err != nil { return fmt.Errorf("reading length delimited response body (%d < %d): %w", n, resp.ContentLength, err) } } } else { if err := json.NewDecoder(resp.Body).Decode(out); err != nil { return fmt.Errorf("failed decoding JSON response body: %w", err) } } return nil }