From 05ca44acfbbdbce16ac099d23d631137d6a4fba3 Mon Sep 17 00:00:00 2001 From: JeffMboya Date: Wed, 8 Apr 2026 09:58:58 +0300 Subject: [PATCH 1/2] feat(sdf): add SDF endpoint for proplet description Adds pkg/sdf with Document types and PropletDocument builder, wires GET /proplets/{id}/sdf through the full manager stack (endpoint, response, transport, service, middleware, mock), and exposes GetPropletSDF on the SDK interface. --- manager/api/endpoint.go | 19 +++++ manager/api/responses.go | 18 +++++ manager/api/transport.go | 6 ++ manager/manager.go | 2 + manager/middleware/logging.go | 21 +++++ manager/middleware/metrics.go | 10 +++ manager/middleware/tracing.go | 10 +++ manager/mocks/service.go | 59 ++++++++++++++ manager/service.go | 10 +++ pkg/sdf/proplet.go | 140 +++++++++++++++++++++++++++++++++ pkg/sdf/proplet_test.go | 141 ++++++++++++++++++++++++++++++++++ pkg/sdf/sdf.go | 72 +++++++++++++++++ pkg/sdk/proplet.go | 23 +++++- pkg/sdk/sdk.go | 9 +++ 14 files changed, 539 insertions(+), 1 deletion(-) create mode 100644 pkg/sdf/proplet.go create mode 100644 pkg/sdf/proplet_test.go create mode 100644 pkg/sdf/sdf.go diff --git a/manager/api/endpoint.go b/manager/api/endpoint.go index 3f68a577..04bb5783 100644 --- a/manager/api/endpoint.go +++ b/manager/api/endpoint.go @@ -52,6 +52,25 @@ func getPropletEndpoint(svc manager.Service) endpoint.Endpoint { } } +func getPropletSDFEndpoint(svc manager.Service) endpoint.Endpoint { + return func(ctx context.Context, request any) (any, error) { + req, ok := request.(entityReq) + if !ok { + return propletSDFResponse{}, errors.Join(apiutil.ErrValidation, pkgerrors.ErrInvalidData) + } + if err := req.validate(); err != nil { + return propletSDFResponse{}, errors.Join(apiutil.ErrValidation, err) + } + + doc, err := svc.GetPropletSDF(ctx, req.id) + if err != nil { + return propletSDFResponse{}, err + } + + return propletSDFResponse{Document: doc}, nil + } +} + func deletePropletEndpoint(svc manager.Service) endpoint.Endpoint { return func(ctx context.Context, request any) (any, error) { req, ok := request.(entityReq) diff --git a/manager/api/responses.go b/manager/api/responses.go index 536aea9c..8284c328 100644 --- a/manager/api/responses.go +++ b/manager/api/responses.go @@ -5,12 +5,14 @@ import ( "github.com/absmach/propeller/manager" "github.com/absmach/propeller/pkg/proplet" + "github.com/absmach/propeller/pkg/sdf" "github.com/absmach/propeller/pkg/task" "github.com/absmach/supermq" ) var ( _ supermq.Response = (*propletResponse)(nil) + _ supermq.Response = (*propletSDFResponse)(nil) _ supermq.Response = (*listpropletResponse)(nil) _ supermq.Response = (*taskResponse)(nil) _ supermq.Response = (*listTaskResponse)(nil) @@ -55,6 +57,22 @@ func (w propletResponse) Empty() bool { return w.deleted } +type propletSDFResponse struct { + sdf.Document +} + +func (r propletSDFResponse) Code() int { + return http.StatusOK +} + +func (r propletSDFResponse) Headers() map[string]string { + return map[string]string{} +} + +func (r propletSDFResponse) Empty() bool { + return false +} + type listpropletResponse struct { proplet.PropletPage } diff --git a/manager/api/transport.go b/manager/api/transport.go index faf0e260..28724e22 100644 --- a/manager/api/transport.go +++ b/manager/api/transport.go @@ -51,6 +51,12 @@ func MakeHandler(svc manager.Service, logger *slog.Logger, instanceID string) ht api.EncodeResponse, opts..., ), "delete-proplet").ServeHTTP) + r.Get("/sdf", otelhttp.NewHandler(kithttp.NewServer( + getPropletSDFEndpoint(svc), + decodeEntityReq("propletID"), + api.EncodeResponse, + opts..., + ), "get-proplet-sdf").ServeHTTP) r.Get("/metrics", otelhttp.NewHandler(kithttp.NewServer( getPropletMetricsEndpoint(svc), decodeMetricsReq("propletID"), diff --git a/manager/manager.go b/manager/manager.go index e38fc7af..ee864e34 100644 --- a/manager/manager.go +++ b/manager/manager.go @@ -4,11 +4,13 @@ import ( "context" "github.com/absmach/propeller/pkg/proplet" + "github.com/absmach/propeller/pkg/sdf" "github.com/absmach/propeller/pkg/task" ) type Service interface { GetProplet(ctx context.Context, propletID string) (proplet.Proplet, error) + GetPropletSDF(ctx context.Context, propletID string) (sdf.Document, error) ListProplets(ctx context.Context, offset, limit uint64) (proplet.PropletPage, error) SelectProplet(ctx context.Context, task task.Task) (proplet.Proplet, error) DeleteProplet(ctx context.Context, propletID string) error diff --git a/manager/middleware/logging.go b/manager/middleware/logging.go index 9ef334a3..1213d5f5 100644 --- a/manager/middleware/logging.go +++ b/manager/middleware/logging.go @@ -7,6 +7,7 @@ import ( "github.com/absmach/propeller/manager" "github.com/absmach/propeller/pkg/proplet" + "github.com/absmach/propeller/pkg/sdf" "github.com/absmach/propeller/pkg/task" ) @@ -42,6 +43,26 @@ func (lm *loggingMiddleware) GetProplet(ctx context.Context, id string) (resp pr return lm.svc.GetProplet(ctx, id) } +func (lm *loggingMiddleware) GetPropletSDF(ctx context.Context, id string) (resp sdf.Document, err error) { + defer func(begin time.Time) { + args := []any{ + slog.String("duration", time.Since(begin).String()), + slog.Group("proplet", + slog.String("id", id), + ), + } + if err != nil { + args = append(args, slog.Any("error", err)) + lm.logger.Warn("Get proplet SDF failed", args...) + + return + } + lm.logger.Info("Get proplet SDF completed successfully", args...) + }(time.Now()) + + return lm.svc.GetPropletSDF(ctx, id) +} + func (lm *loggingMiddleware) ListProplets(ctx context.Context, offset, limit uint64) (resp proplet.PropletPage, err error) { defer func(begin time.Time) { args := []any{ diff --git a/manager/middleware/metrics.go b/manager/middleware/metrics.go index c4ae24db..a3fe8dc3 100644 --- a/manager/middleware/metrics.go +++ b/manager/middleware/metrics.go @@ -6,6 +6,7 @@ import ( "github.com/absmach/propeller/manager" "github.com/absmach/propeller/pkg/proplet" + "github.com/absmach/propeller/pkg/sdf" "github.com/absmach/propeller/pkg/task" "github.com/go-kit/kit/metrics" ) @@ -35,6 +36,15 @@ func (mm *metricsMiddleware) GetProplet(ctx context.Context, id string) (proplet return mm.svc.GetProplet(ctx, id) } +func (mm *metricsMiddleware) GetPropletSDF(ctx context.Context, id string) (sdf.Document, error) { + defer func(begin time.Time) { + mm.counter.With("method", "get-proplet-sdf").Add(1) + mm.latency.With("method", "get-proplet-sdf").Observe(time.Since(begin).Seconds()) + }(time.Now()) + + return mm.svc.GetPropletSDF(ctx, id) +} + func (mm *metricsMiddleware) ListProplets(ctx context.Context, offset, limit uint64) (proplet.PropletPage, error) { defer func(begin time.Time) { mm.counter.With("method", "list-proplets").Add(1) diff --git a/manager/middleware/tracing.go b/manager/middleware/tracing.go index 3000a4f0..93d99009 100644 --- a/manager/middleware/tracing.go +++ b/manager/middleware/tracing.go @@ -5,6 +5,7 @@ import ( "github.com/absmach/propeller/manager" "github.com/absmach/propeller/pkg/proplet" + "github.com/absmach/propeller/pkg/sdf" "github.com/absmach/propeller/pkg/task" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -30,6 +31,15 @@ func (tm *tracing) GetProplet(ctx context.Context, id string) (resp proplet.Prop return tm.svc.GetProplet(ctx, id) } +func (tm *tracing) GetPropletSDF(ctx context.Context, id string) (resp sdf.Document, err error) { + ctx, span := tm.tracer.Start(ctx, "get-proplet-sdf", trace.WithAttributes( + attribute.String("id", id), + )) + defer span.End() + + return tm.svc.GetPropletSDF(ctx, id) +} + func (tm *tracing) ListProplets(ctx context.Context, offset, limit uint64) (resp proplet.PropletPage, err error) { ctx, span := tm.tracer.Start(ctx, "list-proplets", trace.WithAttributes( attribute.Int64("offset", int64(offset)), diff --git a/manager/mocks/service.go b/manager/mocks/service.go index 77f39d3f..84cf6255 100644 --- a/manager/mocks/service.go +++ b/manager/mocks/service.go @@ -9,6 +9,7 @@ import ( "github.com/absmach/propeller/manager" "github.com/absmach/propeller/pkg/proplet" + "github.com/absmach/propeller/pkg/sdf" "github.com/absmach/propeller/pkg/task" mock "github.com/stretchr/testify/mock" ) @@ -705,6 +706,64 @@ func (_c *MockService_GetProplet_Call) RunAndReturn(run func(ctx context.Context return _c } +func (_mock *MockService) GetPropletSDF(ctx context.Context, propletID string) (sdf.Document, error) { + ret := _mock.Called(ctx, propletID) + + if len(ret) == 0 { + panic("no return value specified for GetPropletSDF") + } + + var r0 sdf.Document + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (sdf.Document, error)); ok { + return returnFunc(ctx, propletID) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) sdf.Document); ok { + r0 = returnFunc(ctx, propletID) + } else { + r0 = ret.Get(0).(sdf.Document) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, propletID) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +type MockService_GetPropletSDF_Call struct { + *mock.Call +} + +func (_e *MockService_Expecter) GetPropletSDF(ctx interface{}, propletID interface{}) *MockService_GetPropletSDF_Call { + return &MockService_GetPropletSDF_Call{Call: _e.mock.On("GetPropletSDF", ctx, propletID)} +} + +func (_c *MockService_GetPropletSDF_Call) Run(run func(ctx context.Context, propletID string)) *MockService_GetPropletSDF_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run(arg0, arg1) + }) + return _c +} + +func (_c *MockService_GetPropletSDF_Call) Return(doc sdf.Document, err error) *MockService_GetPropletSDF_Call { + _c.Call.Return(doc, err) + return _c +} + +func (_c *MockService_GetPropletSDF_Call) RunAndReturn(run func(ctx context.Context, propletID string) (sdf.Document, error)) *MockService_GetPropletSDF_Call { + _c.Call.Return(run) + return _c +} + // GetPropletMetrics provides a mock function for the type MockService func (_mock *MockService) GetPropletMetrics(ctx context.Context, propletID string, offset uint64, limit uint64) (manager.PropletMetricsPage, error) { ret := _mock.Called(ctx, propletID, offset, limit) diff --git a/manager/service.go b/manager/service.go index c06d40f1..d9049a65 100644 --- a/manager/service.go +++ b/manager/service.go @@ -23,6 +23,7 @@ import ( "github.com/absmach/propeller/pkg/mqtt" "github.com/absmach/propeller/pkg/proplet" "github.com/absmach/propeller/pkg/scheduler" + "github.com/absmach/propeller/pkg/sdf" "github.com/absmach/propeller/pkg/storage" "github.com/absmach/propeller/pkg/task" "github.com/google/uuid" @@ -112,6 +113,15 @@ func (svc *service) GetProplet(ctx context.Context, propletID string) (proplet.P return w, nil } +func (svc *service) GetPropletSDF(ctx context.Context, propletID string) (sdf.Document, error) { + p, err := svc.GetProplet(ctx, propletID) + if err != nil { + return sdf.Document{}, err + } + + return sdf.PropletDocument(p), nil +} + func (svc *service) ListProplets(ctx context.Context, offset, limit uint64) (proplet.PropletPage, error) { proplets, total, err := svc.propletRepo.List(ctx, offset, limit) if err != nil { diff --git a/pkg/sdf/proplet.go b/pkg/sdf/proplet.go new file mode 100644 index 00000000..3a18ae5b --- /dev/null +++ b/pkg/sdf/proplet.go @@ -0,0 +1,140 @@ +package sdf + +import "github.com/absmach/propeller/pkg/proplet" + +func PropletDocument(p proplet.Proplet) Document { + return Document{ + Info: Info{ + Title: "Propeller Proplet: " + p.Name + " (" + p.ID + ")", + Version: "1.0", + License: "Apache-2.0", + }, + SdfThing: map[string]ThingAfford{ + "Proplet": { + Description: "A Propeller proplet — an edge node that executes WebAssembly tasks", + SdfProperty: map[string]PropertyAfford{ + "id": { + DataAfford: DataAfford{ + Description: "Unique proplet identifier", + Type: "string", + ReadOnly: true, + }, + }, + "name": { + DataAfford: DataAfford{ + Description: "Human-readable proplet name", + Type: "string", + }, + }, + "alive": { + DataAfford: DataAfford{ + Description: "Whether the proplet sent a heartbeat within the liveness window", + Type: "boolean", + ReadOnly: true, + }, + Observable: true, + }, + "task_count": { + DataAfford: DataAfford{ + Description: "Number of tasks currently running on this proplet", + Type: "integer", + ReadOnly: true, + Minimum: minPtr(), + }, + Observable: true, + }, + "metadata": { + DataAfford: DataAfford{ + SdfRef: "#/sdfThing/Proplet/sdfData/PropletMetadata", + }, + }, + }, + SdfAction: map[string]ActionAfford{ + "start_task": { + Description: "Dispatch a WebAssembly task to this proplet", + SdfInputData: &DataAfford{ + SdfRef: "#/sdfThing/Proplet/sdfData/TaskDispatch", + }, + }, + "stop_task": { + Description: "Stop a running task on this proplet", + SdfInputData: &DataAfford{ + Description: "Identifier of the task to stop", + Type: "string", + }, + }, + }, + SdfEvent: map[string]EventAfford{ + "heartbeat": { + Description: "Periodic liveness signal from the proplet", + SdfOutputData: &DataAfford{ + SdfRef: "#/sdfThing/Proplet/sdfData/HeartbeatPayload", + }, + }, + "task_result": { + Description: "Emitted when a task finishes (success or failure)", + SdfOutputData: &DataAfford{ + SdfRef: "#/sdfThing/Proplet/sdfData/TaskResult", + }, + }, + }, + SdfData: map[string]DataAfford{ + "PropletMetadata": { + Description: "Static metadata reported by the proplet at registration", + Type: "object", + Properties: map[string]DataAfford{ + "description": {Type: "string"}, + "tags": {Type: "array", Items: &DataAfford{Type: "string"}}, + "location": {Type: "string"}, + "ip": {Type: "string"}, + "environment": {Type: "string"}, + "os": {Type: "string"}, + "hostname": {Type: "string"}, + "cpu_arch": {Type: "string"}, + "total_memory_bytes": {Type: "integer", Minimum: minPtr()}, + "proplet_version": {Type: "string"}, + "wasm_runtime": {Type: "string"}, + }, + }, + "TaskDispatch": { + Description: "Payload used to dispatch a task to the proplet", + Type: "object", + Required: []string{"id", "name"}, + Properties: map[string]DataAfford{ + "id": {Type: "string"}, + "name": {Type: "string"}, + "image_url": {Type: "string"}, + "cli_args": {Type: "array", Items: &DataAfford{Type: "string"}}, + "inputs": {Type: "array", Items: &DataAfford{Type: "string"}}, + "env": {Type: "object", AdditionalProperties: &DataAfford{Type: "string"}}, + "daemon": {Type: "boolean"}, + "encrypted": {Type: "boolean"}, + "kbs_resource_path": {Type: "string"}, + }, + }, + "HeartbeatPayload": { + Description: "Payload of a proplet heartbeat event", + Type: "object", + Properties: map[string]DataAfford{ + "proplet_id": {Type: "string"}, + "name": {Type: "string"}, + "task_count": {Type: "integer", Minimum: minPtr()}, + "alive": {Type: "boolean"}, + }, + }, + "TaskResult": { + Description: "Result payload emitted after a task completes", + Type: "object", + Properties: map[string]DataAfford{ + "task_id": {Type: "string"}, + "proplet_id": {Type: "string"}, + "state": {Type: "string", Enum: []any{"Completed", "Failed", "Skipped", "Interrupted"}}, + "results": {Description: "Arbitrary task output; schema varies by task"}, + "error": {Type: "string"}, + }, + }, + }, + }, + }, + } +} diff --git a/pkg/sdf/proplet_test.go b/pkg/sdf/proplet_test.go new file mode 100644 index 00000000..1ada5c84 --- /dev/null +++ b/pkg/sdf/proplet_test.go @@ -0,0 +1,141 @@ +package sdf_test + +import ( + "strings" + "testing" + + "github.com/absmach/propeller/pkg/proplet" + "github.com/absmach/propeller/pkg/sdf" + "github.com/stretchr/testify/assert" +) + +func TestPropletDocument(t *testing.T) { + cases := []struct { + desc string + proplet proplet.Proplet + wantTitleHasID bool + wantProperties []string + wantActions []string + wantEvents []string + wantSdfDataKeys []string + }{ + { + desc: "full proplet with name and id", + proplet: proplet.Proplet{ID: "abc-123", Name: "my-proplet"}, + wantTitleHasID: true, + wantProperties: []string{"id", "name", "alive", "task_count", "metadata"}, + wantActions: []string{"start_task", "stop_task"}, + wantEvents: []string{"heartbeat", "task_result"}, + wantSdfDataKeys: []string{"PropletMetadata", "TaskDispatch", "HeartbeatPayload", "TaskResult"}, + }, + { + desc: "proplet with empty name", + proplet: proplet.Proplet{ID: "xyz-456", Name: ""}, + wantTitleHasID: true, + wantProperties: []string{"id", "name", "alive", "task_count", "metadata"}, + wantActions: []string{"start_task", "stop_task"}, + wantEvents: []string{"heartbeat", "task_result"}, + wantSdfDataKeys: []string{"PropletMetadata", "TaskDispatch", "HeartbeatPayload", "TaskResult"}, + }, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + doc := sdf.PropletDocument(tc.proplet) + + assert.NotEmpty(t, doc.Info.Title) + assert.NotEmpty(t, doc.Info.Version) + + if tc.wantTitleHasID { + assert.True(t, strings.Contains(doc.Info.Title, tc.proplet.ID), "expected title to contain proplet ID %q, got %q", tc.proplet.ID, doc.Info.Title) + } + + thing, ok := doc.SdfThing["Proplet"] + assert.True(t, ok, "expected SdfThing['Proplet'] to exist") + + for _, key := range tc.wantProperties { + _, ok := thing.SdfProperty[key] + assert.True(t, ok, "expected sdfProperty[%q] to exist", key) + } + + for _, key := range tc.wantActions { + _, ok := thing.SdfAction[key] + assert.True(t, ok, "expected sdfAction[%q] to exist", key) + } + + for _, key := range tc.wantEvents { + _, ok := thing.SdfEvent[key] + assert.True(t, ok, "expected sdfEvent[%q] to exist", key) + } + + for _, key := range tc.wantSdfDataKeys { + _, ok := thing.SdfData[key] + assert.True(t, ok, "expected sdfData[%q] to exist", key) + } + }) + } +} + +func TestPropletDocumentTaskResultStateEnum(t *testing.T) { + cases := []struct { + desc string + proplet proplet.Proplet + wantStates []string + }{ + { + desc: "task result state enum contains all expected values", + proplet: proplet.Proplet{ID: "abc-123", Name: "my-proplet"}, + wantStates: []string{"Completed", "Failed", "Skipped", "Interrupted"}, + }, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + doc := sdf.PropletDocument(tc.proplet) + thing := doc.SdfThing["Proplet"] + + result, ok := thing.SdfData["TaskResult"] + assert.True(t, ok, "expected sdfData['TaskResult'] to exist") + + stateAfford, ok := result.Properties["state"] + assert.True(t, ok, "expected TaskResult.properties.state to exist") + + enumSet := make(map[string]bool) + for _, v := range stateAfford.Enum { + s, ok := v.(string) + assert.True(t, ok, "expected state enum value to be string, got %T", v) + enumSet[s] = true + } + + for _, s := range tc.wantStates { + assert.True(t, enumSet[s], "missing state enum value %q", s) + } + }) + } +} + +func TestPropletDocumentMinimumPointerNotAliased(t *testing.T) { + cases := []struct { + desc string + proplet proplet.Proplet + }{ + { + desc: "minimum pointers are not aliased across fields", + proplet: proplet.Proplet{ID: "x", Name: "n"}, + }, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + doc := sdf.PropletDocument(tc.proplet) + thing := doc.SdfThing["Proplet"] + + taskCount := thing.SdfProperty["task_count"] + heartbeat := thing.SdfData["HeartbeatPayload"] + + assert.NotNil(t, taskCount.Minimum) + assert.NotNil(t, heartbeat.Properties["task_count"].Minimum) + assert.NotSame(t, taskCount.Minimum, heartbeat.Properties["task_count"].Minimum, "Minimum pointers must not be aliased") + }) + } +} diff --git a/pkg/sdf/sdf.go b/pkg/sdf/sdf.go new file mode 100644 index 00000000..60f26f84 --- /dev/null +++ b/pkg/sdf/sdf.go @@ -0,0 +1,72 @@ +package sdf + +type Document struct { + Info Info `json:"info"` + Namespace map[string]string `json:"namespace,omitempty"` + DefaultNamespace string `json:"defaultNamespace,omitempty"` + SdfProperty map[string]PropertyAfford `json:"sdfProperty,omitempty"` + SdfAction map[string]ActionAfford `json:"sdfAction,omitempty"` + SdfEvent map[string]EventAfford `json:"sdfEvent,omitempty"` + SdfObject map[string]ObjectAfford `json:"sdfObject,omitempty"` + SdfThing map[string]ThingAfford `json:"sdfThing,omitempty"` + SdfData map[string]DataAfford `json:"sdfData,omitempty"` +} + +type Info struct { + Title string `json:"title"` + Version string `json:"version"` + Copyright string `json:"copyright,omitempty"` + License string `json:"license,omitempty"` +} + +type DataAfford struct { + Description string `json:"description,omitempty"` + Type string `json:"type,omitempty"` + SdfRef string `json:"sdfRef,omitempty"` + Properties map[string]DataAfford `json:"properties,omitempty"` + AdditionalProperties *DataAfford `json:"additionalProperties,omitempty"` + Items *DataAfford `json:"items,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` + Minimum *float64 `json:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + Enum []any `json:"enum,omitempty"` + Required []string `json:"required,omitempty"` +} + +type PropertyAfford struct { + DataAfford + Observable bool `json:"observable,omitempty"` +} + +type ActionAfford struct { + Description string `json:"description,omitempty"` + SdfInputData *DataAfford `json:"sdfInputData,omitempty"` + SdfOutputData *DataAfford `json:"sdfOutputData,omitempty"` +} + +type EventAfford struct { + Description string `json:"description,omitempty"` + SdfOutputData *DataAfford `json:"sdfOutputData,omitempty"` +} + +type ObjectAfford struct { + Description string `json:"description,omitempty"` + SdfProperty map[string]PropertyAfford `json:"sdfProperty,omitempty"` + SdfAction map[string]ActionAfford `json:"sdfAction,omitempty"` + SdfEvent map[string]EventAfford `json:"sdfEvent,omitempty"` + SdfData map[string]DataAfford `json:"sdfData,omitempty"` +} + +type ThingAfford struct { + Description string `json:"description,omitempty"` + SdfProperty map[string]PropertyAfford `json:"sdfProperty,omitempty"` + SdfAction map[string]ActionAfford `json:"sdfAction,omitempty"` + SdfEvent map[string]EventAfford `json:"sdfEvent,omitempty"` + SdfObject map[string]ObjectAfford `json:"sdfObject,omitempty"` + SdfData map[string]DataAfford `json:"sdfData,omitempty"` +} + +func minPtr() *float64 { + v := float64(0) + return &v +} diff --git a/pkg/sdk/proplet.go b/pkg/sdk/proplet.go index 19c68361..9e5ae295 100644 --- a/pkg/sdk/proplet.go +++ b/pkg/sdk/proplet.go @@ -1,9 +1,30 @@ package sdk -import "net/http" +import ( + "encoding/json" + "net/http" + + "github.com/absmach/propeller/pkg/sdf" +) const propletsEndpoint = "/proplets" +func (sdk *propSDK) GetPropletSDF(id string) (sdf.Document, error) { + url := sdk.managerURL + propletsEndpoint + "/" + id + "/sdf" + + body, err := sdk.processRequest(http.MethodGet, url, nil, http.StatusOK) + if err != nil { + return sdf.Document{}, err + } + + var doc sdf.Document + if err := json.Unmarshal(body, &doc); err != nil { + return sdf.Document{}, err + } + + return doc, nil +} + func (sdk *propSDK) DeleteProplet(id string) error { url := sdk.managerURL + propletsEndpoint + "/" + id diff --git a/pkg/sdk/sdk.go b/pkg/sdk/sdk.go index c3cb6b28..8603e4e0 100644 --- a/pkg/sdk/sdk.go +++ b/pkg/sdk/sdk.go @@ -6,6 +6,8 @@ import ( "fmt" "io" "net/http" + + "github.com/absmach/propeller/pkg/sdf" ) const CTJSON string = "application/json" @@ -106,6 +108,13 @@ type SDK interface { // _ := sdk.StopJob("b1d10738-c5d7-4ff1-8f4d-b9328ce6f040") StopJob(jobID string) error + // GetPropletSDF returns the SDF description of a proplet. + // + // example: + // doc, _ := sdk.GetPropletSDF("b1d10738-c5d7-4ff1-8f4d-b9328ce6f040") + // fmt.Println(doc) + GetPropletSDF(id string) (sdf.Document, error) + // DeleteProplet deletes a proplet by id. // // example: From 2391b6f4a16465ec11aa7b8f99b26d5208421134 Mon Sep 17 00:00:00 2001 From: JeffMboya Date: Wed, 8 Apr 2026 11:37:09 +0300 Subject: [PATCH 2/2] fix(sdf): resolve linter errors in sdf package and tests --- pkg/sdf/proplet_test.go | 45 +++++++++++++++++++++++++---------------- pkg/sdf/sdf.go | 2 ++ 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/pkg/sdf/proplet_test.go b/pkg/sdf/proplet_test.go index 1ada5c84..4b5184a4 100644 --- a/pkg/sdf/proplet_test.go +++ b/pkg/sdf/proplet_test.go @@ -1,7 +1,6 @@ package sdf_test import ( - "strings" "testing" "github.com/absmach/propeller/pkg/proplet" @@ -10,6 +9,8 @@ import ( ) func TestPropletDocument(t *testing.T) { + t.Parallel() + cases := []struct { desc string proplet proplet.Proplet @@ -20,34 +21,36 @@ func TestPropletDocument(t *testing.T) { wantSdfDataKeys []string }{ { - desc: "full proplet with name and id", - proplet: proplet.Proplet{ID: "abc-123", Name: "my-proplet"}, - wantTitleHasID: true, - wantProperties: []string{"id", "name", "alive", "task_count", "metadata"}, - wantActions: []string{"start_task", "stop_task"}, - wantEvents: []string{"heartbeat", "task_result"}, + desc: "full proplet with name and id", + proplet: proplet.Proplet{ID: "abc-123", Name: "my-proplet"}, + wantTitleHasID: true, + wantProperties: []string{"id", "name", "alive", "task_count", "metadata"}, + wantActions: []string{"start_task", "stop_task"}, + wantEvents: []string{"heartbeat", "task_result"}, wantSdfDataKeys: []string{"PropletMetadata", "TaskDispatch", "HeartbeatPayload", "TaskResult"}, }, { - desc: "proplet with empty name", - proplet: proplet.Proplet{ID: "xyz-456", Name: ""}, - wantTitleHasID: true, - wantProperties: []string{"id", "name", "alive", "task_count", "metadata"}, - wantActions: []string{"start_task", "stop_task"}, - wantEvents: []string{"heartbeat", "task_result"}, + desc: "proplet with empty name", + proplet: proplet.Proplet{ID: "xyz-456", Name: ""}, + wantTitleHasID: true, + wantProperties: []string{"id", "name", "alive", "task_count", "metadata"}, + wantActions: []string{"start_task", "stop_task"}, + wantEvents: []string{"heartbeat", "task_result"}, wantSdfDataKeys: []string{"PropletMetadata", "TaskDispatch", "HeartbeatPayload", "TaskResult"}, }, } for _, tc := range cases { t.Run(tc.desc, func(t *testing.T) { + t.Parallel() + doc := sdf.PropletDocument(tc.proplet) assert.NotEmpty(t, doc.Info.Title) assert.NotEmpty(t, doc.Info.Version) if tc.wantTitleHasID { - assert.True(t, strings.Contains(doc.Info.Title, tc.proplet.ID), "expected title to contain proplet ID %q, got %q", tc.proplet.ID, doc.Info.Title) + assert.Contains(t, doc.Info.Title, tc.proplet.ID, "expected title to contain proplet ID %q, got %q", tc.proplet.ID, doc.Info.Title) } thing, ok := doc.SdfThing["Proplet"] @@ -77,10 +80,12 @@ func TestPropletDocument(t *testing.T) { } func TestPropletDocumentTaskResultStateEnum(t *testing.T) { + t.Parallel() + cases := []struct { - desc string - proplet proplet.Proplet - wantStates []string + desc string + proplet proplet.Proplet + wantStates []string }{ { desc: "task result state enum contains all expected values", @@ -91,6 +96,8 @@ func TestPropletDocumentTaskResultStateEnum(t *testing.T) { for _, tc := range cases { t.Run(tc.desc, func(t *testing.T) { + t.Parallel() + doc := sdf.PropletDocument(tc.proplet) thing := doc.SdfThing["Proplet"] @@ -115,6 +122,8 @@ func TestPropletDocumentTaskResultStateEnum(t *testing.T) { } func TestPropletDocumentMinimumPointerNotAliased(t *testing.T) { + t.Parallel() + cases := []struct { desc string proplet proplet.Proplet @@ -127,6 +136,8 @@ func TestPropletDocumentMinimumPointerNotAliased(t *testing.T) { for _, tc := range cases { t.Run(tc.desc, func(t *testing.T) { + t.Parallel() + doc := sdf.PropletDocument(tc.proplet) thing := doc.SdfThing["Proplet"] diff --git a/pkg/sdf/sdf.go b/pkg/sdf/sdf.go index 60f26f84..703cfa85 100644 --- a/pkg/sdf/sdf.go +++ b/pkg/sdf/sdf.go @@ -35,6 +35,7 @@ type DataAfford struct { type PropertyAfford struct { DataAfford + Observable bool `json:"observable,omitempty"` } @@ -68,5 +69,6 @@ type ThingAfford struct { func minPtr() *float64 { v := float64(0) + return &v }