Skip to content

Commit 55a9d32

Browse files
authored
Add support for locations management (#1)
Even it's not covered by [the official documentation](https://backstage.io/docs/features/software-catalog/software-catalog-api#locations), it's possible to LIST/GET/CREATE/DELETE locations via `/catalog/location` API endpoint as per [this code](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend/src/service/createRouter.ts#L199-L239).
2 parents 78085b0 + ff4415f commit 55a9d32

File tree

12 files changed

+385
-3
lines changed

12 files changed

+385
-3
lines changed

.gitattributes

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
* text=auto eol=lf
22

3-
go.sum linguist-generated=true
3+
go.sum linguist-generated=true
4+
backstage/testdata/ linguist-generated=true

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,9 @@ jobs:
7575
run: |
7676
sleep 30
7777
go run ./examples/entities/main.go
78+
- name: Run locations example
79+
env:
80+
BACKSTAGE_BASE_URL: http://localhost:${{ job.services.backstage.ports[7000] }}/api
81+
run: |
82+
sleep 30
83+
go run ./examples/locations/main.go

backstage/entity.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package backstage
33
import (
44
"bytes"
55
"context"
6+
"errors"
67
"fmt"
78
"net/http"
89
"net/url"
@@ -214,6 +215,10 @@ func (s *entityService) Get(ctx context.Context, uid string) (*Entity, *http.Res
214215

215216
// Delete deletes an orphaned entity by its UID.
216217
func (s *entityService) Delete(ctx context.Context, uid string) (*http.Response, error) {
218+
if uid == "" {
219+
return nil, errors.New("uid cannot be empty")
220+
}
221+
217222
path, _ := url.JoinPath(s.apiPath, "/by-uid/", uid)
218223
req, _ := s.client.newRequest(http.MethodDelete, path, nil)
219224

backstage/entity_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ func TestEntityServiceDelete(t *testing.T) {
207207
c, _ := NewClient(baseURL.String(), "", nil)
208208
s := newEntityService(newCatalogService(c))
209209

210-
resp, err := s.Delete(context.Background(), "uid")
210+
resp, err := s.Delete(context.Background(), uid)
211211
assert.NoError(t, err, "Delete should not return an error")
212212
assert.NotEmpty(t, resp, "Response should not be empty")
213213
assert.EqualValues(t, http.StatusNoContent, resp.StatusCode, "Response status code should match the one from the server")

backstage/kind_location.go

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ package backstage
22

33
import (
44
"context"
5+
"errors"
6+
"fmt"
57
"net/http"
8+
"net/url"
69
)
710

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

37-
// Presence describes whether the presence of the location target is required and it should be considered an error if it
40+
// Presence describes whether the presence of the location target is required, and it should be considered an error if it
3841
// can not be found.
3942
Presence string `json:"presence,omitempty"`
4043
} `json:"spec"`
4144
}
4245

46+
// LocationCreateResponse defines POST response from location endpoints.
47+
type LocationCreateResponse struct {
48+
// Exists is only set in dryRun mode.
49+
Exists bool `json:"exists,omitempty"`
50+
// Location contains details of created location.
51+
Location *LocationResponse `json:"location,omitempty"`
52+
// Entities is a list of entities that were discovered from the created location.
53+
Entities []Entity `json:"entities"`
54+
}
55+
56+
// LocationResponse defines GET response to get single location from location endpoints.
57+
type LocationResponse struct {
58+
// ID of the location.
59+
ID string `json:"id"`
60+
// Type of the location.
61+
Type string `json:"type"`
62+
// Target of the location.
63+
Target string `json:"target"`
64+
}
65+
66+
// LocationListResponse defines GET response to get all locations from location endpoints.
67+
type LocationListResponse struct {
68+
Data *LocationResponse `json:"data"`
69+
}
70+
4371
// locationService handles communication with the location related methods of the Backstage Catalog API.
4472
type locationService typedEntityService[LocationEntityV1alpha1]
4573

@@ -56,3 +84,59 @@ func (s *locationService) Get(ctx context.Context, n string, ns string) (*Locati
5684
cs := (typedEntityService[LocationEntityV1alpha1])(*s)
5785
return cs.get(ctx, KindLocation, n, ns)
5886
}
87+
88+
// Create creates a new location.
89+
func (s *locationService) Create(ctx context.Context, target string, dryRun bool) (*LocationCreateResponse, *http.Response, error) {
90+
if target == "" {
91+
return nil, nil, errors.New("target cannot be empty")
92+
}
93+
94+
path, _ := url.JoinPath(s.apiPath, "../locations")
95+
req, _ := s.client.newRequest(http.MethodPost, fmt.Sprintf("%s?dryRun=%t", path, dryRun), struct {
96+
Target string `json:"target"`
97+
Type string `json:"type"`
98+
}{
99+
Target: target,
100+
Type: "url",
101+
})
102+
103+
var entity *LocationCreateResponse
104+
resp, err := s.client.do(ctx, req, &entity)
105+
106+
return entity, resp, err
107+
108+
}
109+
110+
// List returns all locations.
111+
func (s *locationService) List(ctx context.Context) ([]LocationListResponse, *http.Response, error) {
112+
path, _ := url.JoinPath(s.apiPath, "../locations")
113+
req, _ := s.client.newRequest(http.MethodGet, path, nil)
114+
115+
var entities []LocationListResponse
116+
resp, err := s.client.do(ctx, req, &entities)
117+
118+
return entities, resp, err
119+
}
120+
121+
// GetByID returns a location identified by its ID.
122+
func (s *locationService) GetByID(ctx context.Context, id string) (*LocationResponse, *http.Response, error) {
123+
path, _ := url.JoinPath(s.apiPath, "../locations", id)
124+
req, _ := s.client.newRequest(http.MethodGet, path, nil)
125+
126+
var entity *LocationResponse
127+
resp, err := s.client.do(ctx, req, &entity)
128+
129+
return entity, resp, err
130+
}
131+
132+
// DeleteByID deletes a location identified by its ID.
133+
func (s *locationService) DeleteByID(ctx context.Context, id string) (*http.Response, error) {
134+
if id == "" {
135+
return nil, errors.New("id cannot be empty")
136+
}
137+
138+
path, _ := url.JoinPath(s.apiPath, "../locations", id)
139+
req, _ := s.client.newRequest(http.MethodDelete, path, nil)
140+
141+
return s.client.do(ctx, req, nil)
142+
}

backstage/kind_location_test.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"net/http"
78
"net/url"
89
"os"
910
"testing"
@@ -43,3 +44,169 @@ func TestKindLocationGet(t *testing.T) {
4344
assert.NotEmpty(t, resp, "Response should not be empty")
4445
assert.EqualValues(t, &expected, actual, "Response body should match the one from the server")
4546
}
47+
48+
// TestKindLocationCreateByID tests functionality of creating a new location.
49+
func TestKindLocationCreateByID(t *testing.T) {
50+
const dataFile = "testdata/location_create.json"
51+
const target = "https://github.com/tdabasinskas/go/backstage/test"
52+
53+
expected := LocationCreateResponse{}
54+
expectedData, _ := os.ReadFile(dataFile)
55+
err := json.Unmarshal(expectedData, &expected)
56+
57+
assert.FileExists(t, dataFile, "Test data file should exist")
58+
assert.NoError(t, err, "Unmarshal should not return an error")
59+
60+
baseURL, _ := url.Parse("https://foo:1234/api")
61+
defer gock.Off()
62+
gock.New(baseURL.String()).
63+
MatchHeader("Accept", "application/json").
64+
Post("/catalog/locations").
65+
MatchParam("dryRun", "false").
66+
Reply(200).
67+
JSON(&LocationCreateResponse{
68+
Location: &LocationResponse{
69+
ID: "830d2354-8bbb-42d1-a751-2959f6da5416",
70+
Type: "url",
71+
Target: target,
72+
},
73+
Entities: []Entity{},
74+
})
75+
76+
c, _ := NewClient(baseURL.String(), "", nil)
77+
s := newLocationService(&entityService{
78+
client: c,
79+
apiPath: "/catalog/entities",
80+
})
81+
82+
actual, resp, err := s.Create(context.Background(), target, false)
83+
assert.NoError(t, err, "Get should not return an error")
84+
assert.NotEmpty(t, resp, "Response should not be empty")
85+
assert.EqualValues(t, &expected, actual, "Response body should match the one from the server")
86+
}
87+
88+
// TestKindLocationCreateByID_DryRun tests functionality of creating a new location.
89+
func TestKindLocationCreateByID_DryRun(t *testing.T) {
90+
const dataFile = "testdata/location_create_dryrun.json"
91+
const target = "https://github.com/tdabasinskas/go/backstage/test"
92+
93+
expected := LocationCreateResponse{}
94+
expectedData, _ := os.ReadFile(dataFile)
95+
err := json.Unmarshal(expectedData, &expected)
96+
97+
assert.FileExists(t, dataFile, "Test data file should exist")
98+
assert.NoError(t, err, "Unmarshal should not return an error")
99+
100+
baseURL, _ := url.Parse("https://foo:1234/api")
101+
defer gock.Off()
102+
gock.New(baseURL.String()).
103+
MatchHeader("Accept", "application/json").
104+
Post("/catalog/locations").
105+
MatchParam("dryRun", "true").
106+
Reply(200).
107+
JSON(&LocationCreateResponse{
108+
Location: &LocationResponse{
109+
ID: "830d2354-8bbb-42d1-a751-2959f6da5416",
110+
Type: "url",
111+
Target: target,
112+
},
113+
Entities: []Entity{},
114+
})
115+
116+
c, _ := NewClient(baseURL.String(), "", nil)
117+
s := newLocationService(&entityService{
118+
client: c,
119+
apiPath: "/catalog/entities",
120+
})
121+
122+
actual, resp, err := s.Create(context.Background(), target, true)
123+
assert.NoError(t, err, "Get should not return an error")
124+
assert.NotEmpty(t, resp, "Response should not be empty")
125+
assert.EqualValues(t, &expected, actual, "Response body should match the one from the server")
126+
}
127+
128+
// TestKindLocationGetByID tests functionality of getting a location by its ID.
129+
func TestKindLocationGetByID(t *testing.T) {
130+
const dataFile = "testdata/location_by_id.json"
131+
const id = "830d2354-8bbb-42d1-a751-2959f6da5416"
132+
133+
expected := LocationResponse{}
134+
expectedData, _ := os.ReadFile(dataFile)
135+
err := json.Unmarshal(expectedData, &expected)
136+
137+
assert.FileExists(t, dataFile, "Test data file should exist")
138+
assert.NoError(t, err, "Unmarshal should not return an error")
139+
140+
baseURL, _ := url.Parse("https://foo:1234/api")
141+
defer gock.Off()
142+
gock.New(baseURL.String()).
143+
MatchHeader("Accept", "application/json").
144+
Get(fmt.Sprintf("/catalog/locations/%s", id)).
145+
Reply(200).
146+
File(dataFile)
147+
148+
c, _ := NewClient(baseURL.String(), "", nil)
149+
s := newLocationService(&entityService{
150+
client: c,
151+
apiPath: "/catalog/entities",
152+
})
153+
154+
actual, resp, err := s.GetByID(context.Background(), id)
155+
assert.NoError(t, err, "Get should not return an error")
156+
assert.NotEmpty(t, resp, "Response should not be empty")
157+
assert.EqualValues(t, &expected, actual, "Response body should match the one from the server")
158+
}
159+
160+
// TestKindLocationList tests functionality of getting all locations.
161+
func TestKindLocationList(t *testing.T) {
162+
const dataFile = "testdata/locations.json"
163+
164+
var expected []LocationListResponse
165+
expectedData, _ := os.ReadFile(dataFile)
166+
err := json.Unmarshal(expectedData, &expected)
167+
168+
assert.FileExists(t, dataFile, "Test data file should exist")
169+
assert.NoError(t, err, "Unmarshal should not return an error")
170+
171+
baseURL, _ := url.Parse("https://foo:1234/api")
172+
defer gock.Off()
173+
gock.New(baseURL.String()).
174+
MatchHeader("Accept", "application/json").
175+
Get("/catalog/locations").
176+
Reply(200).
177+
File(dataFile)
178+
179+
c, _ := NewClient(baseURL.String(), "", nil)
180+
s := newLocationService(&entityService{
181+
client: c,
182+
apiPath: "/catalog/entities",
183+
})
184+
185+
actual, resp, err := s.List(context.Background())
186+
assert.NoError(t, err, "Get should not return an error")
187+
assert.NotEmpty(t, resp, "Response should not be empty")
188+
assert.EqualValues(t, expected, actual, "Response body should match the one from the server")
189+
}
190+
191+
// TestEntityServiceDelete tests the deletion of an entity.
192+
func TestKindLocationDeleteByID(t *testing.T) {
193+
const id = "id"
194+
195+
baseURL, _ := url.Parse("https://foo:1234/api")
196+
defer gock.Off()
197+
gock.New(baseURL.String()).
198+
MatchHeader("Accept", "application/json").
199+
Delete(fmt.Sprintf("/catalog/locations/%s", id)).
200+
Reply(http.StatusNoContent)
201+
202+
c, _ := NewClient(baseURL.String(), "", nil)
203+
s := newLocationService(&entityService{
204+
client: c,
205+
apiPath: "/catalog/entities",
206+
})
207+
208+
resp, err := s.DeleteByID(context.Background(), id)
209+
assert.NoError(t, err, "Delete should not return an error")
210+
assert.NotEmpty(t, resp, "Response should not be empty")
211+
assert.EqualValues(t, http.StatusNoContent, resp.StatusCode, "Response status code should match the one from the server")
212+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"id": "830d2354-8bbb-42d1-a751-2959f6da5416",
3+
"type": "url",
4+
"target": "https://github.com/tdabasinskas/go/backstage/test"
5+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"location": {
3+
"id": "830d2354-8bbb-42d1-a751-2959f6da5416",
4+
"type": "url",
5+
"target": "https://github.com/tdabasinskas/go/backstage/test"
6+
},
7+
"entities": []
8+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"exists": false,
3+
"location": {
4+
"id": "830d2354-8bbb-42d1-a751-2959f6da5416",
5+
"type": "url",
6+
"target": "https://github.com/tdabasinskas/go/backstage/test"
7+
},
8+
"entities": []
9+
}

backstage/testdata/locations.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[
2+
{
3+
"data": {
4+
"id": "650a7ec5-9813-4f42-ae8a-cde84653daf4",
5+
"target": "https://github.com/tdabasinskas/test",
6+
"type": "url"
7+
}
8+
},
9+
{
10+
"data": {
11+
"id": "ab31518c-91a4-49b8-a65a-3a12c7f92055",
12+
"target": "https://github.com/tdabasinskas/example",
13+
"type": "url"
14+
}
15+
}
16+
]

0 commit comments

Comments
 (0)