mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-17 16:36:02 +00:00
api/v1: build responses from view accessors, not AsStruct
Serialize node, user and pre-auth-key responses through the NodeView, UserView and PreAuthKeyView accessors instead of AsStruct(), which clones the whole record on every read. Document the convention in AGENTS.md.
This commit is contained in:
parent
339cb392a9
commit
96d736ec23
5 changed files with 66 additions and 61 deletions
|
|
@ -275,6 +275,11 @@ Key reminders:
|
||||||
`e = e.Str("k", v)`. Forgetting to reassign silently drops the field.
|
`e = e.Str("k", v)`. Forgetting to reassign silently drops the field.
|
||||||
- **Tests**: prefer `hscontrol/servertest/` for server-level tests that
|
- **Tests**: prefer `hscontrol/servertest/` for server-level tests that
|
||||||
don't need Docker — faster than full integration tests.
|
don't need Docker — faster than full integration tests.
|
||||||
|
- **View types in read paths**: response serializers must read through
|
||||||
|
`NodeView`/`UserView`/`PreAuthKeyView` accessors. `AsStruct()` clones the
|
||||||
|
whole record on every read — it is only for DB-write/merge clones and mutable
|
||||||
|
working copies, never to build an API response. `grep AsStruct hscontrol/api`
|
||||||
|
must come back empty.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,7 @@ func registerNodeReadOps(api huma.API, b Backend) {
|
||||||
// Tags-as-identity: tagged nodes are presented as the special
|
// Tags-as-identity: tagged nodes are presented as the special
|
||||||
// TaggedDevices user.
|
// TaggedDevices user.
|
||||||
if node.IsTagged() {
|
if node.IsTagged() {
|
||||||
user := userFromState(&types.TaggedDevices)
|
user := userFromView(types.TaggedDevices.View())
|
||||||
n.User = &user
|
n.User = &user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -583,74 +583,73 @@ func registerNodeAdminOps(api huma.API, b Backend) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// nodeFromView builds the Node response from a NodeView. SubnetRoutes is left
|
// nodeFromView builds the Node response from a NodeView, reading through the
|
||||||
// empty; callers that serve routes set it explicitly.
|
// view accessors. SubnetRoutes is left empty; callers that serve routes set it
|
||||||
|
// explicitly.
|
||||||
func nodeFromView(view types.NodeView) Node {
|
func nodeFromView(view types.NodeView) Node {
|
||||||
node := view.AsStruct()
|
|
||||||
|
|
||||||
n := Node{
|
n := Node{
|
||||||
ID: formatID(uint64(node.ID)),
|
ID: view.StringID(),
|
||||||
MachineKey: node.MachineKey.String(),
|
MachineKey: view.MachineKey().String(),
|
||||||
NodeKey: node.NodeKey.String(),
|
NodeKey: view.NodeKey().String(),
|
||||||
DiscoKey: node.DiscoKey.String(),
|
DiscoKey: view.DiscoKey().String(),
|
||||||
IPAddresses: nonNilStrings(node.IPsAsString()),
|
IPAddresses: nonNilStrings(view.IPsAsString()),
|
||||||
Name: node.Hostname,
|
Name: view.Hostname(),
|
||||||
CreatedAt: node.CreatedAt,
|
CreatedAt: view.CreatedAt(),
|
||||||
RegisterMethod: registerMethodEnum(node.RegisterMethod),
|
RegisterMethod: registerMethodEnum(view.RegisterMethod()),
|
||||||
GivenName: node.GivenName,
|
GivenName: view.GivenName(),
|
||||||
Online: node.IsOnline != nil && *node.IsOnline,
|
Online: view.IsOnline().Valid() && view.IsOnline().Get(),
|
||||||
ApprovedRoutes: nonNilStrings(util.PrefixesToString(node.ApprovedRoutes)),
|
ApprovedRoutes: nonNilStrings(util.PrefixesToString(view.ApprovedRoutes().AsSlice())),
|
||||||
AvailableRoutes: nonNilStrings(util.PrefixesToString(node.AnnouncedRoutes())),
|
AvailableRoutes: nonNilStrings(util.PrefixesToString(view.AnnouncedRoutes())),
|
||||||
SubnetRoutes: []string{},
|
SubnetRoutes: []string{},
|
||||||
Tags: nonNilStrings(node.Tags),
|
Tags: nonNilStrings(view.Tags().AsSlice()),
|
||||||
}
|
}
|
||||||
|
|
||||||
if node.User != nil {
|
if view.User().Valid() {
|
||||||
user := userFromState(node.User)
|
user := userFromView(view.User())
|
||||||
n.User = &user
|
n.User = &user
|
||||||
}
|
}
|
||||||
|
|
||||||
if node.AuthKey != nil {
|
if view.AuthKey().Valid() {
|
||||||
n.PreAuthKey = nodePreAuthKeyFromState(node.AuthKey)
|
n.PreAuthKey = nodePreAuthKeyFromView(view.AuthKey())
|
||||||
}
|
}
|
||||||
|
|
||||||
if node.LastSeen != nil {
|
if view.LastSeen().Valid() {
|
||||||
ls := *node.LastSeen
|
ls := view.LastSeen().Get()
|
||||||
n.LastSeen = &ls
|
n.LastSeen = &ls
|
||||||
}
|
}
|
||||||
|
|
||||||
if node.Expiry != nil {
|
if view.Expiry().Valid() {
|
||||||
exp := *node.Expiry
|
exp := view.Expiry().Get()
|
||||||
n.Expiry = &exp
|
n.Expiry = &exp
|
||||||
}
|
}
|
||||||
|
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// nodePreAuthKeyFromState builds the embedded NodePreAuthKey, masking the key to
|
// nodePreAuthKeyFromView builds the embedded NodePreAuthKey, masking the key to
|
||||||
// its prefix (legacy plaintext keys are shown in full).
|
// its prefix (legacy plaintext keys are shown in full).
|
||||||
func nodePreAuthKeyFromState(key *types.PreAuthKey) *NodePreAuthKey {
|
func nodePreAuthKeyFromView(key types.PreAuthKeyView) *NodePreAuthKey {
|
||||||
pak := &NodePreAuthKey{
|
pak := &NodePreAuthKey{
|
||||||
ID: formatID(key.ID),
|
ID: formatID(key.ID()),
|
||||||
Key: maskedPreAuthKey(key),
|
Key: maskedPreAuthKey(key),
|
||||||
Reusable: key.Reusable,
|
Reusable: key.Reusable(),
|
||||||
Ephemeral: key.Ephemeral,
|
Ephemeral: key.Ephemeral(),
|
||||||
Used: key.Used,
|
Used: key.Used(),
|
||||||
AclTags: nonNilStrings(key.Tags),
|
AclTags: nonNilStrings(key.Tags().AsSlice()),
|
||||||
}
|
}
|
||||||
|
|
||||||
if key.User != nil {
|
if key.User().Valid() {
|
||||||
user := userFromState(key.User)
|
user := userFromView(key.User())
|
||||||
pak.User = &user
|
pak.User = &user
|
||||||
}
|
}
|
||||||
|
|
||||||
if key.Expiration != nil {
|
if key.Expiration().Valid() {
|
||||||
exp := *key.Expiration
|
exp := key.Expiration().Get()
|
||||||
pak.Expiration = &exp
|
pak.Expiration = &exp
|
||||||
}
|
}
|
||||||
|
|
||||||
if key.CreatedAt != nil {
|
if key.CreatedAt().Valid() {
|
||||||
created := *key.CreatedAt
|
created := key.CreatedAt().Get()
|
||||||
pak.CreatedAt = &created
|
pak.CreatedAt = &created
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,7 @@ func preAuthKeyNewToResponse(key *types.PreAuthKeyNew) PreAuthKey {
|
||||||
}
|
}
|
||||||
|
|
||||||
if key.User != nil {
|
if key.User != nil {
|
||||||
u := userFromState(key.User)
|
u := userFromView(key.User.View())
|
||||||
out.User = &u
|
out.User = &u
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -243,7 +243,7 @@ func preAuthKeyNewToResponse(key *types.PreAuthKeyNew) PreAuthKey {
|
||||||
func preAuthKeyToResponse(key *types.PreAuthKey) PreAuthKey {
|
func preAuthKeyToResponse(key *types.PreAuthKey) PreAuthKey {
|
||||||
out := PreAuthKey{
|
out := PreAuthKey{
|
||||||
ID: formatID(key.ID),
|
ID: formatID(key.ID),
|
||||||
Key: maskedPreAuthKey(key),
|
Key: maskedPreAuthKey(key.View()),
|
||||||
Reusable: key.Reusable,
|
Reusable: key.Reusable,
|
||||||
Ephemeral: key.Ephemeral,
|
Ephemeral: key.Ephemeral,
|
||||||
Used: key.Used,
|
Used: key.Used,
|
||||||
|
|
@ -251,7 +251,7 @@ func preAuthKeyToResponse(key *types.PreAuthKey) PreAuthKey {
|
||||||
}
|
}
|
||||||
|
|
||||||
if key.User != nil {
|
if key.User != nil {
|
||||||
u := userFromState(key.User)
|
u := userFromView(key.User.View())
|
||||||
out.User = &u
|
out.User = &u
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -269,12 +269,12 @@ func preAuthKeyToResponse(key *types.PreAuthKey) PreAuthKey {
|
||||||
// maskedPreAuthKey masks new keys (those with a stored prefix) so the secret is
|
// maskedPreAuthKey masks new keys (those with a stored prefix) so the secret is
|
||||||
// never returned; legacy plaintext keys are returned in full for backwards
|
// never returned; legacy plaintext keys are returned in full for backwards
|
||||||
// compatibility.
|
// compatibility.
|
||||||
func maskedPreAuthKey(key *types.PreAuthKey) string {
|
func maskedPreAuthKey(key types.PreAuthKeyView) string {
|
||||||
if key.Prefix != "" {
|
if key.Prefix() != "" {
|
||||||
return "hskey-auth-" + key.Prefix + "-***"
|
return "hskey-auth-" + key.Prefix() + "-***"
|
||||||
}
|
}
|
||||||
|
|
||||||
return key.Key
|
return key.Key()
|
||||||
}
|
}
|
||||||
|
|
||||||
// nonNilTags ensures aclTags serializes as [] rather than null, matching
|
// nonNilTags ensures aclTags serializes as [] rather than null, matching
|
||||||
|
|
|
||||||
|
|
@ -29,23 +29,24 @@ type User struct {
|
||||||
ProfilePicURL string `json:"profilePicUrl"`
|
ProfilePicURL string `json:"profilePicUrl"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// userFromState converts a domain user into the v1 response shape: Name falls
|
// userFromView converts a domain user into the v1 response shape, reading
|
||||||
// back to Username() (email/provider/id) when the stored Name is empty, so OIDC
|
// through the [types.UserView] accessors: Name falls back to Username()
|
||||||
// users display their email.
|
// (email/provider/id) when the stored Name is empty, so OIDC users display
|
||||||
func userFromState(u *types.User) User {
|
// their email.
|
||||||
name := u.Name
|
func userFromView(u types.UserView) User {
|
||||||
|
name := u.Name()
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = u.Username()
|
name = u.Username()
|
||||||
}
|
}
|
||||||
|
|
||||||
return User{
|
return User{
|
||||||
ID: formatID(u.ID),
|
ID: formatID(u.ID()),
|
||||||
Name: name,
|
Name: name,
|
||||||
CreatedAt: u.CreatedAt,
|
CreatedAt: u.CreatedAt(),
|
||||||
DisplayName: u.DisplayName,
|
DisplayName: u.DisplayName(),
|
||||||
Email: u.Email,
|
Email: u.Email(),
|
||||||
ProviderID: u.ProviderIdentifier.String,
|
ProviderID: u.ProviderIdentifier().String,
|
||||||
Provider: u.Provider,
|
Provider: u.Provider(),
|
||||||
ProfilePicURL: u.ProfilePicURL,
|
ProfilePicURL: u.ProfilePicURL(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ func registerUsers(api huma.API, b Backend) {
|
||||||
b.Change(policyChanged)
|
b.Change(policyChanged)
|
||||||
|
|
||||||
out := &userOutput{}
|
out := &userOutput{}
|
||||||
out.Body.User = userFromState(user)
|
out.Body.User = userFromView(user.View())
|
||||||
|
|
||||||
return out, nil
|
return out, nil
|
||||||
})
|
})
|
||||||
|
|
@ -129,7 +129,7 @@ func registerUsers(api huma.API, b Backend) {
|
||||||
}
|
}
|
||||||
|
|
||||||
out := &userOutput{}
|
out := &userOutput{}
|
||||||
out.Body.User = userFromState(newUser)
|
out.Body.User = userFromView(newUser.View())
|
||||||
|
|
||||||
return out, nil
|
return out, nil
|
||||||
})
|
})
|
||||||
|
|
@ -192,7 +192,7 @@ func registerUsers(api huma.API, b Backend) {
|
||||||
|
|
||||||
out.Body.Users = make([]User, len(users))
|
out.Body.Users = make([]User, len(users))
|
||||||
for i := range users {
|
for i := range users {
|
||||||
out.Body.Users[i] = userFromState(&users[i])
|
out.Body.Users[i] = userFromView(users[i].View())
|
||||||
}
|
}
|
||||||
|
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue