github.com/landoop/schema-registry@v0.0.0-20190327143759-50a5701c1891/client.go (about) 1 // Package schemaregistry provides a client for Confluent's Kafka Schema Registry REST API. 2 package schemaregistry 3 4 import ( 5 "bytes" 6 "compress/gzip" 7 "crypto/tls" 8 "encoding/json" 9 "fmt" 10 "io" 11 "io/ioutil" 12 "net" 13 "net/http" 14 "net/url" 15 "strings" 16 "time" 17 ) 18 19 // DefaultURL is the address where a local schema registry listens by default. 20 const DefaultURL = "http://localhost:8081" 21 22 type ( 23 httpDoer interface { 24 Do(req *http.Request) (resp *http.Response, err error) 25 } 26 // Client is the registry schema REST API client. 27 Client struct { 28 baseURL string 29 30 // the client is created on the `NewClient` function, it can be customized via options. 31 client httpDoer 32 } 33 34 // Option describes an optional runtime configurator that can be passed on `NewClient`. 35 // Custom `Option` can be used as well, it's just a type of `func(*schemaregistry.Client)`. 36 // 37 // Look `UsingClient`. 38 Option func(*Client) 39 ) 40 41 // UsingClient modifies the underline HTTP Client that schema registry is using for contact with the backend server. 42 func UsingClient(httpClient *http.Client) Option { 43 return func(c *Client) { 44 if httpClient == nil { 45 return 46 } 47 48 transport := getTransportLayer(httpClient, 0) 49 httpClient.Transport = transport 50 51 c.client = httpClient 52 } 53 } 54 55 func getTransportLayer(httpClient *http.Client, timeout time.Duration) (t http.RoundTripper) { 56 if t := httpClient.Transport; t != nil { 57 return t 58 } 59 60 httpTransport := &http.Transport{ 61 TLSNextProto: make(map[string]func(authority string, c *tls.Conn) http.RoundTripper), 62 } 63 64 if timeout > 0 { 65 httpTransport.Dial = func(network string, addr string) (net.Conn, error) { 66 return net.DialTimeout(network, addr, timeout) 67 } 68 } 69 70 return httpTransport 71 } 72 73 // formatBaseURL will try to make sure that the schema:host:port pattern is followed on the `baseURL` field. 74 func formatBaseURL(baseURL string) string { 75 if baseURL == "" { 76 return "" 77 } 78 79 // remove last slash, so the API can append the path with ease. 80 if baseURL[len(baseURL)-1] == '/' { 81 baseURL = baseURL[0 : len(baseURL)-1] 82 } 83 84 portIdx := strings.LastIndexByte(baseURL, ':') 85 86 schemaIdx := strings.Index(baseURL, "://") 87 hasSchema := schemaIdx >= 0 88 hasPort := portIdx > schemaIdx+1 89 90 var port = "80" 91 if hasPort { 92 port = baseURL[portIdx+1:] 93 } 94 95 // find the schema based on the port. 96 if !hasSchema { 97 if port == "443" { 98 baseURL = "https://" + baseURL 99 } else { 100 baseURL = "http://" + baseURL 101 } 102 } else if !hasPort { 103 // has schema but not port. 104 if strings.HasPrefix(baseURL, "https://") { 105 port = "443" 106 } 107 } 108 109 // finally, append the port part if it wasn't there. 110 if !hasPort { 111 baseURL += ":" + port 112 } 113 114 return baseURL 115 } 116 117 // NewClient creates & returns a new Registry Schema Client 118 // based on the passed url and the options. 119 func NewClient(baseURL string, options ...Option) (*Client, error) { 120 baseURL = formatBaseURL(baseURL) 121 if _, err := url.Parse(baseURL); err != nil { 122 return nil, err 123 } 124 125 c := &Client{baseURL: baseURL} 126 for _, opt := range options { 127 opt(c) 128 } 129 130 if c.client == nil { 131 httpClient := &http.Client{} 132 UsingClient(httpClient)(c) 133 } 134 135 return c, nil 136 } 137 138 const ( 139 contentTypeHeaderKey = "Content-Type" 140 contentTypeJSON = "application/json" 141 142 acceptHeaderKey = "Accept" 143 acceptEncodingHeaderKey = "Accept-Encoding" 144 contentEncodingHeaderKey = "Content-Encoding" 145 gzipEncodingHeaderValue = "gzip" 146 ) 147 148 // ResourceError is being fired from all API calls when an error code is received. 149 type ResourceError struct { 150 ErrorCode int `json:"error_code"` 151 Method string `json:"method,omitempty"` 152 URI string `json:"uri,omitempty"` 153 Message string `json:"message,omitempty"` 154 } 155 156 func (err ResourceError) Error() string { 157 return fmt.Sprintf("client: (%s: %s) failed with error code %d%s", 158 err.Method, err.URI, err.ErrorCode, err.Message) 159 } 160 161 func newResourceError(errCode int, uri, method, body string) ResourceError { 162 unescapedURI, _ := url.QueryUnescape(uri) 163 164 return ResourceError{ 165 ErrorCode: errCode, 166 URI: unescapedURI, 167 Method: method, 168 Message: body, 169 } 170 } 171 172 // These numbers are used by the schema registry to communicate errors. 173 const ( 174 subjectNotFoundCode = 40401 175 schemaNotFoundCode = 40403 176 ) 177 178 // IsSubjectNotFound checks the returned error to see if it is kind of a subject not found error code. 179 func IsSubjectNotFound(err error) bool { 180 if err == nil { 181 return false 182 } 183 184 if resErr, ok := err.(ResourceError); ok { 185 return resErr.ErrorCode == subjectNotFoundCode 186 } 187 188 return false 189 } 190 191 // IsSchemaNotFound checks the returned error to see if it is kind of a schema not found error code. 192 func IsSchemaNotFound(err error) bool { 193 if err == nil { 194 return false 195 } 196 197 if resErr, ok := err.(ResourceError); ok { 198 return resErr.ErrorCode == schemaNotFoundCode 199 } 200 201 return false 202 } 203 204 // isOK is called inside the `Client#do` and it closes the body reader if no accessible. 205 func isOK(resp *http.Response) bool { 206 return !(resp.StatusCode < 200 || resp.StatusCode >= 300) 207 } 208 209 var noOpBuffer = new(bytes.Buffer) 210 211 func acquireBuffer(b []byte) *bytes.Buffer { 212 if len(b) > 0 { 213 return bytes.NewBuffer(b) 214 } 215 216 return noOpBuffer 217 } 218 219 const schemaAPIVersion = "v1" 220 const contentTypeSchemaJSON = "application/vnd.schemaregistry." + schemaAPIVersion + "+json" 221 222 func (c *Client) do(method, path, contentType string, send []byte) (*http.Response, error) { 223 if path[0] == '/' { 224 path = path[1:] 225 } 226 227 uri := c.baseURL + "/" + path 228 229 req, err := http.NewRequest(method, uri, acquireBuffer(send)) 230 if err != nil { 231 return nil, err 232 } 233 234 // set the content type if any. 235 if contentType != "" { 236 req.Header.Set(contentTypeHeaderKey, contentType) 237 } 238 239 // response accept gziped content. 240 req.Header.Add(acceptEncodingHeaderKey, gzipEncodingHeaderValue) 241 req.Header.Add(acceptHeaderKey, contentTypeSchemaJSON+", application/vnd.schemaregistry+json, application/json") 242 243 // send the request and check the response for any connection & authorization errors here. 244 resp, err := c.client.Do(req) 245 if err != nil { 246 return nil, err 247 } 248 249 if !isOK(resp) { 250 defer resp.Body.Close() 251 var errBody string 252 respContentType := resp.Header.Get(contentTypeHeaderKey) 253 254 if strings.Contains(respContentType, "text/html") { 255 // if the body is html, then don't read it, it doesn't contain the raw info we need. 256 } else if strings.Contains(respContentType, "json") { 257 // if it's json try to read it as confluent's specific error json. 258 var resErr ResourceError 259 c.readJSON(resp, &resErr) 260 return nil, resErr 261 } else { 262 // else give the whole body to the error context. 263 b, err := c.readResponseBody(resp) 264 if err != nil { 265 errBody = " unable to read body: " + err.Error() 266 } else { 267 errBody = "\n" + string(b) 268 } 269 } 270 271 return nil, newResourceError(resp.StatusCode, uri, method, errBody) 272 } 273 274 return resp, nil 275 } 276 277 type gzipReadCloser struct { 278 respReader io.ReadCloser 279 gzipReader io.ReadCloser 280 } 281 282 func (rc *gzipReadCloser) Close() error { 283 if rc.gzipReader != nil { 284 defer rc.gzipReader.Close() 285 } 286 287 return rc.respReader.Close() 288 } 289 290 func (rc *gzipReadCloser) Read(p []byte) (n int, err error) { 291 if rc.gzipReader != nil { 292 return rc.gzipReader.Read(p) 293 } 294 295 return rc.respReader.Read(p) 296 } 297 298 func (c *Client) acquireResponseBodyStream(resp *http.Response) (io.ReadCloser, error) { 299 // check for gzip and read it, the right way. 300 var ( 301 reader = resp.Body 302 err error 303 ) 304 305 if encoding := resp.Header.Get(contentEncodingHeaderKey); encoding == gzipEncodingHeaderValue { 306 reader, err = gzip.NewReader(resp.Body) 307 if err != nil { 308 return nil, fmt.Errorf("client: failed to read gzip compressed content, trace: %v", err) 309 } 310 // we wrap the gzipReader and the underline response reader 311 // so a call of .Close() can close both of them with the correct order when finish reading, the caller decides. 312 // Must close manually using a defer on the callers before the `readResponseBody` call, 313 // note that the `readJSON` can decide correctly by itself. 314 return &gzipReadCloser{ 315 respReader: resp.Body, 316 gzipReader: reader, 317 }, nil 318 } 319 320 // return the stream reader. 321 return reader, err 322 } 323 324 func (c *Client) readResponseBody(resp *http.Response) ([]byte, error) { 325 reader, err := c.acquireResponseBodyStream(resp) 326 if err != nil { 327 return nil, err 328 } 329 330 body, err := ioutil.ReadAll(reader) 331 if err = reader.Close(); err != nil { 332 return nil, err 333 } 334 335 // return the body. 336 return body, err 337 } 338 339 func (c *Client) readJSON(resp *http.Response, valuePtr interface{}) error { 340 b, err := c.readResponseBody(resp) 341 if err != nil { 342 return err 343 } 344 345 return json.Unmarshal(b, valuePtr) 346 } 347 348 var errRequired = func(field string) error { 349 return fmt.Errorf("client: %s is required", field) 350 } 351 352 const ( 353 subjectsPath = "subjects" 354 subjectPath = subjectsPath + "/%s" 355 schemaPath = "schemas/ids/%d" 356 ) 357 358 // Subjects returns a list of the available subjects(schemas). 359 // https://docs.confluent.io/current/schema-registry/docs/api.html#subjects 360 func (c *Client) Subjects() (subjects []string, err error) { 361 // # List all available subjects 362 // GET /subjects 363 resp, respErr := c.do(http.MethodGet, subjectsPath, "", nil) 364 if respErr != nil { 365 err = respErr 366 return 367 } 368 369 err = c.readJSON(resp, &subjects) 370 return 371 } 372 373 // Versions returns all schema version numbers registered for this subject. 374 func (c *Client) Versions(subject string) (versions []int, err error) { 375 if subject == "" { 376 err = errRequired("subject") 377 return 378 } 379 380 // # List all versions of a particular subject 381 // GET /subjects/(string: subject)/versions 382 path := fmt.Sprintf(subjectPath, subject+"/versions") 383 resp, respErr := c.do(http.MethodGet, path, "", nil) 384 if respErr != nil { 385 err = respErr 386 return 387 } 388 389 err = c.readJSON(resp, &versions) 390 return 391 } 392 393 // DeleteSubject deletes the specified subject and its associated compatibility level if registered. 394 // It is recommended to use this API only when a topic needs to be recycled or in development environment. 395 // Returns the versions of the schema deleted under this subject. 396 func (c *Client) DeleteSubject(subject string) (versions []int, err error) { 397 if subject == "" { 398 err = errRequired("subject") 399 return 400 } 401 402 // DELETE /subjects/(string: subject) 403 path := fmt.Sprintf(subjectPath, subject) 404 resp, respErr := c.do(http.MethodDelete, path, "", nil) 405 if respErr != nil { 406 err = respErr 407 return 408 } 409 410 err = c.readJSON(resp, &versions) 411 return 412 } 413 414 // IsRegistered tells if the given "schema" is registered for this "subject". 415 func (c *Client) IsRegistered(subject, schema string) (bool, Schema, error) { 416 var fs Schema 417 418 sc := schemaOnlyJSON{schema} 419 send, err := json.Marshal(sc) 420 if err != nil { 421 return false, fs, err 422 } 423 424 path := fmt.Sprintf(subjectPath, subject) 425 resp, err := c.do(http.MethodPost, path, "", send) 426 if err != nil { 427 // schema not found? 428 if IsSchemaNotFound(err) { 429 return false, fs, nil 430 } 431 // error? 432 return false, fs, err 433 } 434 435 if err = c.readJSON(resp, &fs); err != nil { 436 return true, fs, err // found but error when unmarshal. 437 } 438 439 // so we have a schema. 440 return true, fs, nil 441 } 442 443 type ( 444 schemaOnlyJSON struct { 445 Schema string `json:"schema"` 446 } 447 448 idOnlyJSON struct { 449 ID int `json:"id"` 450 } 451 452 isCompatibleJSON struct { 453 IsCompatible bool `json:"is_compatible"` 454 } 455 456 // Schema describes a schema, look `GetSchema` for more. 457 Schema struct { 458 // Schema is the Avro schema string. 459 Schema string `json:"schema"` 460 // Subject where the schema is registered for. 461 Subject string `json:"subject"` 462 // Version of the returned schema. 463 Version int `json:"version"` 464 ID int `json:"id,omitempty"` 465 } 466 467 // Config describes a subject or globa schema-registry configuration 468 Config struct { 469 // CompatibilityLevel mode of subject or global 470 CompatibilityLevel string `json:"compatibilityLevel"` 471 } 472 ) 473 474 // RegisterNewSchema registers a schema. 475 // The returned identifier should be used to retrieve 476 // this schema from the schemas resource and is different from 477 // the schema’s version which is associated with that name. 478 func (c *Client) RegisterNewSchema(subject string, avroSchema string) (int, error) { 479 if subject == "" { 480 return 0, errRequired("subject") 481 } 482 if avroSchema == "" { 483 return 0, errRequired("avroSchema") 484 } 485 486 schema := schemaOnlyJSON{ 487 Schema: avroSchema, 488 } 489 490 send, err := json.Marshal(schema) 491 if err != nil { 492 return 0, err 493 } 494 495 // # Register a new schema under a particular subject 496 // POST /subjects/(string: subject)/versions 497 498 path := fmt.Sprintf(subjectPath+"/versions", subject) 499 resp, err := c.do(http.MethodPost, path, contentTypeSchemaJSON, send) 500 if err != nil { 501 return 0, err 502 } 503 504 var res idOnlyJSON 505 err = c.readJSON(resp, &res) 506 return res.ID, err 507 } 508 509 // JSONAvroSchema converts and returns the json form of the "avroSchema" as []byte. 510 func JSONAvroSchema(avroSchema string) (json.RawMessage, error) { 511 var raw json.RawMessage 512 err := json.Unmarshal(json.RawMessage(avroSchema), &raw) 513 if err != nil { 514 return nil, err 515 } 516 return raw, err 517 } 518 519 // GetSchemaByID returns the Auro schema string identified by the id. 520 // id (int) – the globally unique identifier of the schema. 521 func (c *Client) GetSchemaByID(subjectID int) (string, error) { 522 // # Get the schema for a particular subject id 523 // GET /schemas/ids/{int: id} 524 path := fmt.Sprintf(schemaPath, subjectID) 525 resp, err := c.do(http.MethodGet, path, "", nil) 526 if err != nil { 527 return "", err 528 } 529 530 var res schemaOnlyJSON 531 if err = c.readJSON(resp, &res); err != nil { 532 return "", err 533 } 534 535 return res.Schema, nil 536 } 537 538 // SchemaLatestVersion is the only one valid string for the "versionID", it's the "latest" version string and it's used on `GetLatestSchema`. 539 const SchemaLatestVersion = "latest" 540 541 func checkSchemaVersionID(versionID interface{}) error { 542 if versionID == nil { 543 return errRequired("versionID (string \"latest\" or int)") 544 } 545 546 if verStr, ok := versionID.(string); ok { 547 if verStr != SchemaLatestVersion { 548 return fmt.Errorf("client: %v string is not a valid value for the versionID input parameter [versionID == \"latest\"]", versionID) 549 } 550 } 551 552 if verInt, ok := versionID.(int); ok { 553 if verInt <= 0 || verInt > 2^31-1 { // it's the max of int32, math.MaxInt32 already but do that check. 554 return fmt.Errorf("client: %v integer is not a valid value for the versionID input parameter [ versionID > 0 && versionID <= 2^31-1]", versionID) 555 } 556 } 557 558 return nil 559 } 560 561 // subject (string) – Name of the subject 562 // version (versionId [string "latest" or 1,2^31-1]) – Version of the schema to be returned. 563 // Valid values for versionId are between [1,2^31-1] or the string “latest”. 564 // The string “latest” refers to the last registered schema under the specified subject. 565 // Note that there may be a new latest schema that gets registered right after this request is served. 566 // 567 // It's not safe to use just an interface to the high-level API, therefore we split this method 568 // to two, one which will retrieve the latest versioned schema and the other which will accept 569 // the version as integer and it will retrieve by a specific version. 570 // 571 // See `GetLatestSchema` and `GetSchemaAtVersion` instead. 572 func (c *Client) getSubjectSchemaAtVersion(subject string, versionID interface{}) (s Schema, err error) { 573 if subject == "" { 574 err = errRequired("subject") 575 return 576 } 577 578 if err = checkSchemaVersionID(versionID); err != nil { 579 return 580 } 581 582 // # Get the schema at a particular version 583 // GET /subjects/(string: subject)/versions/(versionId: "latest" | int) 584 path := fmt.Sprintf(subjectPath+"/versions/%v", subject, versionID) 585 resp, respErr := c.do(http.MethodGet, path, "", nil) 586 if respErr != nil { 587 err = respErr 588 return 589 } 590 591 err = c.readJSON(resp, &s) 592 return 593 } 594 595 // GetSchemaBySubject returns the schema for a particular subject and version. 596 func (c *Client) GetSchemaBySubject(subject string, versionID int) (Schema, error) { 597 return c.getSubjectSchemaAtVersion(subject, versionID) 598 } 599 600 // GetLatestSchema returns the latest version of a schema. 601 // See `GetSchemaAtVersion` to retrieve a subject schema by a specific version. 602 func (c *Client) GetLatestSchema(subject string) (Schema, error) { 603 return c.getSubjectSchemaAtVersion(subject, SchemaLatestVersion) 604 } 605 606 // getConfigSubject returns the Config of global or for a given subject. It handles 404 error in a 607 // different way, since not-found for a subject configuration means it's using global. 608 func (c *Client) getConfigSubject(subject string) (Config, error) { 609 var err error 610 var config = Config{} 611 612 path := fmt.Sprintf("/config/%s", subject) 613 resp, respErr := c.do(http.MethodGet, path, "", nil) 614 if respErr != nil && respErr.(ResourceError).ErrorCode != 404 { 615 return config, respErr 616 } 617 if resp != nil { 618 err = c.readJSON(resp, &config) 619 } 620 621 return config, err 622 } 623 624 // GetConfig returns the configuration (Config type) for global Schema-Registry or a specific 625 // subject. When Config returned has "compatibilityLevel" empty, it's using global settings. 626 func (c *Client) GetConfig(subject string) (Config, error) { 627 return c.getConfigSubject(subject) 628 } 629 630 // subject (string) – Name of the subject 631 // version (versionId [string "latest" or 1,2^31-1]) – Version of the schema to be returned. 632 // Valid values for versionId are between [1,2^31-1] or the string “latest”. 633 // The string “latest” refers to the last registered schema under the specified subject. 634 // Note that there may be a new latest schema that gets registered right after this request is served. 635 // 636 // It's not safe to use just an interface to the high-level API, therefore we split this method 637 // to two, one which will retrieve the latest versioned schema and the other which will accept 638 // the version as integer and it will retrieve by a specific version. 639 // 640 // See `IsSchemaCompatible` and `IsLatestSchemaCompatible` instead. 641 func (c *Client) isSchemaCompatibleAtVersion(subject string, avroSchema string, versionID interface{}) (combatible bool, err error) { 642 if subject == "" { 643 err = errRequired("subject") 644 return 645 } 646 if avroSchema == "" { 647 err = errRequired("avroSchema") 648 return 649 } 650 651 if err = checkSchemaVersionID(versionID); err != nil { 652 return 653 } 654 655 schema := schemaOnlyJSON{ 656 Schema: avroSchema, 657 } 658 659 send, err := json.Marshal(schema) 660 if err != nil { 661 return 662 } 663 664 // # Test input schema against a particular version of a subject’s schema for compatibility 665 // POST /compatibility/subjects/(string: subject)/versions/(versionId: "latest" | int) 666 path := fmt.Sprintf("compatibility/"+subjectPath+"/versions/%v", subject, versionID) 667 resp, err := c.do(http.MethodPost, path, contentTypeSchemaJSON, send) 668 if err != nil { 669 return 670 } 671 672 var res isCompatibleJSON 673 err = c.readJSON(resp, &res) 674 return res.IsCompatible, err 675 } 676 677 // IsSchemaCompatible tests compatibility with a specific version of a subject's schema. 678 func (c *Client) IsSchemaCompatible(subject string, avroSchema string, versionID int) (bool, error) { 679 return c.isSchemaCompatibleAtVersion(subject, avroSchema, versionID) 680 } 681 682 // IsLatestSchemaCompatible tests compatibility with the latest version of a subject's schema. 683 func (c *Client) IsLatestSchemaCompatible(subject string, avroSchema string) (bool, error) { 684 return c.isSchemaCompatibleAtVersion(subject, avroSchema, SchemaLatestVersion) 685 }