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 }