github.com/hamba/avro/v2@v2.22.1-0.20240518180522-aff3955acf7d/registry/client.go (about)

     1  // Package registry implements a Confluent Schema Registry compliant client.
     2  //
     3  // See the Confluent Schema Registry docs for an understanding of the
     4  // API: https://docs.confluent.io/current/schema-registry/docs/api.html
     5  package registry
     6  
     7  import (
     8  	"bytes"
     9  	"context"
    10  	"fmt"
    11  	"io"
    12  	"net"
    13  	"net/http"
    14  	"net/url"
    15  	"path"
    16  	"strconv"
    17  	"strings"
    18  	"sync"
    19  	"time"
    20  
    21  	"github.com/hamba/avro/v2"
    22  	jsoniter "github.com/json-iterator/go"
    23  )
    24  
    25  const contentType = "application/vnd.schemaregistry.v1+json"
    26  
    27  // Registry represents a schema registry.
    28  type Registry interface {
    29  	// GetSchema returns the schema with the given id.
    30  	GetSchema(ctx context.Context, id int) (avro.Schema, error)
    31  
    32  	// DeleteSubject delete subject.
    33  	DeleteSubject(ctx context.Context, subject string) ([]int, error)
    34  
    35  	// GetSubjects gets the registry subjects.
    36  	GetSubjects(ctx context.Context) ([]string, error)
    37  
    38  	// GetVersions gets the schema versions for a subject.
    39  	GetVersions(ctx context.Context, subject string) ([]int, error)
    40  
    41  	// GetSchemaByVersion gets the schema by version.
    42  	GetSchemaByVersion(ctx context.Context, subject string, version int) (avro.Schema, error)
    43  
    44  	// GetLatestSchema gets the latest schema for a subject.
    45  	GetLatestSchema(ctx context.Context, subject string) (avro.Schema, error)
    46  
    47  	// GetSchemaInfo gets the schema and schema metadata for a subject and version.
    48  	GetSchemaInfo(ctx context.Context, subject string, version int) (SchemaInfo, error)
    49  
    50  	// GetLatestSchemaInfo gets the latest schema and schema metadata for a subject.
    51  	GetLatestSchemaInfo(ctx context.Context, subject string) (SchemaInfo, error)
    52  
    53  	// CreateSchema creates a schema in the registry, returning the schema id.
    54  	CreateSchema(ctx context.Context, subject, schema string, references ...SchemaReference) (int, avro.Schema, error)
    55  
    56  	// IsRegistered determines if the schema is registered.
    57  	IsRegistered(ctx context.Context, subject, schema string) (int, avro.Schema, error)
    58  
    59  	// IsRegisteredWithRefs determines if the schema is registered, with optional referenced schemas.
    60  	IsRegisteredWithRefs(ctx context.Context, subject, schema string, refs ...SchemaReference) (int, avro.Schema, error)
    61  }
    62  
    63  type schemaPayload struct {
    64  	Schema     string            `json:"schema"`
    65  	References []SchemaReference `json:"references,omitempty"`
    66  }
    67  
    68  // SchemaReference represents a schema reference.
    69  type SchemaReference struct {
    70  	Name    string `json:"name"`
    71  	Subject string `json:"subject"`
    72  	Version int    `json:"version"`
    73  }
    74  
    75  type idPayload struct {
    76  	ID int `json:"id"`
    77  }
    78  
    79  type credentials struct {
    80  	username string
    81  	password string
    82  }
    83  
    84  type schemaInfoPayload struct {
    85  	Schema  string `json:"schema"`
    86  	ID      int    `json:"id"`
    87  	Version int    `json:"version"`
    88  }
    89  
    90  // Parse converts the string schema registry response into a
    91  // SchemaInfo object with an avro.Schema schema.
    92  func (s *schemaInfoPayload) Parse() (info SchemaInfo, err error) {
    93  	info = SchemaInfo{
    94  		ID:      s.ID,
    95  		Version: s.Version,
    96  	}
    97  	info.Schema, err = avro.Parse(s.Schema)
    98  	return info, err
    99  }
   100  
   101  // SchemaInfo represents a schema and metadata information.
   102  type SchemaInfo struct {
   103  	Schema  avro.Schema
   104  	ID      int
   105  	Version int
   106  }
   107  
   108  var defaultClient = &http.Client{
   109  	Transport: &http.Transport{
   110  		Proxy: http.ProxyFromEnvironment,
   111  		DialContext: (&net.Dialer{
   112  			Timeout:   15 * time.Second,
   113  			KeepAlive: 90 * time.Second,
   114  		}).DialContext,
   115  		TLSHandshakeTimeout: 3 * time.Second,
   116  		IdleConnTimeout:     90 * time.Second,
   117  	},
   118  	Timeout: 10 * time.Second,
   119  }
   120  
   121  // ClientFunc is a function used to customize the Client.
   122  type ClientFunc func(*Client)
   123  
   124  // WithHTTPClient sets the http client to make requests with.
   125  func WithHTTPClient(client *http.Client) ClientFunc {
   126  	return func(c *Client) {
   127  		c.client = client
   128  	}
   129  }
   130  
   131  // WithBasicAuth sets the credentials to perform http basic auth.
   132  func WithBasicAuth(username, password string) ClientFunc {
   133  	return func(c *Client) {
   134  		c.creds = credentials{username: username, password: password}
   135  	}
   136  }
   137  
   138  // Client is an HTTP registry client.
   139  type Client struct {
   140  	client *http.Client
   141  	base   *url.URL
   142  
   143  	creds credentials
   144  
   145  	cache sync.Map // map[int]avro.Schema
   146  }
   147  
   148  // NewClient creates a schema registry Client with the given base url.
   149  func NewClient(baseURL string, opts ...ClientFunc) (*Client, error) {
   150  	u, err := url.Parse(baseURL)
   151  	if err != nil {
   152  		return nil, err
   153  	}
   154  	if !strings.HasSuffix(u.Path, "/") {
   155  		u.Path += "/"
   156  	}
   157  
   158  	c := &Client{
   159  		client: defaultClient,
   160  		base:   u,
   161  	}
   162  
   163  	for _, opt := range opts {
   164  		opt(c)
   165  	}
   166  
   167  	return c, nil
   168  }
   169  
   170  // GetSchema returns the schema with the given id.
   171  //
   172  // GetSchema will cache the schema in memory after it is successfully returned,
   173  // allowing it to be used efficiently in a high load situation.
   174  func (c *Client) GetSchema(ctx context.Context, id int) (avro.Schema, error) {
   175  	if schema, ok := c.cache.Load(id); ok {
   176  		return schema.(avro.Schema), nil
   177  	}
   178  
   179  	var resp schemaPayload
   180  	p := path.Join("schemas", "ids", strconv.Itoa(id))
   181  	if err := c.request(ctx, http.MethodGet, p, nil, &resp); err != nil {
   182  		return nil, err
   183  	}
   184  
   185  	schema, err := avro.Parse(resp.Schema)
   186  	if err != nil {
   187  		return nil, err
   188  	}
   189  
   190  	c.cache.Store(id, schema)
   191  
   192  	return schema, nil
   193  }
   194  
   195  // GetSubjects gets the registry subjects.
   196  func (c *Client) GetSubjects(ctx context.Context) ([]string, error) {
   197  	var subjects []string
   198  	if err := c.request(ctx, http.MethodGet, "subjects", nil, &subjects); err != nil {
   199  		return nil, err
   200  	}
   201  	return subjects, nil
   202  }
   203  
   204  // DeleteSubject delete subject.
   205  func (c *Client) DeleteSubject(ctx context.Context, subject string) ([]int, error) {
   206  	var versions []int
   207  	p := path.Join("subjects", subject)
   208  	if err := c.request(ctx, http.MethodDelete, p, nil, &versions); err != nil {
   209  		return nil, err
   210  	}
   211  
   212  	return versions, nil
   213  }
   214  
   215  // GetVersions gets the schema versions for a subject.
   216  func (c *Client) GetVersions(ctx context.Context, subject string) ([]int, error) {
   217  	var versions []int
   218  	p := path.Join("subjects", subject, "versions")
   219  	if err := c.request(ctx, http.MethodGet, p, nil, &versions); err != nil {
   220  		return nil, err
   221  	}
   222  	return versions, nil
   223  }
   224  
   225  // GetSchemaByVersion gets the schema by version.
   226  func (c *Client) GetSchemaByVersion(ctx context.Context, subject string, version int) (avro.Schema, error) {
   227  	var resp schemaPayload
   228  	p := path.Join("subjects", subject, "versions", strconv.Itoa(version))
   229  	if err := c.request(ctx, http.MethodGet, p, nil, &resp); err != nil {
   230  		return nil, err
   231  	}
   232  	return avro.Parse(resp.Schema)
   233  }
   234  
   235  // GetLatestSchema gets the latest schema for a subject.
   236  func (c *Client) GetLatestSchema(ctx context.Context, subject string) (avro.Schema, error) {
   237  	var resp schemaPayload
   238  	p := path.Join("subjects", subject, "versions", "latest")
   239  	if err := c.request(ctx, http.MethodGet, p, nil, &resp); err != nil {
   240  		return nil, err
   241  	}
   242  	return avro.Parse(resp.Schema)
   243  }
   244  
   245  // GetSchemaInfo gets the schema and schema metadata for a subject and version.
   246  func (c *Client) GetSchemaInfo(ctx context.Context, subject string, version int) (SchemaInfo, error) {
   247  	var resp schemaInfoPayload
   248  	p := path.Join("subjects", subject, "versions", strconv.Itoa(version))
   249  	if err := c.request(ctx, http.MethodGet, p, nil, &resp); err != nil {
   250  		return SchemaInfo{}, err
   251  	}
   252  	return resp.Parse()
   253  }
   254  
   255  // GetLatestSchemaInfo gets the latest schema and schema metadata for a subject.
   256  func (c *Client) GetLatestSchemaInfo(ctx context.Context, subject string) (SchemaInfo, error) {
   257  	var resp schemaInfoPayload
   258  	p := path.Join("subjects", subject, "versions", "latest")
   259  	if err := c.request(ctx, http.MethodGet, p, nil, &resp); err != nil {
   260  		return SchemaInfo{}, err
   261  	}
   262  	return resp.Parse()
   263  }
   264  
   265  // CreateSchema creates a schema in the registry, returning the schema id.
   266  func (c *Client) CreateSchema(
   267  	ctx context.Context,
   268  	subject, schema string,
   269  	references ...SchemaReference,
   270  ) (int, avro.Schema, error) {
   271  	var resp idPayload
   272  	req := schemaPayload{Schema: schema, References: references}
   273  	p := path.Join("subjects", subject, "versions")
   274  	if err := c.request(ctx, http.MethodPost, p, req, &resp); err != nil {
   275  		return 0, nil, err
   276  	}
   277  
   278  	sch, err := avro.Parse(schema)
   279  	return resp.ID, sch, err
   280  }
   281  
   282  // IsRegistered determines if the schema is registered.
   283  func (c *Client) IsRegistered(ctx context.Context, subject, schema string) (int, avro.Schema, error) {
   284  	return c.IsRegisteredWithRefs(ctx, subject, schema)
   285  }
   286  
   287  // IsRegisteredWithRefs determines if the schema is registered, with optional referenced schemas.
   288  func (c *Client) IsRegisteredWithRefs(
   289  	ctx context.Context,
   290  	subject, schema string,
   291  	references ...SchemaReference,
   292  ) (int, avro.Schema, error) {
   293  	var resp idPayload
   294  	req := schemaPayload{Schema: schema, References: references}
   295  	if err := c.request(ctx, http.MethodPost, path.Join("subjects", subject), req, &resp); err != nil {
   296  		return 0, nil, err
   297  	}
   298  
   299  	sch, err := avro.Parse(schema)
   300  	return resp.ID, sch, err
   301  }
   302  
   303  // Compatibility levels.
   304  const (
   305  	BackwardCL           string = "BACKWARD"
   306  	BackwardTransitiveCL string = "BACKWARD_TRANSITIVE"
   307  	ForwardCL            string = "FORWARD"
   308  	ForwardTransitiveCL  string = "FORWARD_TRANSITIVE"
   309  	FullCL               string = "FULL"
   310  	FullTransitiveCL     string = "FULL_TRANSITIVE"
   311  	NoneCL               string = "NONE"
   312  )
   313  
   314  func validateCompatibilityLevel(lvl string) error {
   315  	switch lvl {
   316  	case BackwardCL, BackwardTransitiveCL, ForwardCL, ForwardTransitiveCL, FullCL, FullTransitiveCL, NoneCL:
   317  		return nil
   318  	default:
   319  		return fmt.Errorf("invalid compatibility level %s", lvl)
   320  	}
   321  }
   322  
   323  type compatPayload struct {
   324  	Compatibility string `json:"compatibility"`
   325  }
   326  
   327  // SetGlobalCompatibilityLevel sets the global compatibility level of the registry.
   328  func (c *Client) SetGlobalCompatibilityLevel(ctx context.Context, lvl string) error {
   329  	if err := validateCompatibilityLevel(lvl); err != nil {
   330  		return err
   331  	}
   332  
   333  	req := compatPayload{Compatibility: lvl}
   334  	return c.request(ctx, http.MethodPut, "config", req, nil)
   335  }
   336  
   337  // SetCompatibilityLevel sets the compatibility level of a subject.
   338  func (c *Client) SetCompatibilityLevel(ctx context.Context, subject, lvl string) error {
   339  	if err := validateCompatibilityLevel(lvl); err != nil {
   340  		return err
   341  	}
   342  
   343  	req := compatPayload{Compatibility: lvl}
   344  	return c.request(ctx, http.MethodPut, path.Join("config", subject), req, nil)
   345  }
   346  
   347  // GetGlobalCompatibilityLevel gets the global compatibility level.
   348  func (c *Client) GetGlobalCompatibilityLevel(ctx context.Context) (string, error) {
   349  	var resp compatPayload
   350  	if err := c.request(ctx, http.MethodGet, "config", nil, &resp); err != nil {
   351  		return "", err
   352  	}
   353  	return resp.Compatibility, nil
   354  }
   355  
   356  // GetCompatibilityLevel gets the compatibility level of a subject.
   357  func (c *Client) GetCompatibilityLevel(ctx context.Context, subject string) (string, error) {
   358  	var resp compatPayload
   359  	if err := c.request(ctx, http.MethodGet, path.Join("config", subject), nil, &resp); err != nil {
   360  		return "", err
   361  	}
   362  	return resp.Compatibility, nil
   363  }
   364  
   365  func (c *Client) request(ctx context.Context, method, path string, in, out any) error {
   366  	var body io.Reader
   367  	if in != nil {
   368  		b, _ := jsoniter.Marshal(in)
   369  		body = bytes.NewReader(b)
   370  	}
   371  
   372  	// These errors are not possible as we have already parse the base URL.
   373  	u, _ := c.base.Parse(path)
   374  	req, _ := http.NewRequestWithContext(ctx, method, u.String(), body)
   375  	req.Header.Set("Content-Type", contentType)
   376  
   377  	if len(c.creds.username) > 0 || len(c.creds.password) > 0 {
   378  		req.SetBasicAuth(c.creds.username, c.creds.password)
   379  	}
   380  
   381  	resp, err := c.client.Do(req)
   382  	if err != nil {
   383  		return fmt.Errorf("could not perform request: %w", err)
   384  	}
   385  	defer func() {
   386  		_, _ = io.Copy(io.Discard, resp.Body)
   387  		_ = resp.Body.Close()
   388  	}()
   389  
   390  	if resp.StatusCode >= http.StatusBadRequest {
   391  		err := Error{StatusCode: resp.StatusCode}
   392  		_ = jsoniter.NewDecoder(resp.Body).Decode(&err)
   393  		return err
   394  	}
   395  
   396  	if out != nil {
   397  		return jsoniter.NewDecoder(resp.Body).Decode(out)
   398  	}
   399  	return nil
   400  }
   401  
   402  // Error is returned by the registry when there is an error.
   403  type Error struct {
   404  	StatusCode int    `json:"-"`
   405  	Code       int    `json:"error_code"`
   406  	Message    string `json:"message"`
   407  }
   408  
   409  // Error returns the error message.
   410  func (e Error) Error() string {
   411  	if e.Message != "" {
   412  		return e.Message
   413  	}
   414  	return "registry error: " + strconv.Itoa(e.StatusCode)
   415  }