Skip to content

Commit

Permalink
sketch API notes
Browse files Browse the repository at this point in the history
  • Loading branch information
bnewbold committed Jan 7, 2025
1 parent 9dc0051 commit 8fb4c69
Show file tree
Hide file tree
Showing 7 changed files with 283 additions and 57 deletions.
28 changes: 28 additions & 0 deletions atproto/client/admin_auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package client

import (
"context"
"encoding/base64"
"net/http"

"github.com/bluesky-social/indigo/atproto/syntax"
)

type AdminAuth struct {
basicAuthHeader string
}

func NewAdminAuth(password string) AdminAuth {
header := "Basic" + base64.StdEncoding.EncodeToString([]byte("admin:"+password))
return AdminAuth{basicAuthHeader: header}
}

func (a *AdminAuth) DoWithAuth(ctx context.Context, req *http.Request, httpClient *http.Client) (*http.Response, error) {
req.Header.Set("Authorization", a.basicAuthHeader)
return httpClient.Do(req)
}

// Admin bearer token auth does not involve an account DID
func (a *AdminAuth) AccountDID() syntax.DID {
return ""
}
65 changes: 65 additions & 0 deletions atproto/client/api_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package client

import (
"context"
"encoding/json"
"io"
"net/http"

"github.com/bluesky-social/indigo/atproto/syntax"
)

// NOTE: this is an interface so it can be wrapped/extended. eg, a variant with a bunch of retries, or caching, or whatever. maybe that is too complex and we should have simple struct type, more like the existing `indigo/xrpc` package? hrm.

type APIClient interface {
// Full-power method for making atproto API requests.
Do(ctx context.Context, req *APIRequest) (*http.Response, error)

// High-level helper for simple JSON "Query" API calls.
//
// Does not work with all API endpoints. For more control, use the Do() method with APIRequest.
Get(ctx context.Context, endpoint syntax.NSID, params map[string]string) (*json.RawMessage, error)

// High-level helper for simple JSON-to-JSON "Procedure" API calls.
//
// Does not work with all API endpoints. For more control, use the Do() method with APIRequest.
// TODO: what is the right type for body, to indicate it can be marshaled as JSON?
Post(ctx context.Context, endpoint syntax.NSID, body any) (*json.RawMessage, error)

// Returns the currently-authenticated account DID, or empty string if not available.
AuthDID() syntax.DID
}

type APIRequest struct {
HTTPVerb string // TODO: type?
Endpoint syntax.NSID
Body io.Reader
QueryParams map[string]string // TODO: better type for this?
Headers map[string]string
}

func (r *APIRequest) HTTPRequest(ctx context.Context, host string, headers map[string]string) (*http.Request, error) {
// TODO: use 'url' to safely construct the request URL
u := host + "/xrpc/" + r.Endpoint.String()
// XXX: query params
httpReq, err := http.NewRequestWithContext(ctx, r.HTTPVerb, u, r.Body)
if err != nil {
return nil, err
}

// first set default headers
if headers != nil {
for k, v := range headers {
httpReq.Header.Set(k, v)
}
}

// then request-specific take priority (overwrite)
if r.Headers != nil {
for k, v := range r.Headers {
httpReq.Header.Set(k, v)
}
}

return httpReq, nil
}
13 changes: 13 additions & 0 deletions atproto/client/auth_method.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package client

import (
"context"
"net/http"

"github.com/bluesky-social/indigo/atproto/syntax"
)

type AuthMethod interface {
DoWithAuth(ctx context.Context, httpReq *http.Request, httpClient *http.Client) (*http.Response, error)
AccountDID() syntax.DID
}
107 changes: 107 additions & 0 deletions atproto/client/base_api_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package client

import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"

"github.com/bluesky-social/indigo/atproto/syntax"
)

type BaseAPIClient struct {
HTTPClient *http.Client
Host string
Auth AuthMethod
DefaultHeaders map[string]string
}

func (c *BaseAPIClient) Get(ctx context.Context, endpoint syntax.NSID, params map[string]string) (*json.RawMessage, error) {
hdr := map[string]string{
"Accept": "application/json",
}
req := APIRequest{
HTTPVerb: "GET",
Endpoint: endpoint,
Body: nil,
QueryParams: params,
Headers: hdr,
}
resp, err := c.Do(ctx, req)
if err != nil {
return nil, err
}

defer resp.Body.Close()
// TODO: duplicate error handling with Post()?
if !(resp.StatusCode >= 200 && resp.StatusCode < 300) {
return nil, fmt.Errorf("non-successful API request status: %d", resp.StatusCode)
}

var ret json.RawMessage
if err := json.NewDecoder(resp.Body).Decode(&ret); err != nil {
return nil, fmt.Errorf("expected JSON response body: %w", err)
}
return &ret, nil
}

