siyuan-note/siyuan · error
request contract must be a struct
Error message
request contract must be a struct
What it means
Decode asserts via reflection that the generic request type parameter is a struct after successful body decoding. If a contract is declared with a non-struct request type (pointer, map, slice, primitive), this error is returned. It is a contract-definition bug, not a client mistake.
Solutions
- Change the contract's request type parameter to a plain struct type
- Remove any pointer indirection (use MyRequest, not *MyRequest)
- Add a compile-time/test-time check that every contract request type is a struct
Example fix
// before
contract.Define[map[string]any, Resp](path, handler)
// after
type Req struct { ID string `json:"id"` }
contract.Define[Req, Resp](path, handler) Defensive patterns
Strategy: validation
Validate before calling
func assertStructContract[T any]() { var zero T; if reflect.TypeOf(zero) != nil && reflect.TypeOf(zero).Kind() != reflect.Struct { panic("contract request type must be a struct") } } Type guard
func isStruct[T any]() bool { t := reflect.TypeOf((*T)(nil)).Elem(); return t.Kind() == reflect.Struct } Try / catch
if err := contract.Decode(...); err != nil { if strings.Contains(err.Error(), "must be a struct") { return fmt.Errorf("contract definition bug for %s: %w", path, err) }; return err } Prevention
- Declare request types as plain structs, never maps/pointers/scalars
- Add a contract-registry test asserting every request type is a struct
- Review generic instantiations during contract refactors
When it happens
Trigger: Defining an apicontract whose generic request parameter is e.g. map[string]string, a pointer type, or a scalar instead of a plain struct, then routing a request through it.
Common situations: Refactoring contracts and accidentally changing the type parameter; using *MyRequest instead of MyRequest; declaring shared generic contracts with the wrong instantiation.
Understand the failure class
Background: "This is a bug, please report it": internal invariant violations, unreachable panics, and SNH errors explained — this error's family across 47 libraries.
Related errors
- unsupported embedded request field
- unsupported enum field
- attribute view embedded base is missing
- createEmptyParagraph must be a boolean
- empty plugin response contains a body
AI-assisted analysis of siyuan-note/siyuan@9f775e8a12 (2026-09-19).
Data as JSON: /api/errors/2d6946ed1ceeeb77.
Report an issue: GitHub.
Appendix: source
Thrown at kernel/apicontract/decode.go:44
if err != nil {
err = fmt.Errorf("Parses request [%s] failed: %s", e.definition.Path, err)
}
return
}
var fields map[string]json.RawMessage
err = json.NewDecoder(reader).Decode(&fields)
if err != nil {
if e.definition.Body == LegacyOptionalBody {
return request, nil
}
if errors.Is(err, io.EOF) {
err = errors.New("the request body is empty or truncated (EOF)")
}
return request, fmt.Errorf("Parses request [%s] failed: %s", e.definition.Path, err)
}
value := reflect.ValueOf(&request).Elem()
if value.Kind() != reflect.Struct {
return request, fmt.Errorf("request contract must be a struct")
}
err = decodeRequestFields(value, fields)
return request, err
}
func decodeRequestFields(value reflect.Value, fields map[string]json.RawMessage) error {
for i := 0; i < value.NumField(); i++ {
field := value.Type().Field(i)
name := strings.Split(field.Tag.Get("json"), ",")[0]
if (!field.IsExported() && !field.Anonymous) || name == "-" {
continue
}
if field.Anonymous && name == "" {
if field.Type.Kind() != reflect.Struct {
return fmt.Errorf("unsupported embedded request field: %s", field.Name)
}
if err := decodeRequestFields(value.Field(i), fields); err != nil {
return errView on GitHub (pinned to 9f775e8a12)