github.com/vnpaycloud-console/gophercloud/v2@v2.0.5/openstack/objectstorage/v1/objects/requests.go (about) 1 package objects 2 3 import ( 4 "bytes" 5 "context" 6 "crypto/hmac" 7 "crypto/md5" 8 "crypto/sha1" 9 "crypto/sha256" 10 "crypto/sha512" 11 "fmt" 12 "hash" 13 "io" 14 "net/url" 15 "strings" 16 "time" 17 18 "github.com/vnpaycloud-console/gophercloud/v2" 19 v1 "github.com/vnpaycloud-console/gophercloud/v2/openstack/objectstorage/v1" 20 "github.com/vnpaycloud-console/gophercloud/v2/openstack/objectstorage/v1/accounts" 21 "github.com/vnpaycloud-console/gophercloud/v2/openstack/objectstorage/v1/containers" 22 "github.com/vnpaycloud-console/gophercloud/v2/pagination" 23 ) 24 25 // ErrTempURLKeyNotFound is an error indicating that the Temp URL key was 26 // neigther set nor resolved from a container or account metadata. 27 type ErrTempURLKeyNotFound struct{ gophercloud.ErrMissingInput } 28 29 func (e ErrTempURLKeyNotFound) Error() string { 30 return "Unable to obtain the Temp URL key." 31 } 32 33 // ErrTempURLDigestNotValid is an error indicating that the requested 34 // cryptographic hash function is not supported. 35 type ErrTempURLDigestNotValid struct { 36 gophercloud.ErrMissingInput 37 Digest string 38 } 39 40 func (e ErrTempURLDigestNotValid) Error() string { 41 return fmt.Sprintf("The requested %q digest is not supported.", e.Digest) 42 } 43 44 // ListOptsBuilder allows extensions to add additional parameters to the List 45 // request. 46 type ListOptsBuilder interface { 47 ToObjectListParams() (string, error) 48 } 49 50 // ListOpts is a structure that holds parameters for listing objects. 51 type ListOpts struct { 52 // Full has been removed from the Gophercloud API. Gophercloud will now 53 // always request the "full" (json) listing, because simplified listing 54 // (plaintext) returns false results when names contain end-of-line 55 // characters. 56 57 Limit int `q:"limit"` 58 Marker string `q:"marker"` 59 EndMarker string `q:"end_marker"` 60 Format string `q:"format"` 61 Prefix string `q:"prefix"` 62 Delimiter string `q:"delimiter"` 63 Path string `q:"path"` 64 Versions bool `q:"versions"` 65 } 66 67 // ToObjectListParams formats a ListOpts into a query string. 68 func (opts ListOpts) ToObjectListParams() (string, error) { 69 q, err := gophercloud.BuildQueryString(opts) 70 return q.String(), err 71 } 72 73 // List is a function that retrieves all objects in a container. It also returns 74 // the details for the container. To extract only the object information or names, 75 // pass the ListResult response to the ExtractInfo or ExtractNames function, 76 // respectively. 77 func List(c *gophercloud.ServiceClient, containerName string, opts ListOptsBuilder) pagination.Pager { 78 url, err := listURL(c, containerName) 79 if err != nil { 80 return pagination.Pager{Err: err} 81 } 82 83 headers := map[string]string{"Accept": "application/json", "Content-Type": "application/json"} 84 if opts != nil { 85 query, err := opts.ToObjectListParams() 86 if err != nil { 87 return pagination.Pager{Err: err} 88 } 89 url += query 90 } 91 92 pager := pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page { 93 p := ObjectPage{pagination.MarkerPageBase{PageResult: r}} 94 p.MarkerPageBase.Owner = p 95 return p 96 }) 97 pager.Headers = headers 98 return pager 99 } 100 101 // DownloadOptsBuilder allows extensions to add additional parameters to the 102 // Download request. 103 type DownloadOptsBuilder interface { 104 ToObjectDownloadParams() (map[string]string, string, error) 105 } 106 107 // DownloadOpts is a structure that holds parameters for downloading an object. 108 type DownloadOpts struct { 109 IfMatch string `h:"If-Match"` 110 IfModifiedSince time.Time `h:"If-Modified-Since"` 111 IfNoneMatch string `h:"If-None-Match"` 112 IfUnmodifiedSince time.Time `h:"If-Unmodified-Since"` 113 Newest bool `h:"X-Newest"` 114 Range string `h:"Range"` 115 Expires string `q:"expires"` 116 MultipartManifest string `q:"multipart-manifest"` 117 Signature string `q:"signature"` 118 ObjectVersionID string `q:"version-id"` 119 } 120 121 // ToObjectDownloadParams formats a DownloadOpts into a query string and map of 122 // headers. 123 func (opts DownloadOpts) ToObjectDownloadParams() (map[string]string, string, error) { 124 q, err := gophercloud.BuildQueryString(opts) 125 if err != nil { 126 return nil, "", err 127 } 128 h, err := gophercloud.BuildHeaders(opts) 129 if err != nil { 130 return nil, q.String(), err 131 } 132 if !opts.IfModifiedSince.IsZero() { 133 h["If-Modified-Since"] = opts.IfModifiedSince.Format(time.RFC1123) 134 } 135 if !opts.IfUnmodifiedSince.IsZero() { 136 h["If-Unmodified-Since"] = opts.IfUnmodifiedSince.Format(time.RFC1123) 137 } 138 return h, q.String(), nil 139 } 140 141 // Download is a function that retrieves the content and metadata for an object. 142 // To extract just the content, call the DownloadResult method ExtractContent, 143 // after checking DownloadResult's Err field. 144 func Download(ctx context.Context, c *gophercloud.ServiceClient, containerName, objectName string, opts DownloadOptsBuilder) (r DownloadResult) { 145 url, err := downloadURL(c, containerName, objectName) 146 if err != nil { 147 r.Err = err 148 return 149 } 150 151 h := make(map[string]string) 152 if opts != nil { 153 headers, query, err := opts.ToObjectDownloadParams() 154 if err != nil { 155 r.Err = err 156 return 157 } 158 for k, v := range headers { 159 h[k] = v 160 } 161 url += query 162 } 163 164 resp, err := c.Get(ctx, url, nil, &gophercloud.RequestOpts{ 165 MoreHeaders: h, 166 OkCodes: []int{200, 206, 304}, 167 KeepResponseBody: true, 168 }) 169 r.Body, r.Header, r.Err = gophercloud.ParseResponse(resp, err) 170 return 171 } 172 173 // CreateOptsBuilder allows extensions to add additional parameters to the 174 // Create request. 175 type CreateOptsBuilder interface { 176 ToObjectCreateParams() (io.Reader, map[string]string, string, error) 177 } 178 179 // CreateOpts is a structure that holds parameters for creating an object. 180 type CreateOpts struct { 181 Content io.Reader 182 Metadata map[string]string 183 NoETag bool 184 CacheControl string `h:"Cache-Control"` 185 ContentDisposition string `h:"Content-Disposition"` 186 ContentEncoding string `h:"Content-Encoding"` 187 ContentLength int64 `h:"Content-Length"` 188 ContentType string `h:"Content-Type"` 189 CopyFrom string `h:"X-Copy-From"` 190 DeleteAfter int64 `h:"X-Delete-After"` 191 DeleteAt int64 `h:"X-Delete-At"` 192 DetectContentType string `h:"X-Detect-Content-Type"` 193 ETag string `h:"ETag"` 194 IfNoneMatch string `h:"If-None-Match"` 195 ObjectManifest string `h:"X-Object-Manifest"` 196 TransferEncoding string `h:"Transfer-Encoding"` 197 Expires string `q:"expires"` 198 MultipartManifest string `q:"multipart-manifest"` 199 Signature string `q:"signature"` 200 } 201 202 // ToObjectCreateParams formats a CreateOpts into a query string and map of 203 // headers. 204 func (opts CreateOpts) ToObjectCreateParams() (io.Reader, map[string]string, string, error) { 205 q, err := gophercloud.BuildQueryString(opts) 206 if err != nil { 207 return nil, nil, "", err 208 } 209 h, err := gophercloud.BuildHeaders(opts) 210 if err != nil { 211 return nil, nil, "", err 212 } 213 214 for k, v := range opts.Metadata { 215 h["X-Object-Meta-"+k] = v 216 } 217 218 if opts.NoETag { 219 delete(h, "etag") 220 return opts.Content, h, q.String(), nil 221 } 222 223 if h["ETag"] != "" { 224 return opts.Content, h, q.String(), nil 225 } 226 227 // When we're dealing with big files an io.ReadSeeker allows us to efficiently calculate 228 // the md5 sum. An io.Reader is only readable once which means we have to copy the entire 229 // file content into memory first. 230 readSeeker, isReadSeeker := opts.Content.(io.ReadSeeker) 231 if !isReadSeeker { 232 data, err := io.ReadAll(opts.Content) 233 if err != nil { 234 return nil, nil, "", err 235 } 236 readSeeker = bytes.NewReader(data) 237 } 238 239 hash := md5.New() 240 // io.Copy into md5 is very efficient as it's done in small chunks. 241 if _, err := io.Copy(hash, readSeeker); err != nil { 242 return nil, nil, "", err 243 } 244 _, err = readSeeker.Seek(0, io.SeekStart) 245 if err != nil { 246 return nil, nil, "", err 247 } 248 249 h["ETag"] = fmt.Sprintf("%x", hash.Sum(nil)) 250 251 return readSeeker, h, q.String(), nil 252 } 253 254 // Create is a function that creates a new object or replaces an existing 255 // object. 256 func Create(ctx context.Context, c *gophercloud.ServiceClient, containerName, objectName string, opts CreateOptsBuilder) (r CreateResult) { 257 url, err := createURL(c, containerName, objectName) 258 if err != nil { 259 r.Err = err 260 return 261 } 262 h := make(map[string]string) 263 var b io.Reader 264 if opts != nil { 265 tmpB, headers, query, err := opts.ToObjectCreateParams() 266 if err != nil { 267 r.Err = err 268 return 269 } 270 for k, v := range headers { 271 h[k] = v 272 } 273 url += query 274 b = tmpB 275 } 276 277 resp, err := c.Put(ctx, url, b, nil, &gophercloud.RequestOpts{ 278 MoreHeaders: h, 279 }) 280 _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) 281 return 282 } 283 284 // CopyOptsBuilder allows extensions to add additional parameters to the 285 // Copy request. 286 type CopyOptsBuilder interface { 287 ToObjectCopyMap() (map[string]string, error) 288 } 289 290 // CopyOptsQueryBuilder allows extensions to add additional query parameters to 291 // the Copy request. 292 type CopyOptsQueryBuilder interface { 293 ToObjectCopyQuery() (string, error) 294 } 295 296 // CopyOpts is a structure that holds parameters for copying one object to 297 // another. 298 type CopyOpts struct { 299 Metadata map[string]string 300 ContentDisposition string `h:"Content-Disposition"` 301 ContentEncoding string `h:"Content-Encoding"` 302 ContentType string `h:"Content-Type"` 303 304 // Destination is where the object should be copied to, in the form: 305 // `/container/object`. 306 Destination string `h:"Destination" required:"true"` 307 308 ObjectVersionID string `q:"version-id"` 309 } 310 311 // ToObjectCopyMap formats a CopyOpts into a map of headers. 312 func (opts CopyOpts) ToObjectCopyMap() (map[string]string, error) { 313 h, err := gophercloud.BuildHeaders(opts) 314 if err != nil { 315 return nil, err 316 } 317 for k, v := range opts.Metadata { 318 h["X-Object-Meta-"+k] = v 319 } 320 return h, nil 321 } 322 323 // ToObjectCopyQuery formats a CopyOpts into a query. 324 func (opts CopyOpts) ToObjectCopyQuery() (string, error) { 325 q, err := gophercloud.BuildQueryString(opts) 326 if err != nil { 327 return "", err 328 } 329 return q.String(), nil 330 } 331 332 // Copy is a function that copies one object to another. 333 func Copy(ctx context.Context, c *gophercloud.ServiceClient, containerName, objectName string, opts CopyOptsBuilder) (r CopyResult) { 334 h := make(map[string]string) 335 headers, err := opts.ToObjectCopyMap() 336 if err != nil { 337 r.Err = err 338 return 339 } 340 for k, v := range headers { 341 if strings.ToLower(k) == "destination" { 342 // URL-encode the container name and the object name 343 // separately before joining them around the `/` slash 344 // separator. Note that the destination path is also 345 // expected to start with a slash. 346 segments := strings.SplitN(v, "/", 3) 347 if l := len(segments); l != 3 { 348 r.Err = fmt.Errorf("the destination field is expected to contain at least two slash / characters: the initial one, and the separator between the container name and the object name") 349 return 350 } 351 if segments[0] != "" { 352 r.Err = fmt.Errorf("the destination field is expected to start with a slash") 353 return 354 } 355 for i := range segments { 356 segments[i] = url.PathEscape(segments[i]) 357 } 358 v = strings.Join(segments, "/") 359 } 360 h[k] = v 361 } 362 363 url, err := copyURL(c, containerName, objectName) 364 if err != nil { 365 r.Err = err 366 return 367 } 368 369 if opts, ok := opts.(CopyOptsQueryBuilder); ok { 370 query, err := opts.ToObjectCopyQuery() 371 if err != nil { 372 r.Err = err 373 return 374 } 375 url += query 376 } 377 378 resp, err := c.Request(ctx, "COPY", url, &gophercloud.RequestOpts{ 379 MoreHeaders: h, 380 OkCodes: []int{201}, 381 }) 382 _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) 383 return 384 } 385 386 // DeleteOptsBuilder allows extensions to add additional parameters to the 387 // Delete request. 388 type DeleteOptsBuilder interface { 389 ToObjectDeleteQuery() (string, error) 390 } 391 392 // DeleteOpts is a structure that holds parameters for deleting an object. 393 type DeleteOpts struct { 394 MultipartManifest string `q:"multipart-manifest"` 395 ObjectVersionID string `q:"version-id"` 396 } 397 398 // ToObjectDeleteQuery formats a DeleteOpts into a query string. 399 func (opts DeleteOpts) ToObjectDeleteQuery() (string, error) { 400 q, err := gophercloud.BuildQueryString(opts) 401 return q.String(), err 402 } 403 404 // Delete is a function that deletes an object. 405 func Delete(ctx context.Context, c *gophercloud.ServiceClient, containerName, objectName string, opts DeleteOptsBuilder) (r DeleteResult) { 406 url, err := deleteURL(c, containerName, objectName) 407 if err != nil { 408 r.Err = err 409 return 410 } 411 if opts != nil { 412 query, err := opts.ToObjectDeleteQuery() 413 if err != nil { 414 r.Err = err 415 return 416 } 417 url += query 418 } 419 resp, err := c.Delete(ctx, url, nil) 420 _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) 421 return 422 } 423 424 // GetOptsBuilder allows extensions to add additional parameters to the 425 // Get request. 426 type GetOptsBuilder interface { 427 ToObjectGetParams() (map[string]string, string, error) 428 } 429 430 // GetOpts is a structure that holds parameters for getting an object's 431 // metadata. 432 type GetOpts struct { 433 Newest bool `h:"X-Newest"` 434 Expires string `q:"expires"` 435 Signature string `q:"signature"` 436 ObjectVersionID string `q:"version-id"` 437 } 438 439 // ToObjectGetParams formats a GetOpts into a query string and a map of headers. 440 func (opts GetOpts) ToObjectGetParams() (map[string]string, string, error) { 441 q, err := gophercloud.BuildQueryString(opts) 442 if err != nil { 443 return nil, "", err 444 } 445 h, err := gophercloud.BuildHeaders(opts) 446 if err != nil { 447 return nil, q.String(), err 448 } 449 return h, q.String(), nil 450 } 451 452 // Get is a function that retrieves the metadata of an object. To extract just 453 // the custom metadata, pass the GetResult response to the ExtractMetadata 454 // function. 455 func Get(ctx context.Context, c *gophercloud.ServiceClient, containerName, objectName string, opts GetOptsBuilder) (r GetResult) { 456 url, err := getURL(c, containerName, objectName) 457 if err != nil { 458 r.Err = err 459 return 460 } 461 h := make(map[string]string) 462 if opts != nil { 463 headers, query, err := opts.ToObjectGetParams() 464 if err != nil { 465 r.Err = err 466 return 467 } 468 for k, v := range headers { 469 h[k] = v 470 } 471 url += query 472 } 473 474 resp, err := c.Head(ctx, url, &gophercloud.RequestOpts{ 475 MoreHeaders: h, 476 OkCodes: []int{200, 204}, 477 }) 478 _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) 479 return 480 } 481 482 // UpdateOptsBuilder allows extensions to add additional parameters to the 483 // Update request. 484 type UpdateOptsBuilder interface { 485 ToObjectUpdateMap() (map[string]string, error) 486 } 487 488 // UpdateOpts is a structure that holds parameters for updating, creating, or 489 // deleting an object's metadata. 490 type UpdateOpts struct { 491 Metadata map[string]string 492 RemoveMetadata []string 493 ContentDisposition *string `h:"Content-Disposition"` 494 ContentEncoding *string `h:"Content-Encoding"` 495 ContentType *string `h:"Content-Type"` 496 DeleteAfter *int64 `h:"X-Delete-After"` 497 DeleteAt *int64 `h:"X-Delete-At"` 498 DetectContentType *bool `h:"X-Detect-Content-Type"` 499 } 500 501 // ToObjectUpdateMap formats a UpdateOpts into a map of headers. 502 func (opts UpdateOpts) ToObjectUpdateMap() (map[string]string, error) { 503 h, err := gophercloud.BuildHeaders(opts) 504 if err != nil { 505 return nil, err 506 } 507 508 for k, v := range opts.Metadata { 509 h["X-Object-Meta-"+k] = v 510 } 511 512 for _, k := range opts.RemoveMetadata { 513 h["X-Remove-Object-Meta-"+k] = "remove" 514 } 515 return h, nil 516 } 517 518 // Update is a function that creates, updates, or deletes an object's metadata. 519 func Update(ctx context.Context, c *gophercloud.ServiceClient, containerName, objectName string, opts UpdateOptsBuilder) (r UpdateResult) { 520 url, err := updateURL(c, containerName, objectName) 521 if err != nil { 522 r.Err = err 523 return 524 } 525 h := make(map[string]string) 526 if opts != nil { 527 headers, err := opts.ToObjectUpdateMap() 528 if err != nil { 529 r.Err = err 530 return 531 } 532 533 for k, v := range headers { 534 h[k] = v 535 } 536 } 537 resp, err := c.Post(ctx, url, nil, nil, &gophercloud.RequestOpts{ 538 MoreHeaders: h, 539 }) 540 _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) 541 return 542 } 543 544 // HTTPMethod represents an HTTP method string (e.g. "GET"). 545 type HTTPMethod string 546 547 var ( 548 // GET represents an HTTP "GET" method. 549 GET HTTPMethod = "GET" 550 // HEAD represents an HTTP "HEAD" method. 551 HEAD HTTPMethod = "HEAD" 552 // PUT represents an HTTP "PUT" method. 553 PUT HTTPMethod = "PUT" 554 // POST represents an HTTP "POST" method. 555 POST HTTPMethod = "POST" 556 // DELETE represents an HTTP "DELETE" method. 557 DELETE HTTPMethod = "DELETE" 558 ) 559 560 // CreateTempURLOpts are options for creating a temporary URL for an object. 561 type CreateTempURLOpts struct { 562 // (REQUIRED) Method is the HTTP method to allow for users of the temp URL. 563 // Valid values are "GET", "HEAD", "PUT", "POST" and "DELETE". 564 Method HTTPMethod 565 566 // (REQUIRED) TTL is the number of seconds the temp URL should be active. 567 TTL int 568 569 // (Optional) Split is the string on which to split the object URL. Since only 570 // the object path is used in the hash, the object URL needs to be parsed. If 571 // empty, the default OpenStack URL split point will be used ("/v1/"). 572 Split string 573 574 // (Optional) Timestamp is the current timestamp used to calculate the Temp URL 575 // signature. If not specified, the current UNIX timestamp is used as the base 576 // timestamp. 577 Timestamp time.Time 578 579 // (Optional) TempURLKey overrides the Swift container or account Temp URL key. 580 // TempURLKey must correspond to a target container/account key, otherwise the 581 // generated link will be invalid. If not specified, the key is obtained from 582 // a Swift container or account. 583 TempURLKey string 584 585 // (Optional) Digest specifies the cryptographic hash function used to 586 // calculate the signature. Valid values include sha1, sha256, and 587 // sha512. If not specified, the default hash function is sha1. 588 Digest string 589 } 590 591 // CreateTempURL is a function for creating a temporary URL for an object. It 592 // allows users to have "GET" or "POST" access to a particular tenant's object 593 // for a limited amount of time. 594 func CreateTempURL(ctx context.Context, c *gophercloud.ServiceClient, containerName, objectName string, opts CreateTempURLOpts) (string, error) { 595 url, err := getURL(c, containerName, objectName) 596 if err != nil { 597 return "", err 598 } 599 urlToBeSigned := tempURL(c, containerName, objectName) 600 601 if opts.Split == "" { 602 opts.Split = "/v1/" 603 } 604 605 // Initialize time if it was not passed as opts 606 date := opts.Timestamp 607 if date.IsZero() { 608 date = time.Now() 609 } 610 duration := time.Duration(opts.TTL) * time.Second 611 // UNIX time is always UTC 612 expiry := date.Add(duration).Unix() 613 614 // Initialize the tempURLKey to calculate a signature 615 tempURLKey := opts.TempURLKey 616 if tempURLKey == "" { 617 // fallback to a container TempURL key 618 getHeader, err := containers.Get(ctx, c, containerName, nil).Extract() 619 if err != nil { 620 return "", err 621 } 622 tempURLKey = getHeader.TempURLKey 623 if tempURLKey == "" { 624 // fallback to an account TempURL key 625 getHeader, err := accounts.Get(ctx, c, nil).Extract() 626 if err != nil { 627 return "", err 628 } 629 tempURLKey = getHeader.TempURLKey 630 } 631 if tempURLKey == "" { 632 return "", ErrTempURLKeyNotFound{} 633 } 634 } 635 636 secretKey := []byte(tempURLKey) 637 _, objectPath, splitFound := strings.Cut(urlToBeSigned, opts.Split) 638 if !splitFound { 639 return "", fmt.Errorf("URL prefix %q not found", opts.Split) 640 } 641 objectPath = opts.Split + objectPath 642 body := fmt.Sprintf("%s\n%d\n%s", opts.Method, expiry, objectPath) 643 var hash hash.Hash 644 switch opts.Digest { 645 case "", "sha1": 646 hash = hmac.New(sha1.New, secretKey) 647 case "sha256": 648 hash = hmac.New(sha256.New, secretKey) 649 case "sha512": 650 hash = hmac.New(sha512.New, secretKey) 651 default: 652 return "", ErrTempURLDigestNotValid{Digest: opts.Digest} 653 } 654 hash.Write([]byte(body)) 655 hexsum := fmt.Sprintf("%x", hash.Sum(nil)) 656 return fmt.Sprintf("%s?temp_url_sig=%s&temp_url_expires=%d", url, hexsum, expiry), nil 657 } 658 659 // BulkDelete is a function that bulk deletes objects. 660 // In Swift, the maximum number of deletes per request is set by default to 10000. 661 // 662 // See: 663 // * https://github.com/openstack/swift/blob/6d3d4197151f44bf28b51257c1a4c5d33411dcae/etc/proxy-server.conf-sample#L1029-L1034 664 // * https://github.com/openstack/swift/blob/e8cecf7fcc1630ee83b08f9a73e1e59c07f8d372/swift/common/middleware/bulk.py#L309 665 func BulkDelete(ctx context.Context, c *gophercloud.ServiceClient, container string, objects []string) (r BulkDeleteResult) { 666 if err := v1.CheckContainerName(container); err != nil { 667 r.Err = err 668 return 669 } 670 671 encodedContainer := url.PathEscape(container) 672 673 var body bytes.Buffer 674 for i := range objects { 675 if err := v1.CheckObjectName(objects[i]); err != nil { 676 r.Err = err 677 return 678 } 679 body.WriteString(encodedContainer) 680 body.WriteRune('/') 681 body.WriteString(url.PathEscape(objects[i])) 682 body.WriteRune('\n') 683 } 684 685 resp, err := c.Post(ctx, bulkDeleteURL(c), &body, &r.Body, &gophercloud.RequestOpts{ 686 MoreHeaders: map[string]string{ 687 "Accept": "application/json", 688 "Content-Type": "text/plain", 689 }, 690 OkCodes: []int{200}, 691 }) 692 _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) 693 return 694 }