Skip to content

Commit 761af95

Browse files
committed
/v2/info API
1 parent e619fc1 commit 761af95

25 files changed

+1307
-44
lines changed

docs/users/api-v2-spec-rates.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<!--
2+
SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company
3+
4+
SPDX-License-Identifier: Apache-2.0
5+
-->
6+
7+
# Limes Rate API v2 specification
8+
9+
The URLs indicated in the headers of each section are relative to the endpoint URL advertised in the Keystone
10+
catalog under the service type `limes-rates`.
11+
12+
Where permission requirements are indicated, they refer to the default policy. Limes operators can configure their
13+
policy differently, so that certain requests may require other roles or token scopes.
14+
15+
Use the table of contents icon
16+
<img src="https://github.com/github/docs/raw/main/contributing/images/table-of-contents.png" width="25" height="25" />
17+
near the top left corner of this document to jump to a specific section on this page.
18+
19+
20+
21+
22+
TODO
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<!--
2+
SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company
3+
4+
SPDX-License-Identifier: Apache-2.0
5+
-->
6+
7+
# Limes Resource API v2 specification
8+
9+
The URLs indicated in the headers of each section are relative to the endpoint URL advertised in the Keystone
10+
catalog under the service type `resources`.
11+
12+
Where permission requirements are indicated, they refer to the default policy. Limes operators can configure their
13+
policy differently, so that certain requests may require other roles or token scopes.
14+
15+
Use the table of contents icon
16+
<img src="https://github.com/github/docs/raw/main/contributing/images/table-of-contents.png" width="25" height="25" />
17+
near the top left corner of this document to jump to a specific section on this page.
18+
19+
TODO

docs/users/api-v2-specification.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<!--
2+
SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company
3+
4+
SPDX-License-Identifier: Apache-2.0
5+
-->
6+
7+
# V2 API Specification
8+
9+
In an attempt to streamline interaction of consumers with the Limes API, we are
10+
working on a re-designed version 2 of the API. It still differentiates the two
11+
types of entities, **resources** and **rates**
12+
13+
The Limes API v2 has therefore been split into two separately documented sub-specifications:
14+
15+
- [Resource API spec](./api-spec-resources.md)
16+
- [Rate API spec](./api-spec-rates.md)

internal/api/api_test.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ func setupTest(t *testing.T) test.Setup {
128128
test.WithPersistedServiceInfo("shared", srvInfoShared),
129129
test.WithPersistedServiceInfo("unshared", srvInfoUnshared),
130130
test.WithInitialDiscovery,
131-
test.WithEmptyRecordsAsNeeded,
131+
test.WithEmptyResourceRecordsAsNeeded,
132132
)
133133

134134
// shorthands
@@ -332,7 +332,7 @@ func Test_ScrapeErrorOperations(t *testing.T) {
332332
test.WithPersistedServiceInfo("shared", test.DefaultLiquidServiceInfo()),
333333
test.WithPersistedServiceInfo("unshared", test.DefaultLiquidServiceInfo()),
334334
test.WithInitialDiscovery,
335-
test.WithEmptyRecordsAsNeeded,
335+
test.WithEmptyResourceRecordsAsNeeded,
336336
)
337337

