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

  1. Change the contract's request type parameter to a plain struct type
  2. Remove any pointer indirection (use MyRequest, not *MyRequest)
  3. 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

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


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 err

View on GitHub (pinned to 9f775e8a12)