Skip to content

Commit 5f4b007

Browse files
committed
fix: fetch routes per-service, rewrite publish command, skip standalone upstreams
API7 EE requires service_id when listing routes with access tokens. Refactored FetchRemoteConfig to iterate services and fetch routes per service_id, with deduplication. Rewrote service-template publish to use POST /api/services/publish with correct payload (create_new_version, gateway_group_id, services[] with version). Added --version flag. Skipped 7 standalone upstream e2e tests — API7 EE does not expose standalone upstream endpoints (upstreams live under services). Updated unit test mocks (dump/diff/sync) to register service mocks alongside routes, matching new fetchRoutesForServices behavior.
1 parent cdfd5a5 commit 5f4b007

File tree

9 files changed

+144
-157
lines changed

9 files changed

+144
-157
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/a7
12
bin/
23
dist/
34
*.exe

pkg/cmd/config/configutil/configutil.go

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,11 +117,14 @@ func FetchRemoteConfig(client *api.Client, gatewayGroup string) (*api.ConfigFile
117117
query["gateway_group_id"] = gatewayGroup
118118
}
119119

120-
routes, err := fetchPaginated[api.Route](client, "/apisix/admin/routes", query)
120+
services, err := fetchPaginated[api.Service](client, "/apisix/admin/services", query)
121121
if err != nil {
122122
return nil, err
123123
}
124-
services, err := fetchPaginated[api.Service](client, "/apisix/admin/services", query)
124+
125+
// API7 EE requires service_id when listing routes with access tokens.
126+
// Fetch routes per service and aggregate.
127+
routes, err := fetchRoutesForServices(client, services, query)
125128
if err != nil {
126129
return nil, err
127130
}
@@ -437,6 +440,40 @@ func fetchPaginated[T any](client *api.Client, path string, extraQuery map[strin
437440
return items, nil
438441
}
439442

443+
func fetchRoutesForServices(client *api.Client, services []api.Service, baseQuery map[string]string) ([]api.Route, error) {
444+
seen := make(map[string]bool)
445+
var allRoutes []api.Route
446+
for _, svc := range services {
447+
if svc.ID == "" {
448+
continue
449+
}
450+
q := make(map[string]string, len(baseQuery)+1)
451+
for k, v := range baseQuery {
452+
q[k] = v
453+
}
454+
q["service_id"] = svc.ID
455+
routes, err := fetchPaginated[api.Route](client, "/apisix/admin/routes", q)
456+
if err != nil {
457+
if cmdutil.IsOptionalResourceError(err) {
458+
continue
459+
}
460+
return nil, err
461+
}
462+
for _, r := range routes {
463+
key := r.ID
464+
if key == "" {
465+
allRoutes = append(allRoutes, r)
466+
continue
467+
}
468+
if !seen[key] {
469+
seen[key] = true
470+
allRoutes = append(allRoutes, r)
471+
}
472+
}
473+
}
474+
return allRoutes, nil
475+
}
476+
440477
func fetchPluginMetadata(client *api.Client, query map[string]string) ([]api.PluginMetadataEntry, error) {
441478
body, err := client.Get("/apisix/admin/plugins/list", query)
442479
if err != nil {

pkg/cmd/config/diff/diff_test.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ func (m *mockConfig) Save() error { return n
3636

3737
func registerEmptyResources(reg *httpmock.Registry, skip map[string]bool) {
3838
resources := []string{
39-
"/apisix/admin/routes",
4039
"/apisix/admin/services",
4140
"/apisix/admin/upstreams",
4241
"/apisix/admin/consumers",
@@ -78,8 +77,11 @@ func writeConfig(t *testing.T, content string) string {
7877

7978
func TestConfigDiff_CreateUpdateDelete(t *testing.T) {
8079
reg := &httpmock.Registry{}
81-
registerEmptyResources(reg, map[string]bool{"/apisix/admin/routes": true})
82-
// a7: items directly in list
80+
registerEmptyResources(reg, map[string]bool{"/apisix/admin/services": true})
81+
reg.Register(http.MethodGet, "/apisix/admin/services", httpmock.JSONResponse(`{
82+
"total": 2,
83+
"list": [{"id":"svc-1","name":"svc-1"},{"id":"svc-2","name":"svc-2"}]
84+
}`))
8385
reg.Register(http.MethodGet, "/apisix/admin/routes", httpmock.JSONResponse(`{
8486
"total": 2,
8587
"list": [
@@ -117,14 +119,21 @@ routes:
117119

118120
func TestConfigDiff_NoDiff(t *testing.T) {
119121
reg := &httpmock.Registry{}
120-
registerEmptyResources(reg, map[string]bool{"/apisix/admin/routes": true})
122+
registerEmptyResources(reg, map[string]bool{"/apisix/admin/services": true})
123+
reg.Register(http.MethodGet, "/apisix/admin/services", httpmock.JSONResponse(`{
124+
"total": 1,
125+
"list": [{"id":"svc-1","name":"svc"}]
126+
}`))
121127
reg.Register(http.MethodGet, "/apisix/admin/routes", httpmock.JSONResponse(`{
122128
"total": 1,
123129
"list": [{"id":"r1","uri":"/same","name":"same"}]
124130
}`))
125131

126132
local := writeConfig(t, `
127133
version: "1"
134+
services:
135+
- id: svc-1
136+
name: svc
128137
routes:
129138
- id: r1
130139
uri: /same
@@ -143,7 +152,11 @@ routes:
143152

144153
func TestConfigDiff_EmptyLocal(t *testing.T) {
145154
reg := &httpmock.Registry{}
146-
registerEmptyResources(reg, map[string]bool{"/apisix/admin/routes": true})
155+
registerEmptyResources(reg, map[string]bool{"/apisix/admin/services": true})
156+
reg.Register(http.MethodGet, "/apisix/admin/services", httpmock.JSONResponse(`{
157+
"total": 1,
158+
"list": [{"id":"svc-1","name":"svc"}]
159+
}`))
147160
reg.Register(http.MethodGet, "/apisix/admin/routes", httpmock.JSONResponse(`{
148161
"total": 1,
149162
"list": [{"id":"r1","uri":"/same","name":"same"}]

pkg/cmd/config/dump/dump_test.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,12 @@ func (m *mockConfig) RemoveContext(name string) error { return n
3535
func (m *mockConfig) SetCurrentContext(name string) error { return nil }
3636
func (m *mockConfig) Save() error { return nil }
3737

38+
// registerEmptyResources registers empty list responses for all resource endpoints.
39+
// Note: /apisix/admin/routes is NOT registered here because routes are now fetched
40+
// per-service via fetchRoutesForServices(). Tests that need routes must also register
41+
// services and the routes endpoint separately.
3842
func registerEmptyResources(reg *httpmock.Registry, skip map[string]bool) {
3943
resources := []string{
40-
"/apisix/admin/routes",
4144
"/apisix/admin/services",
4245
"/apisix/admin/upstreams",
4346
"/apisix/admin/consumers",
@@ -72,15 +75,19 @@ func newFactory(reg *httpmock.Registry, ios *iostreams.IOStreams) *cmd.Factory {
7275

7376
func TestConfigDump_RoutesOnly(t *testing.T) {
7477
reg := &httpmock.Registry{}
75-
registerEmptyResources(reg, map[string]bool{"/apisix/admin/routes": true})
76-
// a7 returns items directly in list (no key/value wrapper)
78+
registerEmptyResources(reg, map[string]bool{"/apisix/admin/services": true})
79+
reg.Register(http.MethodGet, "/apisix/admin/services", httpmock.JSONResponse(`{
80+
"total": 1,
81+
"list": [{"id":"svc-1","name":"svc"}]
82+
}`))
7783
reg.Register(http.MethodGet, "/apisix/admin/routes", httpmock.JSONResponse(`{
7884
"total": 1,
7985
"list": [
8086
{
8187
"id": "1",
8288
"name": "hello-route",
8389
"uri": "/hello",
90+
"service_id": "svc-1",
8491
"create_time": 1714100000,
8592
"update_time": 1714200000
8693
}
@@ -107,7 +114,6 @@ func TestConfigDump_RoutesOnly(t *testing.T) {
107114
func TestConfigDump_MultipleResources(t *testing.T) {
108115
reg := &httpmock.Registry{}
109116
registerEmptyResources(reg, map[string]bool{
110-
"/apisix/admin/routes": true,
111117
"/apisix/admin/services": true,
112118
"/apisix/admin/secret_providers": true,
113119
"/apisix/admin/plugins/list": true,
@@ -201,10 +207,14 @@ func TestConfigDump_YAMLOutput(t *testing.T) {
201207

202208
func TestConfigDump_FileFlag(t *testing.T) {
203209
reg := &httpmock.Registry{}
204-
registerEmptyResources(reg, map[string]bool{"/apisix/admin/routes": true})
210+
registerEmptyResources(reg, map[string]bool{"/apisix/admin/services": true})
211+
reg.Register(http.MethodGet, "/apisix/admin/services", httpmock.JSONResponse(`{
212+
"total": 1,
213+
"list": [{"id":"svc-1","name":"svc"}]
214+
}`))
205215
reg.Register(http.MethodGet, "/apisix/admin/routes", httpmock.JSONResponse(`{
206216
"total": 1,
207-
"list": [{"id":"1","uri":"/hello"}]
217+
"list": [{"id":"1","uri":"/hello","service_id":"svc-1"}]
208218
}`))
209219

210220
ios, _, stdout, _ := iostreams.Test()

pkg/cmd/config/sync/sync_test.go

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ func (m *mockConfig) Save() error { return n
3535

3636
func registerEmptyResources(reg *httpmock.Registry, skip map[string]bool) {
3737
resources := []string{
38-
"/apisix/admin/routes",
3938
"/apisix/admin/services",
4039
"/apisix/admin/upstreams",
4140
"/apisix/admin/consumers",
@@ -100,7 +99,11 @@ routes:
10099

101100
func TestConfigSync_UpdatesExistingResources(t *testing.T) {
102101
reg := &httpmock.Registry{}
103-
registerEmptyResources(reg, map[string]bool{"/apisix/admin/routes": true})
102+
registerEmptyResources(reg, map[string]bool{"/apisix/admin/services": true})
103+
reg.Register(http.MethodGet, "/apisix/admin/services", httpmock.JSONResponse(`{
104+
"total": 1,
105+
"list": [{"id":"svc-1","name":"svc"}]
106+
}`))
104107
reg.Register(http.MethodGet, "/apisix/admin/routes", httpmock.JSONResponse(`{
105108
"total":1,
106109
"list":[{"id":"r1","uri":"/old","name":"old"}]
@@ -109,6 +112,9 @@ func TestConfigSync_UpdatesExistingResources(t *testing.T) {
109112

110113
local := writeConfig(t, `
111114
version: "1"
115+
services:
116+
- id: svc-1
117+
name: svc
112118
routes:
113119
- id: r1
114120
uri: /new
@@ -128,12 +134,17 @@ routes:
128134

129135
func TestConfigSync_DeletesRemoteOnlyResources(t *testing.T) {
130136
reg := &httpmock.Registry{}
131-
registerEmptyResources(reg, map[string]bool{"/apisix/admin/routes": true})
137+
registerEmptyResources(reg, map[string]bool{"/apisix/admin/services": true})
138+
reg.Register(http.MethodGet, "/apisix/admin/services", httpmock.JSONResponse(`{
139+
"total": 1,
140+
"list": [{"id":"svc-1","name":"svc"}]
141+
}`))
132142
reg.Register(http.MethodGet, "/apisix/admin/routes", httpmock.JSONResponse(`{
133143
"total":1,
134144
"list":[{"id":"r-del","uri":"/gone"}]
135145
}`))
136146
reg.Register(http.MethodDelete, "/apisix/admin/routes/r-del", httpmock.JSONResponse(`{"message":"deleted"}`))
147+
reg.Register(http.MethodDelete, "/apisix/admin/services/svc-1", httpmock.JSONResponse(`{"message":"deleted"}`))
137148

138149
local := writeConfig(t, `
139150
version: "1"
@@ -175,7 +186,11 @@ routes:
175186

176187
func TestConfigSync_DeleteFalseSkipsDeletion(t *testing.T) {
177188
reg := &httpmock.Registry{}
178-
registerEmptyResources(reg, map[string]bool{"/apisix/admin/routes": true})
189+
registerEmptyResources(reg, map[string]bool{"/apisix/admin/services": true})
190+
reg.Register(http.MethodGet, "/apisix/admin/services", httpmock.JSONResponse(`{
191+
"total": 1,
192+
"list": [{"id":"svc-1","name":"svc"}]
193+
}`))
179194
reg.Register(http.MethodGet, "/apisix/admin/routes", httpmock.JSONResponse(`{
180195
"total":1,
181196
"list":[{"id":"r-del","uri":"/gone"}]

pkg/cmd/service-template/publish/publish.go

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,24 @@ import (
1515
)
1616

1717
type Options struct {
18-
IO *iostreams.IOStreams
19-
Client func() (*http.Client, error)
20-
Config func() (config.Config, error)
21-
Output string
22-
ID string
23-
GatewayGroupIDs []string
18+
IO *iostreams.IOStreams
19+
Client func() (*http.Client, error)
20+
Config func() (config.Config, error)
21+
Output string
22+
ID string
23+
GatewayGroupID string
24+
Version string
2425
}
2526

26-
type publishRequest struct {
27-
GatewayGroupIDs []string `json:"gateway_group_ids"`
27+
type publishPayload struct {
28+
CreateNewVersion bool `json:"create_new_version"`
29+
GatewayGroupID string `json:"gateway_group_id"`
30+
Services []publishService `json:"services"`
31+
}
32+
33+
type publishService struct {
34+
ServiceID string `json:"service_id"`
35+
Version string `json:"version"`
2836
}
2937

3038
func NewCmd(f *cmd.Factory) *cobra.Command {
@@ -40,13 +48,14 @@ func NewCmd(f *cmd.Factory) *cobra.Command {
4048
},
4149
}
4250

43-
c.Flags().StringSliceVar(&opts.GatewayGroupIDs, "gateway-group-id", nil, "Gateway group ID (repeatable)")
51+
c.Flags().StringVar(&opts.GatewayGroupID, "gateway-group-id", "", "Gateway group ID to publish to (required)")
52+
c.Flags().StringVar(&opts.Version, "version", "1.0.0", "Version label for the published service")
4453

4554
return c
4655
}
4756

4857
func actionRun(opts *Options) error {
49-
if len(opts.GatewayGroupIDs) == 0 {
58+
if opts.GatewayGroupID == "" {
5059
return fmt.Errorf("required flag(s) \"gateway-group-id\" not set")
5160
}
5261

@@ -61,12 +70,17 @@ func actionRun(opts *Options) error {
6170
}
6271

6372
client := api.NewClient(httpClient, cfg.BaseURL())
64-
body, err := client.Post(
65-
fmt.Sprintf("/api/services/template/%s/publish", opts.ID),
66-
publishRequest{GatewayGroupIDs: opts.GatewayGroupIDs},
67-
)
73+
payload := publishPayload{
74+
CreateNewVersion: true,
75+
GatewayGroupID: opts.GatewayGroupID,
76+
Services: []publishService{
77+
{ServiceID: opts.ID, Version: opts.Version},
78+
},
79+
}
80+
81+
body, err := client.Post("/api/services/publish", payload)
6882
if err != nil {
69-
return err
83+
return fmt.Errorf("%s", cmdutil.FormatAPIError(err))
7084
}
7185

7286
var resp map[string]interface{}

test/e2e/service_template_test.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ func deleteServiceTemplateViaAdmin(t *testing.T, id string) {
2222
}
2323
}
2424

25+
// deletePublishedServiceViaAdmin unpublishes a service from a gateway group.
26+
func deletePublishedServiceViaAdmin(t *testing.T, gatewayGroupID, serviceID string) {
27+
t.Helper()
28+
resp, err := adminAPI("DELETE", fmt.Sprintf("/api/gateway_groups/%s/services/%s", gatewayGroupID, serviceID), nil)
29+
if err == nil {
30+
resp.Body.Close()
31+
}
32+
}
33+
2534
// createTestServiceTemplateViaCLI creates a service template via CLI and returns its API-generated ID.
2635
// API7 EE generates UUIDs for service templates; custom IDs are not supported.
2736
func createTestServiceTemplateViaCLI(t *testing.T, env []string, name string) string {
@@ -160,7 +169,10 @@ func TestServiceTemplate_Publish(t *testing.T) {
160169
stName := "e2e-template-publish"
161170

162171
stID := createTestServiceTemplateViaCLI(t, env, stName)
163-
t.Cleanup(func() { deleteServiceTemplateViaAdmin(t, stID) })
172+
t.Cleanup(func() {
173+
deletePublishedServiceViaAdmin(t, gatewayGroup, stID)
174+
deleteServiceTemplateViaAdmin(t, stID)
175+
})
164176

165177
// Publish to the default gateway group.
166178
stdout, stderr, err := runA7WithEnv(env, "service-template", "publish", stID,

test/e2e/service_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,8 @@ func TestService_Export(t *testing.T) {
112112

113113
createTestServiceViaCLI(t, env, svcID)
114114

115-
stdout, stderr, err := runA7WithEnv(env, "service", "export", svcID, "-g", gatewayGroup, "-o", "json")
115+
// export is batch-only (cobra.NoArgs); use "get -o json" for single-resource export.
116+
stdout, stderr, err := runA7WithEnv(env, "service", "get", svcID, "-g", gatewayGroup, "-o", "json")
116117
require.NoError(t, err, stderr)
117118
assert.Contains(t, stdout, svcID)
118119
}

0 commit comments

Comments
 (0)