338338
s.MustDBExec(`UPDATE project_services SET scraped_at = $1, checked_at = $1`, time.Unix(11, 0))
@@ -919,7 +919,7 @@ func Test_EmptyProjectList(t *testing.T) {
919919
}`),
920920
test.WithPersistedServiceInfo("first", test.DefaultLiquidServiceInfo()),
921921
test.WithInitialDiscovery,
922-
test.WithEmptyRecordsAsNeeded,
922+
test.WithEmptyResourceRecordsAsNeeded,
923923
)
924924

925925
// This warrants its own unit test since the rendering of empty project lists
@@ -943,7 +943,7 @@ func Test_EmptyProjectList(t *testing.T) {
943943
func Test_LargeProjectList(t *testing.T) {
944944
// to test the behavior of the project list endpoint for large lists,
945945
// set up a config with a large number of projects (we do it via the discovery config
946-
// in order to leverage test.WithInitialDiscover and test.WithEmptyRecordsAsNeeded)
946+
// in order to leverage test.WithInitialDiscover and test.WithEmptyResourceRecordsAsNeeded)
947947
projectUUIDs := make([]liquid.ProjectUUID, 100)
948948
projectsAsConfigured := make([]core.KeystoneProject, len(projectUUIDs))
949949
for idx := range projectUUIDs {
@@ -976,10 +976,10 @@ func Test_LargeProjectList(t *testing.T) {
976976
test.WithPersistedServiceInfo("shared", test.DefaultLiquidServiceInfo()),
977977
test.WithPersistedServiceInfo("unshared", test.DefaultLiquidServiceInfo()),
978978
test.WithInitialDiscovery,
979-
test.WithEmptyRecordsAsNeeded,
979+
test.WithEmptyResourceRecordsAsNeeded,
980980
)
981981

982-
// fill various fields that `test.WithEmptyRecordsAsNeeded` initializes empty with reasonably plausible dummy values
982+
// fill various fields that `test.WithEmptyResourceRecordsAsNeeded` initializes empty with reasonably plausible dummy values
983983
// (all those queries take an index into the project list as $1 and the project UUID as $2)
984984
queries := []string{
985985
`UPDATE project_services SET scraped_at = TO_TIMESTAMP($1) AT LOCAL WHERE project_id = (SELECT id FROM projects WHERE uuid = $2)`,
@@ -1100,7 +1100,7 @@ func Test_PutMaxQuotaOnProject(t *testing.T) {
11001100
}`),
11011101
test.WithPersistedServiceInfo("shared", test.DefaultLiquidServiceInfo()),
11021102
test.WithInitialDiscovery,
1103-
test.WithEmptyRecordsAsNeeded,
1103+
test.WithEmptyResourceRecordsAsNeeded,
11041104
)
11051105

11061106
tr, tr0 := easypg.NewTracker(t, s.DB.Db)
@@ -1233,7 +1233,7 @@ func Test_PutQuotaAutogrowth(t *testing.T) {
12331233
test.WithPersistedServiceInfo("shared", test.DefaultLiquidServiceInfo()),
12341234
test.WithPersistedServiceInfo("unshared", test.DefaultLiquidServiceInfo()),
12351235
test.WithInitialDiscovery,
1236-
test.WithEmptyRecordsAsNeeded,
1236+
test.WithEmptyResourceRecordsAsNeeded,
12371237
)
12381238

12391239
tr, tr0 := easypg.NewTracker(t, s.DB.Db)
@@ -1409,7 +1409,7 @@ func TestResourceRenaming(t *testing.T) {
14091409
test.WithPersistedServiceInfo("shared", test.DefaultLiquidServiceInfo()),
14101410
test.WithPersistedServiceInfo("unshared", test.DefaultLiquidServiceInfo()),
14111411
test.WithInitialDiscovery,
1412-
test.WithEmptyRecordsAsNeeded,
1412+
test.WithEmptyResourceRecordsAsNeeded,
14131413
)
14141414

14151415
// helper function that makes one GET query per structural level and checks
@@ -1667,7 +1667,7 @@ func Test_SeparatedTopologyOperations(t *testing.T) {
16671667
test.WithConfig(testAZSeparatedConfigJSON),
16681668
test.WithPersistedServiceInfo("shared", srvInfo),
16691669
test.WithInitialDiscovery,
1670-
test.WithEmptyRecordsAsNeeded,
1670+
test.WithEmptyResourceRecordsAsNeeded,
16711671
)
16721672

16731673
s.MustDBExec(`

internal/api/commitment_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ func setupCommitmentTest(t *testing.T, configJSON string) test.Setup {
258258
test.WithMockLiquidClient("fourth", srvInfoFourth),
259259
test.WithPersistedServiceInfo("fourth", srvInfoFourth),
260260
test.WithInitialDiscovery,
261-
test.WithEmptyRecordsAsNeeded,
261+
test.WithEmptyResourceRecordsAsNeeded,
262262
)
263263

264264
// fill `az_resources`

internal/api/core.go

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,41 @@ func NewV1API(cluster *core.Cluster, tokenValidator gopherpolicy.Validator, audi
104104
return p
105105
}
106106

107+
type v2Provider struct {
108+
Cluster *core.Cluster
109+
DB *gorp.DbMap
110+
VersionData VersionData
111+
tokenValidator gopherpolicy.Validator
112+
auditor audittools.Auditor
113+
114+
// slots for test doubles
115+
timeNow func() time.Time
116+
}
117+
118+
// NewV2API creates an httpapi.API that serves the Limes v2 API.
119+
// It also returns the VersionData for this API version which is needed for the
120+
// version advertisement on "GET /".
121+
func NewV2API(cluster *core.Cluster, tokenValidator gopherpolicy.Validator, auditor audittools.Auditor, timeNow func() time.Time) httpapi.API {
122+
p := &v2Provider{Cluster: cluster, DB: cluster.DB, tokenValidator: tokenValidator, auditor: auditor, timeNow: timeNow}
123+
p.VersionData = VersionData{
124+
Status: "CURRENT",
125+
ID: "v2",
126+
Links: []VersionLinkData{
127+
{
128+
Relation: "self",
129+
URL: p.Path(),
130+
},
131+
{
132+
Relation: "describedby",
133+
URL: "https://github.com/sapcc/limes/blob/master/docs/users/api-v2-specification.md",
134+
Type: "text/html",
135+
},
136+
},
137+
}
138+
139+
return p
140+
}
141+
107142
// NewTokenValidator constructs a gopherpolicy.TokenValidator instance.
108143
func NewTokenValidator(provider *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (gopherpolicy.Validator, error) {
109144
identityV3, err := openstack.NewIdentityV3(provider, eo)
@@ -179,6 +214,23 @@ func (p *v1Provider) AddTo(r *mux.Router) {
179214
r.Methods("GET").Path("/admin/liquid/service-usage-request").HandlerFunc(p.GetServiceUsageRequest)
180215
}
181216

217+
// AddTo implements the httpapi.API interface.
218+
func (p *v2Provider) AddTo(r *mux.Router) {
219+
r.Methods("HEAD", "GET").Path("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
220+
httpapi.IdentifyEndpoint(r, "/")
221+
httpapi.SkipRequestLog(r)
222+
respondwith.JSON(w, 300, map[string]any{"versions": []VersionData{p.VersionData}})
223+
})
224+
225+
r.Methods("GET").Path("/v2/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
226+
httpapi.IdentifyEndpoint(r, "/v2/")
227+
httpapi.SkipRequestLog(r)
228+
respondwith.JSON(w, 200, map[string]any{"version": p.VersionData})
229+
})
230+
231+
r.Methods("GET").Path("/v2/info").HandlerFunc(p.GetInfo)
232+
}
233+
182234
// RequireJSON will parse the request body into the given data structure, or
183235
// write an error response if that fails.
184236
func RequireJSON(w http.ResponseWriter, r *http.Request, data any) bool {
@@ -190,23 +242,41 @@ func RequireJSON(w http.ResponseWriter, r *http.Request, data any) bool {
190242
return true
191243
}
192244

193-
// Path constructs a full URL for a given URL path below the /v1/ endpoint.
194-
func (p *v1Provider) Path(elements ...string) string {
195-
parts := []string{
196-
strings.TrimSuffix(p.Cluster.Config.CatalogURL, "/"),
197-
"v1",
198-
}
245+
func path(catalogURL, apiVersion string, elements ...string) string {
246+
parts := []string{strings.TrimSuffix(catalogURL, "/"), apiVersion}
199247
parts = append(parts, elements...)
200248
return strings.Join(parts, "/")
201249
}
202250

251+
// Path constructs a full URL for a given URL path below the /v1/ endpoint.
252+
func (p *v1Provider) Path(elements ...string) string {
253+
return path(p.Cluster.Config.CatalogURL, "v1", elements...)
254+
}
255+
256+
// Path constructs a full URL for a given URL path below the /v2/ endpoint.
257+
func (p *v2Provider) Path(elements ...string) string {
258+
return path(p.Cluster.Config.CatalogURL, "v2", elements...)
259+
}
260+
261+
// checkToken is a local helper to service the CheckToken functions of the different providers.
262+
func checkToken(r *http.Request, tokenValidator gopherpolicy.Validator) *gopherpolicy.Token {
263+
t := tokenValidator.CheckToken(r)
264+
t.Context.Request = mux.Vars(r)
265+
return t
266+
}
267+
203268
// CheckToken checks the validity of the request's X-Auth-Token in Keystone, and
204269
// returns a Token instance for checking authorization. Any errors that occur
205270
// during this function are deferred until Require() is called.
206271
func (p *v1Provider) CheckToken(r *http.Request) *gopherpolicy.Token {
207-
t := p.tokenValidator.CheckToken(r)
208-
t.Context.Request = mux.Vars(r)
209-
return t
272+
return checkToken(r, p.tokenValidator)
273+
}
274+
275+
// CheckToken checks the validity of the request's X-Auth-Token in Keystone, and
276+
// returns a Token instance for checking authorization. Any errors that occur
277+
// during this function are deferred until Require() is called.
278+
func (p *v2Provider) CheckToken(r *http.Request) *gopherpolicy.Token {
279+
return checkToken(r, p.tokenValidator)
210280
}
211281

212282
// FindDomainFromRequest loads the db.Domain referenced by the :domain_id path
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
{
2+
"service_areas": {
3+
"first": {
4+
"services": {
5+
"first": {
6+
"version": 1,
7+
"display_name": "",
8+
"resources": {
9+
"capacity": {
10+
"resources": {
11+
"capacity": {
12+
"display_name": "",
13+
"unit": "B",
14+
"topology": "az-aware",
15+
"has_capacity": true,
16+
"has_quota": true
17+
}
18+
}
19+
},
20+
"things": {
21+
"resources": {
22+
"things": {
23+
"display_name": "",
24+
"topology": "flat",
25+
"has_capacity": false,
26+
"has_quota": true
27+
}
28+
}
29+
}
30+
},
31+
"rates": {
32+
"objects:create": {
33+
"display_name": "",
34+
"topology": "flat",
35+
"has_usage": true,
36+
"limits": {
37+
"default_limit": 5000,
38+
"default_window": "1s"
39+
}
40+
},
41+
"objects:delete": {
42+
"display_name": "",
43+
"unit": "MiB",
44+
"topology": "flat",
45+
"has_usage": true
46+
},
47+
"objects:unlimited": {
48+
"display_name": "",
49+
"unit": "KiB",
50+
"topology": "flat",
51+
"has_usage": true
52+
},
53+
"objects:update": {
54+
"display_name": "",
55+
"topology": "flat",
56+
"has_usage": true
57+
}
58+
}
59+
}
60+
}
61+
},
62+
"second": {
63+
"services": {
64+
"second": {
65+
"version": 1,
66+
"display_name": "",
67+
"resources": {
68+
"capacity": {
69+
"resources": {
70+
"capacity": {
71+
"display_name": "",
72+
"unit": "B",
73+
"topology": "az-aware",
74+
"has_capacity": true,
75+
"has_quota": true,
76+
"commitment_config": {
77+
"durations": [
78+
"1 hour",
79+
"2 hours"
80+
],
81+
"min_confirm_by": 604800
82+
}
83+
}
84+
}
85+
},
86+
"things": {
87+
"resources": {
88+
"things": {
89+
"display_name": "",
90+
"topology": "flat",
91+
"has_capacity": false,
92+
"has_quota": true
93+
}
94+
}
95+
}
96+
},
97+
"rates": {}
98+
}
99+
}
100+
}
101+
}
102+
}

0 commit comments

Comments
 (0)