feat: publish immutable workflow definitions with typed SDK
This commit is contained in:
@@ -233,7 +233,7 @@ func writeError(w http.ResponseWriter, id string, err error) {
|
||||
if !errors.As(err, &known) {
|
||||
known = fault.Unavailable
|
||||
}
|
||||
status := map[string]int{"INVALID_ARGUMENT": 400, "UNAUTHENTICATED": 401, "PERMISSION_DENIED": 403, "NOT_FOUND": 404, "STATE_CONFLICT": 409, "IDEMPOTENCY_CONFLICT": 409, "BODY_TOO_LARGE": 413, "UNSUPPORTED_MEDIA_TYPE": 415, "DELIVERY_LEASE_LOST": 409, "EVENT_HISTORY_EXPIRED": 410, "SUBSCRIPTION_LIMIT": 409}[known.Code]
|
||||
status := map[string]int{"INVALID_ARGUMENT": 400, "UNAUTHENTICATED": 401, "PERMISSION_DENIED": 403, "NOT_FOUND": 404, "STATE_CONFLICT": 409, "IDEMPOTENCY_CONFLICT": 409, "BODY_TOO_LARGE": 413, "UNSUPPORTED_MEDIA_TYPE": 415, "DELIVERY_LEASE_LOST": 409, "EVENT_HISTORY_EXPIRED": 410, "SUBSCRIPTION_LIMIT": 409, "WORKFLOW_DEFINITION_LIMIT": 409, "WORKFLOW_VERSION_LIMIT": 409}[known.Code]
|
||||
if status == 0 {
|
||||
status = 503
|
||||
}
|
||||
@@ -299,6 +299,18 @@ func exactFields(raw []byte, t reflect.Type) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if t.Kind() == reflect.Map {
|
||||
var values map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &values); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, value := range values {
|
||||
if err := exactFields(value, t.Elem()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if t.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
@@ -307,11 +319,13 @@ func exactFields(raw []byte, t reflect.Type) error {
|
||||
return err
|
||||
}
|
||||
names := map[string]reflect.Type{}
|
||||
nullable := map[string]bool{}
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
name := strings.Split(field.Tag.Get("json"), ",")[0]
|
||||
if name != "" && name != "-" {
|
||||
names[name] = field.Type
|
||||
nullable[name] = field.Tag.Get("nullable") == "true"
|
||||
if field.Tag.Get("required") == "true" {
|
||||
if _, ok := object[name]; !ok {
|
||||
return errors.New("missing required field")
|
||||
@@ -324,6 +338,9 @@ func exactFields(raw []byte, t reflect.Type) error {
|
||||
if !ok {
|
||||
return errors.New("unknown field")
|
||||
}
|
||||
if nullable[name] && bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
|
||||
continue
|
||||
}
|
||||
if err := exactFields(value, field); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
51
internal/transport/httpserver/workflow_definitions.go
Normal file
51
internal/transport/httpserver/workflow_definitions.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/definition"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// RegisterWorkflowDefinitions 提供显式研发发布入口,复用严格JSON、事务认证和幂等信封。
|
||||
func RegisterWorkflowDefinitions(server *http.Server, s *definition.Service) {
|
||||
mux := server.Handler.(*http.ServeMux)
|
||||
mux.HandleFunc("POST /v1/workflow-definitions", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
var in definition.CreateInput
|
||||
if err := decodeBody(r, &in); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
key, err := idempotencyKey(r)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.Create(r.Context(), t, key, in)
|
||||
return out, createdStatus(out.Deduplicated), err
|
||||
}))
|
||||
mux.HandleFunc("GET /v1/workflow-definitions/{id}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
if _, err := historyQuery(r, false); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.Get(r.Context(), t, core.ID(r.PathValue("id")))
|
||||
return out, 200, err
|
||||
}))
|
||||
mux.HandleFunc("POST /v1/workflow-definitions/{id}/versions", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
var in definition.PublishInput
|
||||
if err := decodeBody(r, &in); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
key, err := idempotencyKey(r)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.Publish(r.Context(), t, core.ID(r.PathValue("id")), key, in)
|
||||
return out, createdStatus(out.Deduplicated), err
|
||||
}))
|
||||
mux.HandleFunc("GET /v1/workflow-definitions/{id}/versions/{version}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
if _, err := historyQuery(r, false); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.Version(r.Context(), t, core.ID(r.PathValue("id")), r.PathValue("version"))
|
||||
return out, 200, err
|
||||
}))
|
||||
}
|
||||
58
internal/transport/httpserver/workflow_definitions_test.go
Normal file
58
internal/transport/httpserver/workflow_definitions_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/definition"
|
||||
)
|
||||
|
||||
func TestWorkflowStrictJSONAndNullableLiteral(t *testing.T) {
|
||||
valid := `{"version":"v1","reason":"release","specification":{"schema_version":1,"input_type":{"kind":"OBJECT","fields":{"number":{"kind":"NUMBER"}}},"timeout_ms":1000,"failure_policy":"REVIEW","body":{"output_type":{"kind":"NULL"},"output":{"kind":"LITERAL","literal":null}}}}`
|
||||
for _, tc := range []struct {
|
||||
name, raw string
|
||||
ok bool
|
||||
}{
|
||||
{"explicit null literal", valid, true},
|
||||
{"map type case alias", strings.Replace(valid, `"number":{"kind"`, `"number":{"Kind"`, 1), false},
|
||||
{"map unknown property", strings.Replace(valid, `"number":{"kind":"NUMBER"}`, `"number":{"kind":"NUMBER","code":"script"}`, 1), false},
|
||||
{"null type map", strings.Replace(valid, `"number":{"kind":"NUMBER"}`, `"number":null`, 1), false},
|
||||
{"null collection", strings.Replace(valid, `"body":{`, `"body":{"steps":null,`, 1), false},
|
||||
{"duplicate field", strings.Replace(valid, `"schema_version":1`, `"schema_version":1,"schema_version":2`, 1), false},
|
||||
{"extra top property", strings.Replace(valid, `"reason":"release"`, `"reason":"release","Code":"arbitrary"`, 1), false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("POST", "/", strings.NewReader(tc.raw))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
var in definition.PublishInput
|
||||
err := decodeBody(r, &in)
|
||||
if (err == nil) != tc.ok {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if tc.ok {
|
||||
if _, err := definition.Check(in.Specification); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
// Value.fields 同样递归检查精确字段名;普通任务payload仍不得显式null。
|
||||
for _, raw := range []string{`{"kind":"OBJECT","fields":{"x":{"Kind":"LITERAL","literal":1}}}`, `{"kind":"OBJECT","fields":{"x":null}}`} {
|
||||
r := httptest.NewRequest("POST", "/", strings.NewReader(raw))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
var value definition.Value
|
||||
if decodeBody(r, &value) == nil {
|
||||
t.Fatal("invalid value map accepted")
|
||||
}
|
||||
}
|
||||
var ordinary struct {
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
r := httptest.NewRequest("POST", "/", strings.NewReader(`{"payload":null}`))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
if decodeBody(r, &ordinary) == nil {
|
||||
t.Fatal("nullable exception leaked to task payload")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user