Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for locations management #1

Merged
merged 8 commits into from
Jan 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
* text=auto eol=lf

go.sum linguist-generated=true
go.sum linguist-generated=true
backstage/testdata/ linguist-generated=true
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,9 @@ jobs:
run: |
sleep 30
go run ./examples/entities/main.go
- name: Run locations example
env:
BACKSTAGE_BASE_URL: http://localhost:${{ job.services.backstage.ports[7000] }}/api
run: |
sleep 30
go run ./examples/locations/main.go
5 changes: 5 additions & 0 deletions backstage/entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package backstage
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"net/url"
Expand Down Expand Up @@ -214,6 +215,10 @@ func (s *entityService) Get(ctx context.Context, uid string) (*Entity, *http.Res

// Delete deletes an orphaned entity by its UID.
func (s *entityService) Delete(ctx context.Context, uid string) (*http.Response, error) {
if uid == "" {
return nil, errors.New("uid cannot be empty")
}

path, _ := url.JoinPath(s.apiPath, "/by-uid/", uid)
req, _ := s.client.newRequest(http.MethodDelete, path, nil)

Expand Down
2 changes: 1 addition & 1 deletion backstage/entity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ func TestEntityServiceDelete(t *testing.T) {
c, _ := NewClient(baseURL.String(), "", nil)
s := newEntityService(newCatalogService(c))

resp, err := s.Delete(context.Background(), "uid")
resp, err := s.Delete(context.Background(), uid)
assert.NoError(t, err, "Delete should not return an error")
assert.NotEmpty(t, resp, "Response should not be empty")
assert.EqualValues(t, http.StatusNoContent, resp.StatusCode, "Response status code should match the one from the server")
Expand Down
86 changes: 85 additions & 1 deletion backstage/kind_location.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package backstage

import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
)

// KindLocation defines name for location kind.
Expand Down Expand Up @@ -34,12 +37,37 @@ type LocationEntityV1alpha1 struct {
// entity itself.
Targets []string `json:"targets,omitempty"`

// Presence describes whether the presence of the location target is required and it should be considered an error if it
// Presence describes whether the presence of the location target is required, and it should be considered an error if it
// can not be found.
Presence string `json:"presence,omitempty"`
} `json:"spec"`
}

// LocationCreateResponse defines POST response from location endpoints.
type LocationCreateResponse struct {
// Exists is only set in dryRun mode.
Exists bool `json:"exists,omitempty"`
// Location contains details of created location.
Location *LocationResponse `json:"location,omitempty"`
// Entities is a list of entities that were discovered from the created location.
Entities []Entity `json:"entities"`
}

// LocationResponse defines GET response to get single location from location endpoints.
type LocationResponse struct {
// ID of the location.
ID string `json:"id"`
// Type of the location.
Type string `json:"type"`
// Target of the location.
Target string `json:"target"`
}

// LocationListResponse defines GET response to get all locations from location endpoints.
type LocationListResponse struct {
Data *LocationResponse `json:"data"`
}

// locationService handles communication with the location related methods of the Backstage Catalog API.
type locationService typedEntityService[LocationEntityV1alpha1]

Expand All @@ -56,3 +84,59 @@ func (s *locationService) Get(ctx context.Context, n string, ns string) (*Locati
cs := (typedEntityService[LocationEntityV1alpha1])(*s)
return cs.get(ctx, KindLocation, n, ns)
}

// Create creates a new location.
func (s *locationService) Create(ctx context.Context, target string, dryRun bool) (*LocationCreateResponse, *http.Response, error) {
if target == "" {
return nil, nil, errors.New("target cannot be empty")
}

path, _ := url.JoinPath(s.apiPath, "../locations")
req, _ := s.client.newRequest(http.MethodPost, fmt.Sprintf("%s?dryRun=%t", path, dryRun), struct {
Target string `json:"target"`
Type string `json:"type"`
}{
Target: target,
Type: "url",
})

var entity *LocationCreateResponse
resp, err := s.client.do(ctx, req, &entity)

return entity, resp, err

}

// List returns all locations.
func (s *locationService) List(ctx context.Context) ([]LocationListResponse, *http.Response, error) {
path, _ := url.JoinPath(s.apiPath, "../locations")
req, _ := s.client.newRequest(http.MethodGet, path, nil)

var entities []LocationListResponse
resp, err := s.client.do(ctx, req, &entities)

return entities, resp, err
}

// GetByID returns a location identified by its ID.
func (s *locationService) GetByID(ctx context.Context, id string) (*LocationResponse, *http.Response, error) {
path, _ := url.JoinPath(s.apiPath, "../locations", id)
req, _ := s.client.newRequest(http.MethodGet, path, nil)

var entity *LocationResponse
resp, err := s.client.do(ctx, req, &entity)

return entity, resp, err
}

// DeleteByID deletes a location identified by its ID.
func (s *locationService) DeleteByID(ctx context.Context, id string) (*http.Response, error) {
if id == "" {
return nil, errors.New("id cannot be empty")
}

path, _ := url.JoinPath(s.apiPath, "../locations", id)
req, _ := s.client.newRequest(http.MethodDelete, path, nil)

return s.client.do(ctx, req, nil)
}
167 changes: 167 additions & 0 deletions backstage/kind_location_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"testing"
Expand Down Expand Up @@ -43,3 +44,169 @@ func TestKindLocationGet(t *testing.T) {
assert.NotEmpty(t, resp, "Response should not be empty")
assert.EqualValues(t, &expected, actual, "Response body should match the one from the server")
}

// TestKindLocationCreateByID tests functionality of creating a new location.
func TestKindLocationCreateByID(t *testing.T) {
const dataFile = "testdata/location_create.json"
const target = "https://github.com/tdabasinskas/go/backstage/test"

expected := LocationCreateResponse{}
expectedData, _ := os.ReadFile(dataFile)
err := json.Unmarshal(expectedData, &expected)

assert.FileExists(t, dataFile, "Test data file should exist")
assert.NoError(t, err, "Unmarshal should not return an error")

baseURL, _ := url.Parse("https://foo:1234/api")
defer gock.Off()
gock.New(baseURL.String()).
MatchHeader("Accept", "application/json").
Post("/catalog/locations").
MatchParam("dryRun", "false").
Reply(200).
JSON(&LocationCreateResponse{
Location: &LocationResponse{
ID: "830d2354-8bbb-42d1-a751-2959f6da5416",
Type: "url",
Target: target,
},
Entities: []Entity{},
})

c, _ := NewClient(baseURL.String(), "", nil)
s := newLocationService(&entityService{
client: c,
apiPath: "/catalog/entities",
})

actual, resp, err := s.Create(context.Background(), target, false)
assert.NoError(t, err, "Get should not return an error")
assert.NotEmpty(t, resp, "Response should not be empty")
assert.EqualValues(t, &expected, actual, "Response body should match the one from the server")
}

// TestKindLocationCreateByID_DryRun tests functionality of creating a new location.
func TestKindLocationCreateByID_DryRun(t *testing.T) {
const dataFile = "testdata/location_create_dryrun.json"
const target = "https://github.com/tdabasinskas/go/backstage/test"

expected := LocationCreateResponse{}
expectedData, _ := os.ReadFile(dataFile)
err := json.Unmarshal(expectedData, &expected)

assert.FileExists(t, dataFile, "Test data file should exist")
assert.NoError(t, err, "Unmarshal should not return an error")

baseURL, _ := url.Parse("https://foo:1234/api")
defer gock.Off()
gock.New(baseURL.String()).
MatchHeader("Accept", "application/json").
Post("/catalog/locations").
MatchParam("dryRun", "true").
Reply(200).
JSON(&LocationCreateResponse{
Location: &LocationResponse{
ID: "830d2354-8bbb-42d1-a751-2959f6da5416",
Type: "url",
Target: target,
},
Entities: []Entity{},
})

c, _ := NewClient(baseURL.String(), "", nil)
s := newLocationService(&entityService{
client: c,
apiPath: "/catalog/entities",
})

actual, resp, err := s.Create(context.Background(), target, true)
assert.NoError(t, err, "Get should not return an error")
assert.NotEmpty(t, resp, "Response should not be empty")
assert.EqualValues(t, &expected, actual, "Response body should match the one from the server")
}

// TestKindLocationGetByID tests functionality of getting a location by its ID.
func TestKindLocationGetByID(t *testing.T) {
const dataFile = "testdata/location_by_id.json"
const id = "830d2354-8bbb-42d1-a751-2959f6da5416"

expected := LocationResponse{}
expectedData, _ := os.ReadFile(dataFile)
err := json.Unmarshal(expectedData, &expected)

assert.FileExists(t, dataFile, "Test data file should exist")
assert.NoError(t, err, "Unmarshal should not return an error")

baseURL, _ := url.Parse("https://foo:1234/api")
defer gock.Off()
gock.New(baseURL.String()).
MatchHeader("Accept", "application/json").
Get(fmt.Sprintf("/catalog/locations/%s", id)).
Reply(200).
File(dataFile)

c, _ := NewClient(baseURL.String(), "", nil)
s := newLocationService(&entityService{
client: c,
apiPath: "/catalog/entities",
})

actual, resp, err := s.GetByID(context.Background(), id)
assert.NoError(t, err, "Get should not return an error")
assert.NotEmpty(t, resp, "Response should not be empty")
assert.EqualValues(t, &expected, actual, "Response body should match the one from the server")
}

// TestKindLocationList tests functionality of getting all locations.
func TestKindLocationList(t *testing.T) {
const dataFile = "testdata/locations.json"

var expected []LocationListResponse
expectedData, _ := os.ReadFile(dataFile)
err := json.Unmarshal(expectedData, &expected)

assert.FileExists(t, dataFile, "Test data file should exist")
assert.NoError(t, err, "Unmarshal should not return an error")

baseURL, _ := url.Parse("https://foo:1234/api")
defer gock.Off()
gock.New(baseURL.String()).
MatchHeader("Accept", "application/json").
Get("/catalog/locations").
Reply(200).
File(dataFile)

c, _ := NewClient(baseURL.String(), "", nil)
s := newLocationService(&entityService{
client: c,
apiPath: "/catalog/entities",
})

actual, resp, err := s.List(context.Background())
assert.NoError(t, err, "Get should not return an error")
assert.NotEmpty(t, resp, "Response should not be empty")
assert.EqualValues(t, expected, actual, "Response body should match the one from the server")
}

// TestEntityServiceDelete tests the deletion of an entity.
func TestKindLocationDeleteByID(t *testing.T) {
const id = "id"

baseURL, _ := url.Parse("https://foo:1234/api")
defer gock.Off()
gock.New(baseURL.String()).
MatchHeader("Accept", "application/json").
Delete(fmt.Sprintf("/catalog/locations/%s", id)).
Reply(http.StatusNoContent)

c, _ := NewClient(baseURL.String(), "", nil)
s := newLocationService(&entityService{
client: c,
apiPath: "/catalog/entities",
})

resp, err := s.DeleteByID(context.Background(), id)
assert.NoError(t, err, "Delete should not return an error")
assert.NotEmpty(t, resp, "Response should not be empty")
assert.EqualValues(t, http.StatusNoContent, resp.StatusCode, "Response status code should match the one from the server")
}
14 changes: 14 additions & 0 deletions backstage/testdata/catalog-info.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage
description: |
Backstage is an open-source developer portal that puts the developer experience first.
annotations:
github.com/project-slug: backstage/backstage
backstage.io/techdocs-ref: dir:.
lighthouse.com/website-url: https://backstage.io
spec:
type: library
owner: CNCF
lifecycle: experimental
5 changes: 5 additions & 0 deletions backstage/testdata/location_by_id.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"id": "830d2354-8bbb-42d1-a751-2959f6da5416",
"type": "url",
"target": "https://github.com/tdabasinskas/go/backstage/test"
}
8 changes: 8 additions & 0 deletions backstage/testdata/location_create.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"location": {
"id": "830d2354-8bbb-42d1-a751-2959f6da5416",
"type": "url",
"target": "https://github.com/tdabasinskas/go/backstage/test"
},
"entities": []
}
9 changes: 9 additions & 0 deletions backstage/testdata/location_create_dryrun.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"exists": false,
"location": {
"id": "830d2354-8bbb-42d1-a751-2959f6da5416",
"type": "url",
"target": "https://github.com/tdabasinskas/go/backstage/test"
},
"entities": []
}
16 changes: 16 additions & 0 deletions backstage/testdata/locations.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[
{
"data": {
"id": "650a7ec5-9813-4f42-ae8a-cde84653daf4",
"target": "https://github.com/tdabasinskas/test",
"type": "url"
}
},
{
"data": {
"id": "ab31518c-91a4-49b8-a65a-3a12c7f92055",
"target": "https://github.com/tdabasinskas/example",
"type": "url"
}
}
]
7 changes: 7 additions & 0 deletions examples/locations/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module examplelocations

go 1.19

replace github.com/tdabasinskas/go-backstage => ../..

require github.com/tdabasinskas/go-backstage v0.0.0-20230117064629-2a4ac9398d92
Loading