602 lines
23 KiB
Go
602 lines
23 KiB
Go
package controlapi
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"yovision/sense/internal/auth"
|
|
"yovision/sense/internal/device"
|
|
"yovision/sense/internal/store"
|
|
)
|
|
|
|
type principalContextKey struct{}
|
|
type traceContextKey struct{}
|
|
|
|
type Server struct {
|
|
store store.ControlRepository
|
|
authenticator auth.Authenticator
|
|
cursors *CursorCodec
|
|
now func() time.Time
|
|
}
|
|
|
|
func NewHTTPHandler(
|
|
repository store.ControlRepository, authenticator auth.Authenticator, cursors *CursorCodec,
|
|
) http.Handler {
|
|
server := &Server{
|
|
store: repository, authenticator: authenticator, cursors: cursors, now: time.Now,
|
|
}
|
|
generated := HandlerWithOptions(server, StdHTTPServerOptions{ErrorHandlerFunc: server.bindError})
|
|
return server.authenticate(generated)
|
|
}
|
|
|
|
func (s *Server) authenticate(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
traceID, err := newTraceID()
|
|
if err != nil {
|
|
http.Error(writer, "service unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
ctx := context.WithValue(request.Context(), traceContextKey{}, traceID)
|
|
request = request.WithContext(ctx)
|
|
values := request.Header.Values("Authorization")
|
|
if len(values) != 1 {
|
|
writer.Header().Set("WWW-Authenticate", `Bearer realm="sense-control"`)
|
|
s.writeProblem(writer, request, http.StatusUnauthorized, ErrorCodeUnauthenticated, "authentication is required")
|
|
return
|
|
}
|
|
parts := strings.Fields(values[0])
|
|
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
|
writer.Header().Set("WWW-Authenticate", `Bearer realm="sense-control"`)
|
|
s.writeProblem(writer, request, http.StatusUnauthorized, ErrorCodeUnauthenticated, "authentication is required")
|
|
return
|
|
}
|
|
principal, err := s.authenticator.Authenticate(request.Context(), parts[1])
|
|
if err != nil {
|
|
writer.Header().Set("WWW-Authenticate", `Bearer realm="sense-control"`)
|
|
s.writeProblem(writer, request, http.StatusUnauthorized, ErrorCodeUnauthenticated, "authentication is required")
|
|
return
|
|
}
|
|
ctx = context.WithValue(request.Context(), principalContextKey{}, principal)
|
|
next.ServeHTTP(writer, request.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
func principalFromContext(ctx context.Context) auth.Principal {
|
|
value, _ := ctx.Value(principalContextKey{}).(auth.Principal)
|
|
return value
|
|
}
|
|
|
|
func traceFromContext(ctx context.Context) string {
|
|
value, _ := ctx.Value(traceContextKey{}).(string)
|
|
return value
|
|
}
|
|
|
|
func (s *Server) bindError(writer http.ResponseWriter, request *http.Request, err error) {
|
|
var required *RequiredHeaderError
|
|
if errors.As(err, &required) && required.ParamName == "If-Match" {
|
|
s.writeProblem(writer, request, http.StatusPreconditionRequired,
|
|
ErrorCodePreconditionRequired, "If-Match is required")
|
|
return
|
|
}
|
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "request parameters are invalid")
|
|
}
|
|
|
|
func (s *Server) requireSite(
|
|
writer http.ResponseWriter, request *http.Request, siteID, permission string,
|
|
) (auth.Principal, bool) {
|
|
principal := principalFromContext(request.Context())
|
|
if !validLogicalID(siteID) {
|
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "site_id is invalid")
|
|
return auth.Principal{}, false
|
|
}
|
|
if !principal.AllowsSite(siteID) {
|
|
s.writeProblem(writer, request, http.StatusNotFound, ErrorCodeNotFound, "resource was not found")
|
|
return auth.Principal{}, false
|
|
}
|
|
if !principal.Has(permission) {
|
|
s.writeProblem(writer, request, http.StatusForbidden, ErrorCodeForbidden, "permission is required")
|
|
return auth.Principal{}, false
|
|
}
|
|
return principal, true
|
|
}
|
|
|
|
func (s *Server) writeProblem(
|
|
writer http.ResponseWriter, request *http.Request, status int, code ErrorCode, message string,
|
|
) {
|
|
traceID := traceFromContext(request.Context())
|
|
writer.Header().Set("Content-Type", "application/problem+json")
|
|
writer.Header().Set("Cache-Control", "no-store")
|
|
writer.Header().Set("X-Trace-ID", traceID)
|
|
writer.WriteHeader(status)
|
|
_ = json.NewEncoder(writer).Encode(Problem{
|
|
Type: "/problems/" + string(code), Title: http.StatusText(status), Status: status,
|
|
Code: code, Message: message, TraceId: traceID, FieldErrors: []FieldError{},
|
|
})
|
|
}
|
|
|
|
func writeJSON(writer http.ResponseWriter, status int, traceID string, value any) {
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
writer.Header().Set("Cache-Control", "no-store")
|
|
writer.Header().Set("X-Trace-ID", traceID)
|
|
writer.WriteHeader(status)
|
|
_ = json.NewEncoder(writer).Encode(value)
|
|
}
|
|
|
|
func (s *Server) ListDevices(
|
|
writer http.ResponseWriter, request *http.Request, siteID SiteID, params ListDevicesParams,
|
|
) {
|
|
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesRead)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := 50
|
|
if params.Limit != nil {
|
|
limit = *params.Limit
|
|
}
|
|
if limit < 1 || limit > 100 ||
|
|
(params.Modality != nil && !Modality(*params.Modality).Valid()) ||
|
|
(params.Capability != nil && !Capability(*params.Capability).Valid()) ||
|
|
(params.DesiredState != nil && !DesiredState(*params.DesiredState).Valid()) ||
|
|
(params.ActualState != nil && !ActualState(*params.ActualState).Valid()) {
|
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "list filters are invalid")
|
|
return
|
|
}
|
|
filterHash := filterFingerprint(
|
|
optionalString(params.Modality), optionalString(params.Capability),
|
|
optionalString(params.DesiredState), optionalString(params.ActualState),
|
|
)
|
|
filter := store.ControlListFilter{Limit: limit}
|
|
if params.Modality != nil {
|
|
value := device.Modality(*params.Modality)
|
|
filter.Modality = &value
|
|
}
|
|
if params.Capability != nil {
|
|
value := device.Capability(*params.Capability)
|
|
filter.Capability = &value
|
|
}
|
|
if params.DesiredState != nil {
|
|
value := device.DesiredState(*params.DesiredState)
|
|
filter.DesiredState = &value
|
|
}
|
|
if params.ActualState != nil {
|
|
value := device.ActualState(*params.ActualState)
|
|
filter.ActualState = &value
|
|
}
|
|
if params.Cursor != nil {
|
|
position, err := s.cursors.Decode(*params.Cursor, principal.TenantID, siteID, filterHash)
|
|
if err != nil {
|
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "cursor is invalid")
|
|
return
|
|
}
|
|
filter.AfterCreated = &position.CreatedAt
|
|
filter.AfterDeviceID = position.DeviceID
|
|
}
|
|
page, err := s.store.ListControlDevices(request.Context(), principal.TenantID, siteID, filter)
|
|
if err != nil {
|
|
s.writeStoreError(writer, request, err, false)
|
|
return
|
|
}
|
|
items := make([]Device, 0, len(page.Items))
|
|
for _, value := range page.Items {
|
|
items = append(items, publicDevice(value))
|
|
}
|
|
var nextCursor *string
|
|
if page.HasMore && len(page.Items) > 0 {
|
|
last := page.Items[len(page.Items)-1]
|
|
value, err := s.cursors.Encode(principal.TenantID, siteID, filterHash,
|
|
CursorPosition{CreatedAt: last.CreatedAt, DeviceID: last.ID})
|
|
if err != nil {
|
|
s.writeProblem(writer, request, http.StatusInternalServerError, ErrorCodeInternalError, "response could not be created")
|
|
return
|
|
}
|
|
nextCursor = &value
|
|
}
|
|
writeJSON(writer, http.StatusOK, traceFromContext(request.Context()), DevicePage{
|
|
Items: items, Page: PageInfo{Limit: limit, HasMore: page.HasMore, NextCursor: nextCursor},
|
|
Quota: publicQuota(page.Quota),
|
|
})
|
|
}
|
|
|
|
func optionalString[T ~string](value *T) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return string(*value)
|
|
}
|
|
|
|
func (s *Server) CreateDevice(
|
|
writer http.ResponseWriter, request *http.Request, siteID SiteID, params CreateDeviceParams,
|
|
) {
|
|
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !idempotencyKeyRegex.MatchString(params.IdempotencyKey) {
|
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "Idempotency-Key is invalid")
|
|
return
|
|
}
|
|
contents, err := readRequestBody(request, "application/json")
|
|
if err != nil {
|
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
|
return
|
|
}
|
|
if err := rejectTopLevelNulls(contents); err != nil {
|
|
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, err.Error())
|
|
return
|
|
}
|
|
var body DeviceCreate
|
|
if err := decodeStrictJSON(contents, &body); err != nil {
|
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
|
return
|
|
}
|
|
if code, message := validateDeviceCreate(body); code != "" {
|
|
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCode(code), message)
|
|
return
|
|
}
|
|
desired := Disabled
|
|
if body.DesiredState != nil {
|
|
desired = *body.DesiredState
|
|
}
|
|
body.DesiredState = &desired
|
|
canonical, _ := json.Marshal(body)
|
|
requestHash := sha256.Sum256(canonical)
|
|
now := s.now().UTC()
|
|
deviceID, err := newULID("dev_", now)
|
|
if err != nil {
|
|
s.writeProblem(writer, request, http.StatusServiceUnavailable, ErrorCodeServiceUnavailable, "identifier could not be generated")
|
|
return
|
|
}
|
|
capabilities := make([]device.Capability, len(body.Capabilities))
|
|
for index := range body.Capabilities {
|
|
capabilities[index] = device.Capability(body.Capabilities[index])
|
|
}
|
|
value := device.Device{
|
|
ID: deviceID, TenantID: principal.TenantID, SiteID: siteID, AreaID: body.AreaId,
|
|
SerialNumber: body.SerialNumber, Name: body.Name, Modality: device.Modality(body.Modality),
|
|
Capabilities: capabilities, DesiredState: device.DesiredState(desired),
|
|
ActualState: device.ActualPending, PathName: "devices/" + deviceID,
|
|
Generation: 1, ResourceVersion: 1,
|
|
}
|
|
if body.EndpointRef != nil {
|
|
value.EndpointRef = *body.EndpointRef
|
|
}
|
|
if body.CredentialRef != nil {
|
|
value.CredentialRef = *body.CredentialRef
|
|
}
|
|
if body.ProfileToken != nil {
|
|
value.ProfileToken = *body.ProfileToken
|
|
}
|
|
traceID := traceFromContext(request.Context())
|
|
ctx := store.WithAuditContext(request.Context(), auditContext(principal, "", traceID))
|
|
result, err := s.store.CreateControlDevice(ctx, store.ControlCreateRequest{
|
|
Scope: store.IdempotencyScope{
|
|
PrincipalID: principal.SubjectID, TenantID: principal.TenantID, SiteID: siteID,
|
|
Operation: "createDevice", Key: params.IdempotencyKey,
|
|
RequestHash: requestHash, TraceID: traceID,
|
|
},
|
|
Device: value,
|
|
})
|
|
if err != nil {
|
|
s.writeStoreError(writer, request, err, true)
|
|
return
|
|
}
|
|
writer.Header().Set("ETag", result.ETag)
|
|
writer.Header().Set("Location", result.Location)
|
|
writeJSON(writer, http.StatusCreated, result.TraceID, publicDevice(result.Device))
|
|
}
|
|
|
|
func validateDeviceCreate(body DeviceCreate) (string, string) {
|
|
if !validLogicalID(body.AreaId) || !validLength(body.SerialNumber, 1, 128) ||
|
|
!validLength(body.Name, 1, 200) || strings.TrimSpace(body.SerialNumber) == "" ||
|
|
strings.TrimSpace(body.Name) == "" || !body.Modality.Valid() ||
|
|
len(body.Capabilities) < 1 || len(body.Capabilities) > 16 {
|
|
return "invalid_request", "device fields are invalid"
|
|
}
|
|
seen := make(map[Capability]struct{}, len(body.Capabilities))
|
|
hasVideo := false
|
|
for _, capability := range body.Capabilities {
|
|
if !capability.Valid() {
|
|
return "invalid_request", "device capability is invalid"
|
|
}
|
|
if _, exists := seen[capability]; exists {
|
|
return "invalid_request", "device capabilities contain a duplicate"
|
|
}
|
|
seen[capability] = struct{}{}
|
|
hasVideo = hasVideo || capability == VideoCapture
|
|
}
|
|
if body.Modality == Video && !hasVideo {
|
|
return "invalid_request", "video modality requires video_capture"
|
|
}
|
|
if hasVideo && (body.EndpointRef == nil || body.CredentialRef == nil) {
|
|
return "adapter_not_ready", "video capture requires endpoint and credential references"
|
|
}
|
|
if body.EndpointRef != nil && !validateEndpoint(*body.EndpointRef) {
|
|
return "endpoint_credentials_forbidden", "endpoint reference is invalid"
|
|
}
|
|
if body.CredentialRef != nil && (!validLength(*body.CredentialRef, 1, 512) || strings.TrimSpace(*body.CredentialRef) == "") {
|
|
return "endpoint_credentials_forbidden", "credential reference is invalid"
|
|
}
|
|
if body.ProfileToken != nil && !validLength(*body.ProfileToken, 1, 256) {
|
|
return "invalid_request", "profile token is invalid"
|
|
}
|
|
if body.DesiredState != nil && !body.DesiredState.Valid() {
|
|
return "invalid_request", "desired_state is invalid"
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
func auditContext(principal auth.Principal, reason, traceID string) store.AuditContext {
|
|
actorType := store.AuditActorService
|
|
if principal.ActorType == "user" {
|
|
actorType = store.AuditActorUser
|
|
}
|
|
return store.AuditContext{
|
|
ActorType: actorType, ActorID: principal.SubjectID, Reason: reason, TraceID: traceID,
|
|
}
|
|
}
|
|
|
|
func (s *Server) GetDevice(
|
|
writer http.ResponseWriter, request *http.Request, siteID SiteID, deviceID DeviceID,
|
|
) {
|
|
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesRead)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !validLogicalID(deviceID) {
|
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "device_id is invalid")
|
|
return
|
|
}
|
|
value, err := s.store.GetControlDevice(request.Context(), principal.TenantID, siteID, deviceID)
|
|
if err != nil {
|
|
s.writeStoreError(writer, request, err, false)
|
|
return
|
|
}
|
|
writer.Header().Set("ETag", store.DeviceETag(value.ID, value.ResourceVersion))
|
|
writeJSON(writer, http.StatusOK, traceFromContext(request.Context()), publicDevice(value))
|
|
}
|
|
|
|
func (s *Server) UpdateDevice(
|
|
writer http.ResponseWriter, request *http.Request, siteID SiteID, deviceID DeviceID,
|
|
params UpdateDeviceParams,
|
|
) {
|
|
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !validLogicalID(deviceID) || !validStrongETag(params.IfMatch) {
|
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "device_id or If-Match is invalid")
|
|
return
|
|
}
|
|
contents, err := readRequestBody(request, "application/merge-patch+json")
|
|
if err != nil {
|
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
|
return
|
|
}
|
|
var fields map[string]json.RawMessage
|
|
if err := json.Unmarshal(contents, &fields); err != nil || len(fields) == 0 {
|
|
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "patch must contain a field")
|
|
return
|
|
}
|
|
for _, value := range fields {
|
|
if string(value) == "null" {
|
|
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "patch fields cannot be null")
|
|
return
|
|
}
|
|
}
|
|
var body DevicePatch
|
|
if err := decodeStrictJSON(contents, &body); err != nil {
|
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
|
return
|
|
}
|
|
if code, message := validateDevicePatch(body); code != "" {
|
|
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCode(code), message)
|
|
return
|
|
}
|
|
patch := store.ControlPatch{
|
|
Name: body.Name, EndpointRef: body.EndpointRef,
|
|
CredentialRef: body.CredentialRef, ProfileToken: body.ProfileToken,
|
|
}
|
|
if body.AreaId != nil {
|
|
value := string(*body.AreaId)
|
|
patch.AreaID = &value
|
|
}
|
|
traceID := traceFromContext(request.Context())
|
|
ctx := store.WithAuditContext(request.Context(), auditContext(principal, "", traceID))
|
|
result, err := s.store.PatchControlDevice(
|
|
ctx, principal.TenantID, siteID, deviceID, params.IfMatch, patch,
|
|
)
|
|
if err != nil {
|
|
s.writeStoreError(writer, request, err, true)
|
|
return
|
|
}
|
|
writer.Header().Set("ETag", result.ETag)
|
|
writeJSON(writer, http.StatusAccepted, result.TraceID, MutationReceipt{
|
|
Device: publicDevice(result.Device), AcceptedAt: result.AcceptedAt, TraceId: result.TraceID,
|
|
})
|
|
}
|
|
|
|
func validateDevicePatch(body DevicePatch) (string, string) {
|
|
if body.Name != nil && (!validLength(*body.Name, 1, 200) || strings.TrimSpace(*body.Name) == "") {
|
|
return "invalid_request", "name is invalid"
|
|
}
|
|
if body.AreaId != nil && !validLogicalID(*body.AreaId) {
|
|
return "invalid_request", "area_id is invalid"
|
|
}
|
|
if body.EndpointRef != nil && !validateEndpoint(*body.EndpointRef) {
|
|
return "endpoint_credentials_forbidden", "endpoint reference is invalid"
|
|
}
|
|
if body.CredentialRef != nil && (!validLength(*body.CredentialRef, 1, 512) || strings.TrimSpace(*body.CredentialRef) == "") {
|
|
return "endpoint_credentials_forbidden", "credential reference is invalid"
|
|
}
|
|
if body.ProfileToken != nil && !validLength(*body.ProfileToken, 1, 256) {
|
|
return "invalid_request", "profile token is invalid"
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
func (s *Server) SetDeviceDesiredState(
|
|
writer http.ResponseWriter, request *http.Request, siteID SiteID, deviceID DeviceID,
|
|
params SetDeviceDesiredStateParams,
|
|
) {
|
|
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !validLogicalID(deviceID) || !validStrongETag(params.IfMatch) {
|
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "device_id or If-Match is invalid")
|
|
return
|
|
}
|
|
contents, err := readRequestBody(request, "application/json")
|
|
if err != nil {
|
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
|
return
|
|
}
|
|
var body DesiredStateChange
|
|
if err := decodeStrictJSON(contents, &body); err != nil {
|
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
|
return
|
|
}
|
|
if !body.DesiredState.Valid() || !validLength(body.Reason, 1, 500) || strings.TrimSpace(body.Reason) == "" {
|
|
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "desired_state or reason is invalid")
|
|
return
|
|
}
|
|
traceID := traceFromContext(request.Context())
|
|
ctx := store.WithAuditContext(request.Context(), auditContext(principal, body.Reason, traceID))
|
|
result, err := s.store.SetControlDesiredState(
|
|
ctx, principal.TenantID, siteID, deviceID, params.IfMatch,
|
|
device.DesiredState(body.DesiredState),
|
|
)
|
|
if err != nil {
|
|
s.writeStoreError(writer, request, err, true)
|
|
return
|
|
}
|
|
writer.Header().Set("ETag", result.ETag)
|
|
writeJSON(writer, http.StatusAccepted, result.TraceID, MutationReceipt{
|
|
Device: publicDevice(result.Device), AcceptedAt: result.AcceptedAt, TraceId: result.TraceID,
|
|
})
|
|
}
|
|
|
|
func (s *Server) BatchSetDeviceDesiredState(
|
|
writer http.ResponseWriter, request *http.Request, siteID SiteID,
|
|
params BatchSetDeviceDesiredStateParams,
|
|
) {
|
|
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !idempotencyKeyRegex.MatchString(params.IdempotencyKey) {
|
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "Idempotency-Key is invalid")
|
|
return
|
|
}
|
|
contents, err := readRequestBody(request, "application/json")
|
|
if err != nil {
|
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
|
return
|
|
}
|
|
var body BatchDesiredStateRequest
|
|
if err := decodeStrictJSON(contents, &body); err != nil {
|
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
|
return
|
|
}
|
|
if len(body.Items) > 128 {
|
|
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeBatchTooLarge, "batch contains more than 128 items")
|
|
return
|
|
}
|
|
if len(body.Items) == 0 || !validLength(body.Reason, 1, 500) || strings.TrimSpace(body.Reason) == "" {
|
|
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "batch items or reason is invalid")
|
|
return
|
|
}
|
|
items := make([]store.ControlBatchItem, 0, len(body.Items))
|
|
for _, item := range body.Items {
|
|
if !validLogicalID(item.DeviceId) || !validStrongETag(item.Etag) || !item.DesiredState.Valid() {
|
|
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "batch item is invalid")
|
|
return
|
|
}
|
|
items = append(items, store.ControlBatchItem{
|
|
DeviceID: item.DeviceId, ETag: item.Etag,
|
|
DesiredState: device.DesiredState(item.DesiredState),
|
|
})
|
|
}
|
|
canonical, _ := json.Marshal(body)
|
|
requestHash := sha256.Sum256(canonical)
|
|
traceID := traceFromContext(request.Context())
|
|
ctx := store.WithAuditContext(request.Context(), auditContext(principal, body.Reason, traceID))
|
|
operation, err := s.store.BatchSetControlDesiredState(ctx, store.ControlBatchRequest{
|
|
Scope: store.IdempotencyScope{
|
|
PrincipalID: principal.SubjectID, TenantID: principal.TenantID, SiteID: siteID,
|
|
Operation: "batchSetDeviceDesiredState", Key: params.IdempotencyKey,
|
|
RequestHash: requestHash, TraceID: traceID,
|
|
},
|
|
Reason: body.Reason, Items: items,
|
|
})
|
|
if err != nil {
|
|
s.writeStoreError(writer, request, err, true)
|
|
return
|
|
}
|
|
writer.Header().Set("Location", "/api/v1/operations/"+operation.ID)
|
|
writeJSON(writer, http.StatusAccepted, operation.TraceID, publicOperation(operation))
|
|
}
|
|
|
|
func (s *Server) GetOperation(
|
|
writer http.ResponseWriter, request *http.Request, operationID OperationID,
|
|
) {
|
|
principal := principalFromContext(request.Context())
|
|
if !principal.Has(auth.PermissionDevicesRead) {
|
|
s.writeProblem(writer, request, http.StatusForbidden, ErrorCodeForbidden, "permission is required")
|
|
return
|
|
}
|
|
if !operationIDRegex.MatchString(operationID) {
|
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "operation_id is invalid")
|
|
return
|
|
}
|
|
operation, err := s.store.GetControlOperation(request.Context(), principal.TenantID, operationID)
|
|
if err != nil {
|
|
s.writeStoreError(writer, request, err, false)
|
|
return
|
|
}
|
|
if !principal.AllowsSite(operation.SiteID) {
|
|
s.writeProblem(writer, request, http.StatusNotFound, ErrorCodeNotFound, "resource was not found")
|
|
return
|
|
}
|
|
writeJSON(writer, http.StatusOK, traceFromContext(request.Context()), publicOperation(operation))
|
|
}
|
|
|
|
func (s *Server) writeStoreError(
|
|
writer http.ResponseWriter, request *http.Request, err error, mutation bool,
|
|
) {
|
|
status, code, message := http.StatusInternalServerError, ErrorCodeInternalError, "request could not be completed"
|
|
switch {
|
|
case errors.Is(err, store.ErrNotFound):
|
|
status, code, message = http.StatusNotFound, ErrorCodeNotFound, "resource was not found"
|
|
case errors.Is(err, store.ErrETagMismatch):
|
|
status, code, message = http.StatusPreconditionFailed, ErrorCodeEtagMismatch, "device ETag does not match"
|
|
case errors.Is(err, store.ErrIdempotencyConflict):
|
|
status, code, message = http.StatusConflict, ErrorCodeIdempotencyConflict, "Idempotency-Key was used with another request"
|
|
case errors.Is(err, store.ErrDuplicateSerialNumber):
|
|
status, code, message = http.StatusConflict, ErrorCodeDuplicateSerialNumber, "serial_number already exists in this site"
|
|
case errors.Is(err, store.ErrAreaPolicyDenied):
|
|
status, code, message = http.StatusUnprocessableEntity, ErrorCodeAreaPolicyDenied, "Area policy denies this change"
|
|
case errors.Is(err, store.ErrAreaPolicyUnavailable), errors.Is(err, store.ErrAreaPolicyInvalid):
|
|
status, code, message = http.StatusServiceUnavailable, ErrorCodeAreaPolicyUnavailable, "Area policy is unavailable"
|
|
case errors.Is(err, store.ErrQuotaProjectionUnavailable):
|
|
status, code, message = http.StatusServiceUnavailable, ErrorCodeQuotaProjectionUnavailable, "Site quota is unavailable"
|
|
case errors.Is(err, store.ErrQuotaProjectionInvalid):
|
|
status, code, message = http.StatusServiceUnavailable, ErrorCodeQuotaProjectionInvalid, "Site quota is invalid"
|
|
default:
|
|
var quotaError *device.QuotaExceededError
|
|
if errors.As(err, "aError) {
|
|
status, code, message = http.StatusConflict, ErrorCodeQuotaExceeded, "Site video channel quota is exceeded"
|
|
} else if mutation {
|
|
status, code, message = http.StatusServiceUnavailable, ErrorCodeServiceUnavailable, "device change could not be accepted"
|
|
}
|
|
}
|
|
s.writeProblem(writer, request, status, code, message)
|
|
}
|
|
|
|
var _ ServerInterface = (*Server)(nil)
|