Skip to content

Commit 5c9e226

Browse files
committed
feat(api): add flexible device resolution with query parameter (#4857)
Implement device resolver functionality that allows fetching devices using attributes beyond the default UID. The new `resolver` endpoint accepts different attribute types while automatically scoping results to the current tenant context for security. Currently supported resolvers: - uid - hostname
1 parent 2960ce1 commit 5c9e226

File tree

7 files changed

+304
-1
lines changed

7 files changed

+304
-1
lines changed

api/routes/device.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
const (
1414
GetDeviceListURL = "/devices"
1515
GetDeviceURL = "/devices/:uid"
16+
ResolveDeviceURL = "/devices/resolve"
1617
DeleteDeviceURL = "/devices/:uid"
1718
RenameDeviceURL = "/devices/:uid"
1819
OfflineDeviceURL = "/devices/:uid/offline"
@@ -116,6 +117,24 @@ func (h *Handler) GetDevice(c gateway.Context) error {
116117
return c.JSON(http.StatusOK, device)
117118
}
118119

120+
func (h *Handler) ResolveDevice(c gateway.Context) error {
121+
var req requests.ResolveDevice
122+
if err := c.Bind(&req); err != nil {
123+
return err
124+
}
125+
126+
if err := c.Validate(&req); err != nil {
127+
return err
128+
}
129+
130+
device, err := h.service.ResolveDevice(c.Ctx(), &req)
131+
if err != nil {
132+
return err
133+
}
134+
135+
return c.JSON(http.StatusOK, device)
136+
}
137+
119138
func (h *Handler) DeleteDevice(c gateway.Context) error {
120139
var req requests.DeviceDelete
121140
if err := c.Bind(&req); err != nil {

api/routes/device_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,89 @@ func TestGetDevice(t *testing.T) {
9292
}
9393
}
9494

95+
func TestResolveDevice(t *testing.T) {
96+
mock := new(mocks.Service)
97+
98+
type Expected struct {
99+
device *models.Device
100+
status int
101+
}
102+
103+
cases := []struct {
104+
description string
105+
hostname string
106+
uid string
107+
headers map[string]string
108+
requiredMocks func()
109+
expected Expected
110+
}{
111+
{
112+
description: "succeeds when resolver is uid",
113+
hostname: "",
114+
uid: "uid",
115+
headers: map[string]string{
116+
"Content-Type": "application/json",
117+
"X-Role": authorizer.RoleOwner.String(),
118+
"X-Tenant-ID": "00000000-0000-4000-0000-000000000000",
119+
},
120+
requiredMocks: func() {
121+
mock.
122+
On("ResolveDevice", gomock.Anything, &requests.ResolveDevice{TenantID: "00000000-0000-4000-0000-000000000000", UID: "uid"}).
123+
Return(&models.Device{}, nil).
124+
Once()
125+
},
126+
expected: Expected{
127+
device: &models.Device{},
128+
status: http.StatusOK,
129+
},
130+
},
131+
{
132+
description: "succeeds when resolver is hostname",
133+
hostname: "hostname",
134+
uid: "",
135+
headers: map[string]string{
136+
"Content-Type": "application/json",
137+
"X-Role": authorizer.RoleOwner.String(),
138+
"X-Tenant-ID": "00000000-0000-4000-0000-000000000000",
139+
},
140+
requiredMocks: func() {
141+
mock.
142+
On("ResolveDevice", gomock.Anything, &requests.ResolveDevice{TenantID: "00000000-0000-4000-0000-000000000000", Hostname: "hostname"}).
143+
Return(&models.Device{}, nil).
144+
Once()
145+
},
146+
expected: Expected{
147+
device: &models.Device{},
148+
status: http.StatusOK,
149+
},
150+
},
151+
}
152+
153+
for _, tc := range cases {
154+
t.Run(tc.description, func(t *testing.T) {
155+
tc.requiredMocks()
156+
157+
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/devices/resolve?hostname=%s&uid=%s", tc.hostname, tc.uid), nil)
158+
for k, v := range tc.headers {
159+
req.Header.Set(k, v)
160+
}
161+
162+
rec := httptest.NewRecorder()
163+
e := NewRouter(mock)
164+
e.ServeHTTP(rec, req)
165+
166+
assert.Equal(t, tc.expected.status, rec.Result().StatusCode)
167+
168+
var session *models.Device
169+
if err := json.NewDecoder(rec.Result().Body).Decode(&session); err != nil {
170+
assert.ErrorIs(t, io.EOF, err)
171+
}
172+
173+
assert.Equal(t, tc.expected.device, session)
174+
})
175+
}
176+
}
177+
95178
func TestDeleteDevice(t *testing.T) {
96179
mock := new(mocks.Service)
97180

api/routes/routes.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ func NewRouter(service services.Service, opts ...Option) *echo.Echo {
116116

117117
publicAPI.GET(GetDeviceListURL, routesmiddleware.Authorize(gateway.Handler(handler.GetDeviceList)))
118118
publicAPI.GET(GetDeviceURL, routesmiddleware.Authorize(gateway.Handler(handler.GetDevice)))
119+
publicAPI.GET(ResolveDeviceURL, routesmiddleware.Authorize(gateway.Handler(handler.ResolveDevice)))
119120
publicAPI.PUT(UpdateDevice, gateway.Handler(handler.UpdateDevice), routesmiddleware.RequiresPermission(authorizer.DeviceUpdate))
120121
publicAPI.PATCH(RenameDeviceURL, gateway.Handler(handler.RenameDevice), routesmiddleware.RequiresPermission(authorizer.DeviceRename))
121122
publicAPI.PATCH(UpdateDeviceStatusURL, gateway.Handler(handler.UpdateDeviceStatus), routesmiddleware.RequiresPermission(authorizer.DeviceAccept)) // TODO: DeviceWrite

api/services/device.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ const StatusAccepted = "accepted"
1818
type DeviceService interface {
1919
ListDevices(ctx context.Context, req *requests.DeviceList) ([]models.Device, int, error)
2020
GetDevice(ctx context.Context, uid models.UID) (*models.Device, error)
21+
22+
// ResolveDevice attempts to resolve a device by searching for either its UID or hostname. When both are provided,
23+
// UID takes precedence over hostname. The search is scoped to the namespace's tenant ID to limit results.
24+
//
25+
// It returns the resolved device and any error encountered.
26+
ResolveDevice(ctx context.Context, req *requests.ResolveDevice) (*models.Device, error)
27+
2128
DeleteDevice(ctx context.Context, uid models.UID, tenant string) error
2229
RenameDevice(ctx context.Context, uid models.UID, name, tenant string) error
2330
LookupDevice(ctx context.Context, namespace, name string) (*models.Device, error)
@@ -82,6 +89,28 @@ func (s *service) GetDevice(ctx context.Context, uid models.UID) (*models.Device
8289
return device, nil
8390
}
8491

92+
func (s *service) ResolveDevice(ctx context.Context, req *requests.ResolveDevice) (*models.Device, error) {
93+
n, err := s.store.NamespaceGet(ctx, req.TenantID)
94+
if err != nil {
95+
return nil, NewErrNamespaceNotFound(req.TenantID, err)
96+
}
97+
98+
var device *models.Device
99+
switch {
100+
case req.UID != "":
101+
device, err = s.store.DeviceGet(ctx, models.UID(req.UID))
102+
case req.Hostname != "":
103+
device, err = s.store.DeviceLookup(ctx, n.Name, req.Hostname)
104+
}
105+
106+
if err != nil {
107+
// TODO: refactor this error to accept a string instead of models.UID
108+
return nil, NewErrDeviceNotFound(models.UID(""), err)
109+
}
110+
111+
return device, nil
112+
}
113+
85114
// DeleteDevice deletes a device from a namespace.
86115
//
87116
// It receives a context, used to "control" the request flow and, the device UID from models.Device and the tenant ID

api/services/device_test.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,141 @@ func TestGetDevice(t *testing.T) {
586586
mock.AssertExpectations(t)
587587
}
588588

589+
func TestResolveDevice(t *testing.T) {
590+
mock := new(storemock.Store)
591+
592+
ctx := context.TODO()
593+
594+
type Expected struct {
595+
device *models.Device
596+
err error
597+
}
598+
599+
cases := []struct {
600+
description string
601+
requiredMocks func()
602+
req *requests.ResolveDevice
603+
expected Expected
604+
}{
605+
{
606+
description: "fails when namespace does not exists",
607+
req: &requests.ResolveDevice{TenantID: "00000000-0000-0000-0000-000000000000", UID: "uid", Hostname: ""},
608+
requiredMocks: func() {
609+
mock.
610+
On("NamespaceGet", ctx, "00000000-0000-0000-0000-000000000000").
611+
Return(nil, errors.New("error", "", 0)).
612+
Once()
613+
},
614+
expected: Expected{
615+
nil,
616+
NewErrNamespaceNotFound("00000000-0000-0000-0000-000000000000", errors.New("error", "", 0)),
617+
},
618+
},
619+
{
620+
description: "fails when cannot retrieve a device with the specified UID",
621+
req: &requests.ResolveDevice{TenantID: "00000000-0000-0000-0000-000000000000", UID: "uid", Hostname: ""},
622+
requiredMocks: func() {
623+
mock.
624+
On("NamespaceGet", ctx, "00000000-0000-0000-0000-000000000000").
625+
Return(&models.Namespace{Name: "namespace"}, nil).
626+
Once()
627+
mock.
628+
On("DeviceGet", ctx, models.UID("uid")).
629+
Return(nil, errors.New("error", "", 0)).
630+
Once()
631+
},
632+
expected: Expected{
633+
nil,
634+
NewErrDeviceNotFound(models.UID(""), errors.New("error", "", 0)),
635+
},
636+
},
637+
{
638+
description: "succeeds to fetch a device using UID",
639+
req: &requests.ResolveDevice{TenantID: "00000000-0000-0000-0000-000000000000", UID: "uid", Hostname: ""},
640+
requiredMocks: func() {
641+
mock.
642+
On("NamespaceGet", ctx, "00000000-0000-0000-0000-000000000000").
643+
Return(&models.Namespace{Name: "namespace"}, nil).
644+
Once()
645+
mock.
646+
On("DeviceGet", ctx, models.UID("uid")).
647+
Return(&models.Device{UID: "uid"}, nil).
648+
Once()
649+
},
650+
expected: Expected{
651+
&models.Device{UID: "uid"},
652+
nil,
653+
},
654+
},
655+
{
656+
description: "fails when cannot retrieve a device with the specified hostname",
657+
req: &requests.ResolveDevice{TenantID: "00000000-0000-0000-0000-000000000000", UID: "", Hostname: "hostname"},
658+
requiredMocks: func() {
659+
mock.
660+
On("NamespaceGet", ctx, "00000000-0000-0000-0000-000000000000").
661+
Return(&models.Namespace{Name: "namespace"}, nil).
662+
Once()
663+
mock.
664+
On("DeviceLookup", ctx, "namespace", "hostname").
665+
Return(nil, errors.New("error", "", 0)).
666+
Once()
667+
},
668+
expected: Expected{
669+
nil,
670+
NewErrDeviceNotFound(models.UID(""), errors.New("error", "", 0)),
671+
},
672+
},
673+
{
674+
description: "succeeds to fetch a device using hostname",
675+
req: &requests.ResolveDevice{TenantID: "00000000-0000-0000-0000-000000000000", UID: "", Hostname: "hostname"},
676+
requiredMocks: func() {
677+
mock.
678+
On("NamespaceGet", ctx, "00000000-0000-0000-0000-000000000000").
679+
Return(&models.Namespace{Name: "namespace"}, nil).
680+
Once()
681+
mock.
682+
On("DeviceLookup", ctx, "namespace", "hostname").
683+
Return(&models.Device{UID: "uid"}, nil).
684+
Once()
685+
},
686+
expected: Expected{
687+
&models.Device{UID: "uid"},
688+
nil,
689+
},
690+
},
691+
{
692+
description: "succeeds to fetch a device using uid when both are provided",
693+
req: &requests.ResolveDevice{TenantID: "00000000-0000-0000-0000-000000000000", UID: "uid", Hostname: "hostname"},
694+
requiredMocks: func() {
695+
mock.
696+
On("NamespaceGet", ctx, "00000000-0000-0000-0000-000000000000").
697+
Return(&models.Namespace{Name: "namespace"}, nil).
698+
Once()
699+
mock.
700+
On("DeviceGet", ctx, models.UID("uid")).
701+
Return(&models.Device{UID: "uid"}, nil).
702+
Once()
703+
},
704+
expected: Expected{
705+
&models.Device{UID: "uid"},
706+
nil,
707+
},
708+
},
709+
}
710+
711+
s := NewService(store.Store(mock), privateKey, publicKey, storecache.NewNullCache(), clientMock)
712+
for _, tc := range cases {
713+
t.Run(tc.description, func(t *testing.T) {
714+
tc.requiredMocks()
715+
716+
device, err := s.ResolveDevice(ctx, tc.req)
717+
assert.Equal(t, tc.expected, Expected{device, err})
718+
})
719+
}
720+
721+
mock.AssertExpectations(t)
722+
}
723+
589724
func TestDeleteDevice(t *testing.T) {
590725
storeMock := new(storemock.Store)
591726

api/services/mocks/services.go

Lines changed: 31 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/api/requests/device.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ type DeviceGet struct {
2929
DeviceParam
3030
}
3131

32+
type ResolveDevice struct {
33+
TenantID string `header:"X-Tenant-ID" validate:"required"`
34+
UID string `query:"uid" validate:"omitempty"`
35+
Hostname string `query:"hostname" validate:"omitempty"`
36+
}
37+
3238
// DeviceDelete is the structure to represent the request data for delete device endpoint.
3339
type DeviceDelete struct {
3440
DeviceParam

0 commit comments

Comments
 (0)