From 219cc1c2c1abac86d34faf5b1810478393af74cd Mon Sep 17 00:00:00 2001 From: Chris Robinson Date: Mon, 27 Oct 2014 12:08:04 -0700 Subject: [PATCH] Added Capability to get images and images with details new file: examples/30-image-v1.go modified: examples/config.json.dist modified: examples/setup.go modified: identity/v2/auth.go new file: image/v1/image.go new file: image/v1/image_test.go new file: misc/rfc8601DateTime.go new file: misc/rfc8601DateTime_test.go modified: misc/util.go modified: misc/util_test.go modified: objectstorage/v1/objectstorage.go modified: objectstorage/v1/objectstorage_test.go new file: testUtil/testUtil.go Partially implements blueprint image-v1 Change-Id: I6277a5c8915f45f4b7585855d836015ffd9e1c12 --- examples/30-image-v1.go | 75 +++++++ examples/config.json.dist | 1 + examples/setup.go | 1 + identity/v2/auth.go | 2 +- image/v1/image.go | 211 ++++++++++++++++++++ image/v1/image_test.go | 261 +++++++++++++++++++++++++ misc/rfc8601DateTime.go | 57 ++++++ misc/rfc8601DateTime_test.go | 58 ++++++ misc/util.go | 134 ++++++++++++- misc/util_test.go | 29 +++ objectstorage/v1/objectstorage.go | 26 +-- objectstorage/v1/objectstorage_test.go | 8 +- testUtil/testUtil.go | 139 +++++++++++++ 13 files changed, 979 insertions(+), 23 deletions(-) create mode 100644 examples/30-image-v1.go create mode 100644 image/v1/image.go create mode 100644 image/v1/image_test.go create mode 100644 misc/rfc8601DateTime.go create mode 100644 misc/rfc8601DateTime_test.go create mode 100644 testUtil/testUtil.go diff --git a/examples/30-image-v1.go b/examples/30-image-v1.go new file mode 100644 index 0000000..b63e0b6 --- /dev/null +++ b/examples/30-image-v1.go @@ -0,0 +1,75 @@ +// Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package main + +import ( + "fmt" + "git.openstack.org/stackforge/golang-client.git/identity/v2" + "git.openstack.org/stackforge/golang-client.git/image/v1" + "net/http" + "time" +) + +// Image examples. +func main() { + config := getConfig() + + // Authenticate with a username, password, tenant id. + auth, err := identity.AuthUserNameTenantName(config.Host, + config.Username, + config.Password, + config.ProjectName) + if err != nil { + panicString := fmt.Sprint("There was an error authenticating:", err) + panic(panicString) + } + if !auth.Access.Token.Expires.After(time.Now()) { + panic("There was an error. The auth token has an invalid expiration.") + } + + // Find the endpoint for the image service. + url := "" + for _, svc := range auth.Access.ServiceCatalog { + if svc.Type == "image" { + for _, ep := range svc.Endpoints { + if ep.VersionId == "1.0" && ep.Region == config.ImageRegion { + url = ep.PublicURL + break + } + } + } + } + + if url == "" { + panic("v1 image service url not found during authentication") + } + + imageService := image.Service{TokenID: auth.Access.Token.Id, Client: *http.DefaultClient, URL: url} + imagesDetails, err := imageService.ImagesDetail() + if err != nil { + panicString := fmt.Sprint("Cannot access images:", err) + panic(panicString) + } + + var imageIDs = make([]string, 0) + for _, element := range imagesDetails { + imageIDs = append(imageIDs, element.ID) + } + + if len(imageIDs) == 0 { + panicString := fmt.Sprint("No images found, check to make sure access is correct") + panic(panicString) + } +} diff --git a/examples/config.json.dist b/examples/config.json.dist index a5619b1..c4aae1d 100644 --- a/examples/config.json.dist +++ b/examples/config.json.dist @@ -5,4 +5,5 @@ "ProjectID": "", "ProjectName": "", "Container": "I♡HPHelion" + "ImageRegion": "" } \ No newline at end of file diff --git a/examples/setup.go b/examples/setup.go index 069ea60..aeaf26a 100644 --- a/examples/setup.go +++ b/examples/setup.go @@ -31,6 +31,7 @@ type testconfig struct { ProjectID string ProjectName string Container string + ImageRegion string } // getConfig provides access to credentials in other tests and examples. diff --git a/identity/v2/auth.go b/identity/v2/auth.go index 5383175..b8b2514 100644 --- a/identity/v2/auth.go +++ b/identity/v2/auth.go @@ -135,7 +135,7 @@ func auth(url, jsonStr *string) (Auth, error) { if err != nil { return Auth{}, err } - if err = misc.CheckHttpResponseStatusCode(resp); err != nil { + if err = misc.CheckHTTPResponseStatusCode(resp); err != nil { return Auth{}, err } var contentType string = strings.ToLower(resp.Header.Get("Content-Type")) diff --git a/image/v1/image.go b/image/v1/image.go new file mode 100644 index 0000000..6945d49 --- /dev/null +++ b/image/v1/image.go @@ -0,0 +1,211 @@ +// Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +/* +Package image implements a client library for accessing OpenStack Image V1 service + +Images and ImageDetails can be retrieved using the api. + +In addition more complex filtering and sort queries can by using the ImageQueryParameters. + +*/ +package image + +import ( + "fmt" + "git.openstack.org/stackforge/golang-client.git/misc" + "net/http" + "net/url" +) + +// Service is a client service that can make +// requests against a OpenStack version 1 image service. +// Below is an example on creating an image service and getting images: +// imageService := image.ImageService{Client: *http.DefaultClient, TokenId: tokenId, Url: "http://imageservicelocation"} +// images:= imageService.Images() +type Service struct { + Client http.Client + TokenID string + URL string +} + +// Response is a structure for all properties of +// an image for a non detailed query +type Response struct { + CheckSum string `json:"checksum"` + ContainerFormat string `json:"container_format"` + DiskFormat string `json:"disk_format"` + ID string `json:"id"` + Name string `json:"name"` + Size int64 `json:"size"` +} + +// DetailResponse is a structure for all properties of +// an image for a detailed query +type DetailResponse struct { + CheckSum string `json:"checksum"` + ContainerFormat string `json:"container_format"` + CreatedAt misc.RFC8601DateTime `json:"created_at"` + Deleted bool `json:"deleted"` + DeletedAt *misc.RFC8601DateTime `json:"deleted_at"` + DiskFormat string `json:"disk_format"` + ID string `json:"id"` + IsPublic bool `json:"is_public"` + MinDisk int64 `json:"min_disk"` + MinRAM int64 `json:"min_ram"` + Name string `json:"name"` + Owner *string `json:"owner"` + UpdatedAt misc.RFC8601DateTime `json:"updated_at"` + Properties map[string]string `json:"properties"` + Protected bool `json:"protected"` + Status string `json:"status"` + Size int64 `json:"size"` + VirtualSize *int64 `json:"virtual_size"` // Note: Property exists in OpenStack dev stack payloads but not Helion public cloud. +} + +// QueryParameters is a structure that +// contains the filter, sort, and paging parameters for +// an image or imagedetail query. +type QueryParameters struct { + Name string + Status string + ContainerFormat string + DiskFormat string + MinSize int64 + MaxSize int64 + SortKey string + SortDirection SortDirection + Marker string + Limit int64 +} + +// SortDirection of the sort, ascending or descending. +type SortDirection string + +const ( + // Desc specifies the sort direction to be descending. + Desc SortDirection = "desc" + // Asc specifies the sort direction to be ascending. + Asc SortDirection = "asc" +) + +// Images will issue a get request to OpenStack to retrieve the list of images. +func (imageService Service) Images() (image []Response, err error) { + return imageService.QueryImages(nil) +} + +// ImagesDetail will issue a get request to OpenStack to retrieve the list of images complete with +// additional details. +func (imageService Service) ImagesDetail() (image []DetailResponse, err error) { + return imageService.QueryImagesDetail(nil) +} + +// QueryImages will issue a get request with the specified ImageQueryParameters to retrieve the list of +// images. +func (imageService Service) QueryImages(queryParameters *QueryParameters) ([]Response, error) { + imagesContainer := imagesResponse{} + err := imageService.queryImages(false /*includeDetails*/, &imagesContainer, queryParameters) + if err != nil { + return nil, err + } + + return imagesContainer.Images, nil +} + +// QueryImagesDetail will issue a get request with the specified QueryParameters to retrieve the list of +// images with additional details. +func (imageService Service) QueryImagesDetail(queryParameters *QueryParameters) ([]DetailResponse, error) { + imagesDetailContainer := imagesDetailResponse{} + err := imageService.queryImages(true /*includeDetails*/, &imagesDetailContainer, queryParameters) + if err != nil { + return nil, err + } + + return imagesDetailContainer.Images, nil +} + +func (imageService Service) queryImages(includeDetails bool, imagesResponseContainer interface{}, queryParameters *QueryParameters) error { + urlPostFix := "/images" + if includeDetails { + urlPostFix = urlPostFix + "/detail" + } + + reqURL, err := buildQueryURL(imageService, queryParameters, urlPostFix) + if err != nil { + return err + } + + err = misc.GetJSON(reqURL.String(), imageService.TokenID, imageService.Client, &imagesResponseContainer) + if err != nil { + return err + } + + return nil +} + +func buildQueryURL(imageService Service, queryParameters *QueryParameters, imagePartialURL string) (*url.URL, error) { + reqURL, err := url.Parse(imageService.URL) + if err != nil { + return nil, err + } + + if queryParameters != nil { + values := url.Values{} + if queryParameters.Name != "" { + values.Set("name", queryParameters.Name) + } + if queryParameters.ContainerFormat != "" { + values.Set("container_format", queryParameters.ContainerFormat) + } + if queryParameters.DiskFormat != "" { + values.Set("disk_format", queryParameters.DiskFormat) + } + if queryParameters.Status != "" { + values.Set("status", queryParameters.Status) + } + if queryParameters.MinSize != 0 { + values.Set("size_min", fmt.Sprintf("%d", queryParameters.MinSize)) + } + if queryParameters.MaxSize != 0 { + values.Set("size_max", fmt.Sprintf("%d", queryParameters.MaxSize)) + } + if queryParameters.Limit != 0 { + values.Set("limit", fmt.Sprintf("%d", queryParameters.Limit)) + } + if queryParameters.Marker != "" { + values.Set("marker", queryParameters.Marker) + } + if queryParameters.SortKey != "" { + values.Set("sort_key", queryParameters.SortKey) + } + if queryParameters.SortDirection != "" { + values.Set("sort_dir", string(queryParameters.SortDirection)) + } + + if len(values) > 0 { + reqURL.RawQuery = values.Encode() + } + } + reqURL.Path += imagePartialURL + + return reqURL, nil +} + +type imagesDetailResponse struct { + Images []DetailResponse `json:"images"` +} + +type imagesResponse struct { + Images []Response `json:"images"` +} diff --git a/image/v1/image_test.go b/image/v1/image_test.go new file mode 100644 index 0000000..dc2e70e --- /dev/null +++ b/image/v1/image_test.go @@ -0,0 +1,261 @@ +// Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +// image.go +package image_test + +import ( + "errors" + "git.openstack.org/stackforge/golang-client.git/image/v1" + "git.openstack.org/stackforge/golang-client.git/misc" + "git.openstack.org/stackforge/golang-client.git/testUtil" + "net/http" + "strings" + "testing" +) + +var tokn = "eaaafd18-0fed-4b3a-81b4-663c99ec1cbb" + +func TestListImages(t *testing.T) { + anon := func(imageService *image.Service) { + images, err := imageService.Images() + if err != nil { + t.Error(err) + } + + if len(images) != 3 { + t.Error(errors.New("Incorrect number of images found")) + } + expectedImage := image.Response{ + Name: "Ubuntu Server 14.04.1 LTS (amd64 20140927) - Partner Image", + ContainerFormat: "bare", + DiskFormat: "qcow2", + CheckSum: "6798a7d67ff0b241b6fe165798914d86", + ID: "bec3cab5-4722-40b9-a78a-3489218e22fe", + Size: 255525376} + // Verify first one matches expected values + testUtil.Equals(t, expectedImage, images[0]) + } + + testImageServiceAction(t, "images", sampleImagesData, anon) +} + +func TestListImageDetails(t *testing.T) { + anon := func(imageService *image.Service) { + images, err := imageService.ImagesDetail() + if err != nil { + t.Error(err) + } + + if len(images) != 2 { + t.Error(errors.New("Incorrect number of images found")) + } + createdAt, _ := misc.NewDateTime(`"2014-09-29T14:44:31"`) + updatedAt, _ := misc.NewDateTime(`"2014-09-29T15:33:37"`) + owner := "10014302369510" + virtualSize := int64(2525125) + expectedImageDetail := image.DetailResponse{ + Status: "active", + Name: "Ubuntu Server 12.04.5 LTS (amd64 20140927) - Partner Image", + Deleted: false, + ContainerFormat: "bare", + CreatedAt: createdAt, + DiskFormat: "qcow2", + UpdatedAt: updatedAt, + MinDisk: 8, + Protected: false, + ID: "8ca068c5-6fde-4701-bab8-322b3e7c8d81", + MinRAM: 0, + CheckSum: "de1831ea85702599a27e7e63a9a444c3", + Owner: &owner, + IsPublic: true, + DeletedAt: nil, + Properties: map[string]string{ + "com.ubuntu.cloud__1__milestone": "release", + "com.hp__1__os_distro": "com.ubuntu", + "description": "Ubuntu Server 12.04.5 LTS (amd64 20140927) for HP Public Cloud. Ubuntu Server is the world's most popular Linux for cloud environments. Updates and patches for Ubuntu 12.04.5 LTS will be available until 2017-04-26. Ubuntu Server is the perfect platform for all workloads from web applications to NoSQL databases and Hadoop. More information regarding Ubuntu Cloud is available from http://www.ubuntu.com/cloud and instructions for using Juju to deploy workloads are available from http://juju.ubuntu.com EULA: http://www.ubuntu.com/about/about-ubuntu/licensing Privacy Policy: http://www.ubuntu.com/privacy-policy", + "com.ubuntu.cloud__1__suite": "precise", + "com.ubuntu.cloud__1__serial": "20140927", + "com.hp__1__bootable_volume": "True", + "com.hp__1__vendor": "Canonical", + "com.hp__1__image_lifecycle": "active", + "com.hp__1__image_type": "disk", + "os_version": "12.04", + "architecture": "x86_64", + "os_type": "linux-ext4", + "com.ubuntu.cloud__1__stream": "server", + "com.ubuntu.cloud__1__official": "True", + "com.ubuntu.cloud__1__published_at": "2014-09-29T15:33:36"}, + Size: 261423616, + VirtualSize: &virtualSize} + testUtil.Equals(t, expectedImageDetail, images[0]) + } + + testImageServiceAction(t, "images/detail", sampleImageDetailsData, anon) +} + +func TestNameFilterUrlProduced(t *testing.T) { + testImageQueryParameter(t, "images?name=CentOS+deprecated", + image.QueryParameters{Name: "CentOS deprecated"}) +} + +func TestStatusUrlProduced(t *testing.T) { + testImageQueryParameter(t, "images?status=active", + image.QueryParameters{Status: "active"}) +} + +func TestMinMaxSizeUrlProduced(t *testing.T) { + testImageQueryParameter(t, "images?size_max=5300014&size_min=100158", + image.QueryParameters{MinSize: 100158, MaxSize: 5300014}) +} + +func TestMarkerLimitUrlProduced(t *testing.T) { + testImageQueryParameter(t, "images?limit=20&marker=bec3cab5-4722-40b9-a78a-3489218e22fe", + image.QueryParameters{Marker: "bec3cab5-4722-40b9-a78a-3489218e22fe", Limit: 20}) +} + +func TestContainerFormatFilterUrlProduced(t *testing.T) { + testImageQueryParameter(t, "images?container_format=bare", + image.QueryParameters{ContainerFormat: "bare"}) +} + +func TestSortKeySortUrlProduced(t *testing.T) { + testImageQueryParameter(t, "images?sort_key=id", + image.QueryParameters{SortKey: "id"}) +} + +func TestSortDirSortUrlProduced(t *testing.T) { + testImageQueryParameter(t, "images?sort_dir=asc", + image.QueryParameters{SortDirection: image.Asc}) +} + +func testImageQueryParameter(t *testing.T, uriEndsWith string, queryParameters image.QueryParameters) { + anon := func(imageService *image.Service) { + _, _ = imageService.QueryImages(&queryParameters) + } + + testImageServiceAction(t, uriEndsWith, sampleImagesData, anon) +} + +func testImageServiceAction(t *testing.T, uriEndsWith string, testData string, imageServiceAction func(*image.Service)) { + anon := func(req *http.Request) { + reqURL := req.URL.String() + if !strings.HasSuffix(reqURL, uriEndsWith) { + t.Error(errors.New("Incorrect url created, expected:" + uriEndsWith + " at the end, actual url:" + reqURL)) + } + } + apiServer := testUtil.CreateGetJSONTestRequestServer(t, tokn, testData, anon) + defer apiServer.Close() + + imageService := image.Service{TokenID: tokn, Client: *http.DefaultClient, URL: apiServer.URL} + imageServiceAction(&imageService) +} + +var sampleImagesData = `{ + "images":[ + { + "name":"Ubuntu Server 14.04.1 LTS (amd64 20140927) - Partner Image", + "container_format":"bare", + "disk_format":"qcow2", + "checksum":"6798a7d67ff0b241b6fe165798914d86", + "id":"bec3cab5-4722-40b9-a78a-3489218e22fe", + "size":255525376 + }, + { + "name":"Ubuntu Server 12.04.5 LTS (amd64 20140927) - Partner Image", + "container_format":"bare", + "disk_format":"qcow2", + "checksum":"de1831ea85702599a27e7e63a9a444c3", + "id":"8ca068c5-6fde-4701-bab8-322b3e7c8d81", + "size":261423616 + }, + { + "name":"HP_LR-PC_Load_Generator_12-02_Windows-2008R2x64", + "container_format":"bare", + "disk_format":"qcow2", + "checksum":"052d70c2b4d4988a8816197381e9083a", + "id":"12b9c19b-8823-4f40-9531-0f05fb0933f2", + "size":14012055552 + } + ] +}` + +var sampleImageDetailsData = `{ + "images":[ + { + "status":"active", + "name":"Ubuntu Server 12.04.5 LTS (amd64 20140927) - Partner Image", + "deleted":false, + "container_format":"bare", + "created_at":"2014-09-29T14:44:31", + "disk_format":"qcow2", + "updated_at":"2014-09-29T15:33:37", + "min_disk":8, + "protected":false, + "id":"8ca068c5-6fde-4701-bab8-322b3e7c8d81", + "min_ram":0, + "checksum":"de1831ea85702599a27e7e63a9a444c3", + "owner":"10014302369510", + "is_public":true, + "deleted_at":null, + "properties":{ + "com.ubuntu.cloud__1__milestone":"release", + "com.hp__1__os_distro":"com.ubuntu", + "description":"Ubuntu Server 12.04.5 LTS (amd64 20140927) for HP Public Cloud. Ubuntu Server is the world's most popular Linux for cloud environments. Updates and patches for Ubuntu 12.04.5 LTS will be available until 2017-04-26. Ubuntu Server is the perfect platform for all workloads from web applications to NoSQL databases and Hadoop. More information regarding Ubuntu Cloud is available from http://www.ubuntu.com/cloud and instructions for using Juju to deploy workloads are available from http://juju.ubuntu.com EULA: http://www.ubuntu.com/about/about-ubuntu/licensing Privacy Policy: http://www.ubuntu.com/privacy-policy", + "com.ubuntu.cloud__1__suite":"precise", + "com.ubuntu.cloud__1__serial":"20140927", + "com.hp__1__bootable_volume":"True", + "com.hp__1__vendor":"Canonical", + "com.hp__1__image_lifecycle":"active", + "com.hp__1__image_type":"disk", + "os_version":"12.04", + "architecture":"x86_64", + "os_type":"linux-ext4", + "com.ubuntu.cloud__1__stream":"server", + "com.ubuntu.cloud__1__official":"True", + "com.ubuntu.cloud__1__published_at":"2014-09-29T15:33:36" + }, + "size":261423616, + "virtual_size":2525125 + }, + { + "status":"active", + "name":"Windows Server 2008 Enterprise SP2 x64 Volume License 20140415 (b)", + "deleted":false, + "container_format":"bare", + "created_at":"2014-04-25T19:53:24", + "disk_format":"qcow2", + "updated_at":"2014-04-25T19:57:11", + "min_disk":30, + "protected":true, + "id":"1294610e-fdc4-579b-829b-d0c9f5c0a612", + "min_ram":0, + "checksum":"37208aa6d49929f12132235c5f834f2d", + "owner":null, + "is_public":true, + "deleted_at":null, + "properties":{ + "hp_image_license":"1002", + "com.hp__1__os_distro":"com.microsoft.server", + "com.hp__1__image_lifecycle":"active", + "com.hp__1__image_type":"disk", + "architecture":"x86_64", + "com.hp__1__license_os":"1002", + "com.hp__1__bootable_volume":"true" + }, + "size":6932856832, + "virtual_size":null + } + ] +}` diff --git a/misc/rfc8601DateTime.go b/misc/rfc8601DateTime.go new file mode 100644 index 0000000..d10daac --- /dev/null +++ b/misc/rfc8601DateTime.go @@ -0,0 +1,57 @@ +// Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package misc + +import ( + "time" +) + +// RFC8601DateTime is a type for decoding and encoding json +// date times that follow RFC 8601 format. The type currently +// decodes and encodes with exactly precision to seconds. If more +// formats of RFC8601 need to be supported additional work +// will be needed. +type RFC8601DateTime struct { + time.Time +} + +// NewDateTime creates a new RFC8601DateTime taking a string as input. +// It must follow the "2006-01-02T15:04:05" pattern. +func NewDateTime(input string) (val RFC8601DateTime, err error) { + val = RFC8601DateTime{} + err = val.formatValue(input) + return val, err +} + +// UnmarshalJSON converts the bytes give to a RFC8601DateTime +// Errors will occur if the bytes when converted to a string +// don't match the format "2006-01-02T15:04:05". +func (r *RFC8601DateTime) UnmarshalJSON(data []byte) error { + return r.formatValue(string(data)) +} + +// MarshalJSON converts a RFC8601DateTime to a []byte. +func (r RFC8601DateTime) MarshalJSON() ([]byte, error) { + val := r.Time.Format(format) + return []byte(val), nil +} + +func (r *RFC8601DateTime) formatValue(input string) (err error) { + timeVal, err := time.Parse(format, input) + r.Time = timeVal + return +} + +const format = `"2006-01-02T15:04:05"` diff --git a/misc/rfc8601DateTime_test.go b/misc/rfc8601DateTime_test.go new file mode 100644 index 0000000..3384810 --- /dev/null +++ b/misc/rfc8601DateTime_test.go @@ -0,0 +1,58 @@ +// Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package misc_test + +import ( + "encoding/json" + "git.openstack.org/stackforge/golang-client.git/misc" + "git.openstack.org/stackforge/golang-client.git/testUtil" + "testing" + "time" +) + +var testValue = `{"created_at":"2014-09-29T14:44:31"}` +var testTime, _ = time.Parse(`"2006-01-02T15:04:05"`, `"2014-09-29T14:44:31"`) +var timeTestValue = timeTest{CreatedAt: misc.RFC8601DateTime{testTime}} + +func TestMarshalTimeTest(t *testing.T) { + bytes, _ := json.Marshal(timeTestValue) + + testUtil.Equals(t, testValue, string(bytes)) +} + +func TestUnmarshalValidTimeTest(t *testing.T) { + val := timeTest{} + err := json.Unmarshal([]byte(testValue), &val) + testUtil.IsNil(t, err) + testUtil.Equals(t, timeTestValue.CreatedAt.Time, val.CreatedAt.Time) +} + +func TestUnmarshalInvalidDataFormatTimeTest(t *testing.T) { + val := timeTest{} + err := json.Unmarshal([]byte("something other than date time"), &val) + testUtil.Assert(t, err != nil, "expected an error") +} + +// Added this test to ensure that its understood that +// only one specific format is supported at this time. +func TestUnmarshalInvalidDateTimeFormatTimeTest(t *testing.T) { + val := timeTest{} + err := json.Unmarshal([]byte("2014-09-29T14:44"), &val) + testUtil.Assert(t, err != nil, "expected an error") +} + +type timeTest struct { + CreatedAt misc.RFC8601DateTime `json:"created_at"` +} diff --git a/misc/util.go b/misc/util.go index fdf6f11..c2a2e61 100644 --- a/misc/util.go +++ b/misc/util.go @@ -16,11 +16,106 @@ package misc import ( "bytes" + "encoding/json" "errors" + "fmt" "io" "net/http" ) +var zeroByte = new([]byte) //pointer to empty []byte + +// PostJSON sends an Http Request with using the "POST" method and with +// a "Content-Type" header with application/json and X-Auth-Token" header +// set to the specified token value. The inputValue is encoded to json +// and sent in the body of the request. The response json body is +// decoded into the outputValue. If the response does sends an invalid +// or error status code then an error will be returned. If the Content-Type +// value of the response is not "application/json" an error is returned. +func PostJSON(url string, token string, client http.Client, inputValue interface{}, outputValue interface{}) (err error) { + body, err := json.Marshal(inputValue) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(body)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Auth-Token", token) + + resp, err := client.Do(req) + if err != nil { + return err + } + + if resp.StatusCode != 201 && resp.StatusCode != 202 { + err = errors.New("Error: status code != 201 or 202, actual status code '" + resp.Status + "'") + return + } + + contentTypeValue := resp.Header.Get("Content-Type") + if contentTypeValue != "application/json" { + err = errors.New("Error: Expected a json payload but instead recieved '" + contentTypeValue + "'") + return + } + + err = json.NewDecoder(resp.Body).Decode(&outputValue) + defer resp.Body.Close() + if err != nil { + return err + } + + return nil +} + +// Delete sends an Http Request with using the "DELETE" method and with +// an "X-Auth-Token" header set to the specified token value. The request +// is made by the specified client. +func Delete(url string, token string, client http.Client) (err error) { + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + return err + } + + req.Header.Set("X-Auth-Token", token) + + resp, err := client.Do(req) + if err != nil { + return err + } + + // Expecting a successful delete + if !(resp.StatusCode == 200 || resp.StatusCode == 202 || resp.StatusCode == 204) { + err = fmt.Errorf("Unexpected server response status code on Delete '%s'", resp.StatusCode) + return + } + + return nil +} + +//GetJSON sends an Http Request with using the "GET" method and with +//an "Accept" header set to "application/json" and the authenication token +//set to the specified token value. The request is made by the +//specified client. The val interface should be a pointer to the +//structure that the json response should be decoded into. +func GetJSON(url string, token string, client http.Client, val interface{}) (err error) { + req, err := createJSONGetRequest(url, token) + if err != nil { + return err + } + + err = executeRequestCheckStatusDecodeJSONResponse(client, req, val) + if err != nil { + return err + } + + return nil +} + //CallAPI sends an HTTP request using "method" to "url". //For uploading / sending file, caller needs to set the "content". Otherwise, //set it to zero length []byte. If Header fields need to be set, then set it in @@ -71,11 +166,11 @@ func (readCloser) Close() error { return nil } -//CheckStatusCode compares http response header StatusCode against expected -//statuses. Primary function is to ensure StatusCode is in the 20x (return nil). -//Ok: 200. Created: 201. Accepted: 202. No Content: 204. -//Otherwise return error message. -func CheckHttpResponseStatusCode(resp *http.Response) error { +// CheckHTTPResponseStatusCode compares http response header StatusCode against expected +// statuses. Primary function is to ensure StatusCode is in the 20x (return nil). +// Ok: 200. Created: 201. Accepted: 202. No Content: 204. +// Otherwise return error message. +func CheckHTTPResponseStatusCode(resp *http.Response) error { switch resp.StatusCode { case 200, 201, 202, 204: return nil @@ -108,3 +203,32 @@ func CheckHttpResponseStatusCode(resp *http.Response) error { } return errors.New("Error: unexpected response status code") } + +func createJSONGetRequest(url string, token string) (req *http.Request, err error) { + req, err = http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Auth-Token", token) + + return req, nil +} + +func executeRequestCheckStatusDecodeJSONResponse(client http.Client, req *http.Request, val interface{}) (err error) { + resp, err := client.Do(req) + if err != nil { + return err + } + + err = CheckHTTPResponseStatusCode(resp) + if err != nil { + return err + } + + err = json.NewDecoder(resp.Body).Decode(&val) + defer resp.Body.Close() + + return err +} diff --git a/misc/util_test.go b/misc/util_test.go index 5c256cc..7ab8520 100644 --- a/misc/util_test.go +++ b/misc/util_test.go @@ -18,6 +18,7 @@ import ( "bytes" "errors" "git.openstack.org/stackforge/golang-client.git/misc" + "git.openstack.org/stackforge/golang-client.git/testUtil" "io/ioutil" "net/http" "net/http/httptest" @@ -25,6 +26,29 @@ import ( "testing" ) +var token = "2350971-5716-8165" + +func TestDelete(t *testing.T) { + var apiServer = testUtil.CreateDeleteTestRequestServer(t, token, "/other") + defer apiServer.Close() + + err := misc.Delete(apiServer.URL+"/other", token, *http.DefaultClient) + testUtil.IsNil(t, err) +} + +func TestPostJsonWithValidResponse(t *testing.T) { + var apiServer = testUtil.CreatePostJSONTestRequestServer(t, token, `{"id":"id1","name":"Chris"}`, "", `{"id":"id1","name":"name"}`) + defer apiServer.Close() + actual := TestStruct{} + ti := TestStruct{ID: "id1", Name: "name"} + + err := misc.PostJSON(apiServer.URL, token, *http.DefaultClient, ti, &actual) + testUtil.IsNil(t, err) + expected := TestStruct{ID: "id1", Name: "Chris"} + + testUtil.Equals(t, expected, actual) +} + func TestCallAPI(t *testing.T) { tokn := "eaaafd18-0fed-4b3a-81b4-663c99ec1cbb" var apiServer = httptest.NewServer(http.HandlerFunc( @@ -108,3 +132,8 @@ func TestCallAPIPutContent(t *testing.T) { t.Error(err) } } + +type TestStruct struct { + ID string `json:"id"` + Name string `json:"name"` +} diff --git a/objectstorage/v1/objectstorage.go b/objectstorage/v1/objectstorage.go index e1dfbba..4e73876 100644 --- a/objectstorage/v1/objectstorage.go +++ b/objectstorage/v1/objectstorage.go @@ -69,8 +69,8 @@ func PutContainer(url, token string, s ...string) error { //obtained token. "Limit", "marker", "prefix", "path", "delim" corresponds //to the API's "limit", "marker", "prefix", "path", and "delimiter". func ListObjects(limit int64, - marker, prefix, path, delim, conUrl, token string) ([]byte, error) { - var query string = "?format=json" + marker, prefix, path, delim, conURL, token string) ([]byte, error) { + var query = "?format=json" if limit > 0 { query += "&limit=" + strconv.FormatInt(limit, 10) } @@ -86,12 +86,12 @@ func ListObjects(limit int64, if delim != "" { query += "&delimiter=" + url.QueryEscape(delim) } - resp, err := misc.CallAPI("GET", conUrl+query, zeroByte, + resp, err := misc.CallAPI("GET", conURL+query, zeroByte, "X-Auth-Token", token) if err != nil { return nil, err } - if err = misc.CheckHttpResponseStatusCode(resp); err != nil { + if err = misc.CheckHTTPResponseStatusCode(resp); err != nil { return nil, err } body, err := ioutil.ReadAll(resp.Body) @@ -112,20 +112,20 @@ func PutObject(fContent *[]byte, url, token string, s ...string) (err error) { if err != nil { return err } - return misc.CheckHttpResponseStatusCode(resp) + return misc.CheckHTTPResponseStatusCode(resp) } //CopyObject calls the OpenStack copy object API using previously obtained //token. Note from API doc: "The destination container must exist before //attempting the copy." -func CopyObject(srcUrl, destUrl, token string) (err error) { - resp, err := misc.CallAPI("COPY", srcUrl, zeroByte, +func CopyObject(srcURL, destURL, token string) (err error) { + resp, err := misc.CallAPI("COPY", srcURL, zeroByte, "X-Auth-Token", token, - "Destination", destUrl) + "Destination", destURL) if err != nil { return err } - return misc.CheckHttpResponseStatusCode(resp) + return misc.CheckHTTPResponseStatusCode(resp) } //DeleteObject calls the OpenStack delete object API using @@ -141,7 +141,7 @@ func DeleteObject(url, token string) (err error) { if err != nil { return err } - return misc.CheckHttpResponseStatusCode(resp) + return misc.CheckHTTPResponseStatusCode(resp) } //SetObjectMeta calls the OpenStack API to create/update meta data for @@ -153,7 +153,7 @@ func SetObjectMeta(url string, token string, s ...string) (err error) { if err != nil { return err } - return misc.CheckHttpResponseStatusCode(resp) + return misc.CheckHTTPResponseStatusCode(resp) } //GetObjectMeta calls the OpenStack retrieve object metadata API using @@ -163,7 +163,7 @@ func GetObjectMeta(url, token string) (http.Header, error) { if err != nil { return nil, err } - return resp.Header, misc.CheckHttpResponseStatusCode(resp) + return resp.Header, misc.CheckHTTPResponseStatusCode(resp) } //GetObject calls the OpenStack retrieve object API using previously @@ -178,7 +178,7 @@ func GetObject(url, token string) (http.Header, []byte, error) { if err != nil { return nil, nil, err } - if err = misc.CheckHttpResponseStatusCode(resp); err != nil { + if err = misc.CheckHTTPResponseStatusCode(resp); err != nil { return nil, nil, err } var body []byte diff --git a/objectstorage/v1/objectstorage_test.go b/objectstorage/v1/objectstorage_test.go index 02bfc51..dd34587 100644 --- a/objectstorage/v1/objectstorage_test.go +++ b/objectstorage/v1/objectstorage_test.go @@ -224,18 +224,18 @@ func TestPutObject(t *testing.T) { } func TestCopyObject(t *testing.T) { - destUrl := "/destContainer/dest/Obj" + destURL := "/destContainer/dest/Obj" var apiServer = httptest.NewServer(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { - if r.Method == "COPY" && r.Header.Get("Destination") == destUrl { + if r.Method == "COPY" && r.Header.Get("Destination") == destURL { w.WriteHeader(200) return } t.Error(errors.New( - "Failed: r.Method == COPY && r.Header.Get(Destination) == destUrl")) + "Failed: r.Method == COPY && r.Header.Get(Destination) == destURL")) })) defer apiServer.Close() - if err := objectstorage.CopyObject(apiServer.URL+objPrefix, destUrl, + if err := objectstorage.CopyObject(apiServer.URL+objPrefix, destURL, tokn); err != nil { t.Error(err) } diff --git a/testUtil/testUtil.go b/testUtil/testUtil.go new file mode 100644 index 0000000..b8c9c58 --- /dev/null +++ b/testUtil/testUtil.go @@ -0,0 +1,139 @@ +// Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +// Package testUtil has helpers to be used with unit tests +package testUtil + +import ( + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/http/httputil" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" +) + +// Equals fails the test if exp is not equal to act. +// Code was copied from https://github.com/benbjohnson/testing MIT license +func Equals(tb testing.TB, exp, act interface{}) { + if !reflect.DeepEqual(exp, act) { + _, file, line, _ := runtime.Caller(1) + fmt.Printf("\033[31m%s:%d:\n\n\texp: %#v\n\n\tgot: %#v\033[39m\n\n", filepath.Base(file), line, exp, act) + tb.FailNow() + } +} + +// Assert fails the test if the condition is false. +// Code was copied from https://github.com/benbjohnson/testing MIT license +func Assert(tb testing.TB, condition bool, msg string, v ...interface{}) { + if !condition { + _, file, line, _ := runtime.Caller(1) + fmt.Printf("\033[31m%s:%d: "+msg+"\033[39m\n\n", append([]interface{}{filepath.Base(file), line}, v...)...) + tb.FailNow() + } +} + +// IsNil ensures that the act interface is nil +// otherwise an error is raised. +func IsNil(tb testing.TB, act interface{}) { + if act != nil { + tb.Error("expected nil", act) + tb.FailNow() + } +} + +// CreateGetJSONTestRequestServer creates a httptest.Server that can be used to test GetJson requests. Just specify the token, +// json payload that is to be read by the response, and a verification func that can be used +// to do additional validation of the request that is built +func CreateGetJSONTestRequestServer(t *testing.T, expectedAuthTokenValue string, jsonResponsePayload string, verifyRequest func(*http.Request)) *httptest.Server { + return httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + headerValuesEqual(t, r, "X-Auth-Token", expectedAuthTokenValue) + headerValuesEqual(t, r, "Accept", "application/json") + verifyRequest(r) + if r.Method == "GET" { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(jsonResponsePayload)) + w.WriteHeader(http.StatusOK) + return + } + + t.Error(errors.New("Failed: r.Method == GET")) + })) +} + +// CreatePostJSONTestRequestServer creates a http.Server that can be used to test PostJson requests. Specify the token, +// response json payload and the url and request body that is expected. +func CreatePostJSONTestRequestServer(t *testing.T, expectedAuthTokenValue string, outputResponseJSONPayload string, expectedRequestURLEndsWith string, expectedRequestBody string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + headerValuesEqual(t, r, "X-Auth-Token", expectedAuthTokenValue) + headerValuesEqual(t, r, "Accept", "application/json") + headerValuesEqual(t, r, "Content-Type", "application/json") + reqURL := r.URL.String() + if !strings.HasSuffix(reqURL, expectedRequestURLEndsWith) { + t.Error(errors.New("Incorrect url created, expected:" + expectedRequestURLEndsWith + " at the end, actual url:" + reqURL)) + } + actualRequestBody := dumpRequestBody(r) + if actualRequestBody != expectedRequestBody { + t.Error(errors.New("Incorrect payload created, expected:'" + expectedRequestBody + "', actual '" + actualRequestBody + "'")) + } + if r.Method == "POST" { + w.Header().Set("Content-Type", "application/json") + // Status Code has to be written before writing the payload or + // else it defaults to 200 OK instead. + w.WriteHeader(http.StatusCreated) + w.Write([]byte(outputResponseJSONPayload)) + return + } + + t.Error(errors.New("Failed: r.Method == POST")) + })) +} + +// CreateDeleteTestRequestServer creates a http.Server that can be used to test Delete requests. +func CreateDeleteTestRequestServer(t *testing.T, expectedAuthTokenValue string, urlEndsWith string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + headerValuesEqual(t, r, "X-Auth-Token", expectedAuthTokenValue) + reqURL := r.URL.String() + if !strings.HasSuffix(reqURL, urlEndsWith) { + t.Error(errors.New("Incorrect url created, expected '" + urlEndsWith + "' at the end, actual url:" + reqURL)) + } + if r.Method == "DELETE" { + w.WriteHeader(204) + return + } + + t.Error(errors.New("Failed: r.Method == DELETE")) + })) +} + +func dumpRequestBody(request *http.Request) (body string) { + requestWithBody, _ := httputil.DumpRequest(request, true) + requestWithoutBody, _ := httputil.DumpRequest(request, false) + body = strings.Replace(string(requestWithBody), string(requestWithoutBody), "", 1) + return +} + +func headerValuesEqual(t *testing.T, req *http.Request, name string, expectedValue string) { + actualValue := req.Header.Get(name) + if actualValue != expectedValue { + t.Error(fmt.Errorf("Expected Header {Name:'%s', Value:'%s', actual value '%s'", name, expectedValue, actualValue)) + } +}