func (c *BaseAPIClient) Post(ctx context.Context, endpoint syntax.NSID, body any) (*json.RawMessage, error) {
bodyJSON, err := json.Marshal(body)
if err != nil {
return nil, err
}
hdr := map[string]string{
"Accept": "application/json",
"Content-Type": "application/json",
}
req := APIRequest{
HTTPVerb: "POST",
Endpoint: endpoint,
Body: bytes.NewReader(bodyJSON),
QueryParams: nil,
Headers: hdr,
}
resp, err := c.Do(ctx, req)
if err != nil {
return nil, err
}

defer resp.Body.Close()
// TODO: duplicate error handling with Get()?
if !(resp.StatusCode >= 200 && resp.StatusCode < 300) {
return nil, fmt.Errorf("non-successful API request status: %d", resp.StatusCode)
}

var ret json.RawMessage
if err := json.NewDecoder(resp.Body).Decode(&ret); err != nil {
return nil, fmt.Errorf("expected JSON response body: %w", err)
}
return &ret, nil
}

func (c *BaseAPIClient) Do(ctx context.Context, req APIRequest) (*http.Response, error) {
httpReq, err := req.HTTPRequest(ctx, c.Host, c.DefaultHeaders)
if err != nil {
return nil, err
}

var resp *http.Response
if c.Auth != nil {
resp, err = c.Auth.DoWithAuth(ctx, httpReq, c.HTTPClient)
} else {
resp, err = c.HTTPClient.Do(httpReq)
}
if err != nil {
return nil, err
}
// TODO: handle some common response errors: rate-limits, 5xx, auth required, etc
return resp, nil
}

func (c *BaseAPIClient) AuthDID() syntax.DID {
if c.Auth != nil {
return c.Auth.AccountDID()
}
return ""
}
57 changes: 0 additions & 57 deletions atproto/client/client.go

This file was deleted.

39 changes: 39 additions & 0 deletions atproto/client/net_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package client

import (
"context"
"encoding/json"
"errors"
"io"

"github.com/bluesky-social/indigo/atproto/syntax"
)

// API for clients which pull data from the public atproto network.
//
// Implementations of this interface might resolve PDS instances for DIDs, and fetch data from there. Or they might talk to an archival relay or other network mirroring service.
type NetClient interface {
// Fetches record JSON, without verification or validation. A version (CID) can optionally be specified; use empty string to fetch the latest.
// Returns the record as JSON, and the CID indicated by the server. Does not verify that the data (as CBOR) matches the CID, and does not cryptographically verify a "proof chain" to the record.
GetRecordJSON(ctx context.Context, aturi syntax.ATURI, version syntax.CID) (*json.RawMessage, *syntax.CID, error)

// Fetches the indicated record as CBOR, and authenticates it by checking both the cryptographic signature and Merkle Tree hashes from the current repo revision. A version (CID) can optionally be specified; use empty string to fetch the latest.
// Returns the record as CBOR; the CID of the validated record, and the repo commit revision.
VerifyRecordCBOR(ctx context.Context, aturi syntax.ATURI, version syntax.CID) (*[]byte, *syntax.CID, string, error)

// Fetches repo export (CAR file). Optionally attempts to fetch only the diff "since" an earlier repo revision.
GetRepoCAR(ctx context.Context, did syntax.DID, since string) (*io.Reader, error)

// Fetches indicated blob. Does not validate the CID. Returns a reader (which calling code is responsible for closing).
GetBlob(ctx context.Context, did syntax.DID, cid syntax.CID) (*io.Reader, error)
CheckAccountStatus(ctx context.Context, did syntax.DID) (*AccountStatus, error)
}

// XXX: type alias to codegen? or just copy? this is protocol-level
type AccountStatus struct {
}

func VerifyBlobCID(blob []byte, cid syntax.CID) error {
// XXX: compute hash, check against provided CID
return errors.New("Not Implemented")
}
31 changes: 31 additions & 0 deletions atproto/client/refresh_auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package client

import (
"context"
"net/http"

"github.com/bluesky-social/indigo/atproto/syntax"
)

type RefreshAuth struct {
AccessToken string
RefreshToken string
DID syntax.DID
// The AuthHost might different from any APIClient host, if there is an entryway involved
AuthHost string
}

// TODO:
//func NewRefreshAuth(pdsHost, accountIdentifier, password string) (*RefreshAuth, error) {

func (a *RefreshAuth) DoWithAuth(ctx context.Context, httpReq *http.Request, httpClient *http.Client) (*http.Response, error) {
httpReq.Header.Set("Authorization", "Bearer "+a.AccessToken)
// XXX: check response. if it is 403, because access token is expired, then take a lock and do a refresh
// TODO: when doing a refresh request, copy at least the User-Agent header from httpReq, and re-use httpClient
return httpClient.Do(httpReq)
}

// Admin bearer token auth does not involve an account DID
func (a *RefreshAuth) AccountDID() syntax.DID {
return a.DID
}

0 comments on commit 8fb4c69

Please # to comment.