package main
import (
"encoding/json"
"fmt"
"math"
"math/big"
"strconv"
"strings"
)
type Input struct {
AuthorID int64
AuthorOverflow bool
Title, Body string
Tags []string
}
type Validation struct {
Type string `json:"type"`
Loc []any `json:"loc"`
Msg string `json:"msg"`
}
var messages = map[string]string{"missing": "Field required", "int_parsing": "Input should be a valid integer, unable to parse string as an integer", "int_type": "Input should be a valid integer", "int_from_float": "Input should be a valid integer, got a number with a fractional part", "string_type": "Input should be a valid string", "list_type": "Input should be a valid list"}
func issue(kind string, loc ...any) Validation { return Validation{kind, loc, messages[kind]} }
func integer(value any) (int64, string) {
switch v := value.(type) {
case bool:
if v {
return 1, ""
}
return 0, ""
case json.Number:
if n, err := v.Int64(); err == nil {
return n, ""
}
if z, ok := new(big.Int).SetString(string(v), 10); ok && !z.IsInt64() {
return 0, "overflow"
}
n, err := v.Float64()
if err != nil || math.IsNaN(n) || math.IsInf(n, 0) || n != math.Trunc(n) {
return 0, "int_from_float"
}
if n >= math.MaxInt64 || n < math.MinInt64 {
return 0, "overflow"
}
return int64(n), ""
case string:
v = strings.TrimSpace(v)
for i, c := range v {
if c == '_' && (i == 0 || i+1 == len(v) || v[i-1] < '0' || v[i-1] > '9' || v[i+1] < '0' || v[i+1] > '9') {
return 0, "int_parsing"
}
}
v = strings.ReplaceAll(v, "_", "")
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
return n, ""
}
if z, ok := new(big.Int).SetString(v, 10); ok && !z.IsInt64() {
return 0, "overflow"
}
// Pydantic accepts integral decimal strings, but not exponent notation.
if strings.Contains(v, ".") && !strings.ContainsAny(v, "eE") {
parts := strings.Split(v, ".")
if len(parts) == 2 && parts[0] != "" && parts[1] != "" && strings.Trim(parts[1], "0") == "" {
if n, err := strconv.ParseInt(parts[0], 10, 64); err == nil {
return n, ""
}
}
}
return 0, "int_parsing"
default:
return 0, "int_type"
}
}
func validate(body []byte, post bool) (Input, []Validation, error) {
result := Input{Tags: []string{}}
issues := []Validation{}
if len(body) == 0 {
return result, []Validation{issue("missing", "body", "payload")}, nil
}
if !json.Valid(body) {
return result, nil, pythonJSONError(body)
}
var value any
d := json.NewDecoder(strings.NewReader(string(body)))
d.UseNumber()
if err := d.Decode(&value); err != nil {
return result, nil, err
}
m, _ := value.(map[string]any)
fields := []string{"author_id"}
if post {
fields = append(fields, "title")
}
fields = append(fields, "body")
for _, field := range fields {
val, ok := m[field]
if !ok {
// Ninja's from-attributes adapter resolves str.title() on a string body.
if _, isString := value.(string); isString && field == "title" {
continue
}
issues = append(issues, issue("missing", "body", "payload", field))
continue
}
if field == "author_id" {
n, kind := integer(val)
if kind != "" {
if kind == "overflow" {
result.AuthorOverflow = true
} else {
issues = append(issues, issue(kind, "body", "payload", field))
}
} else {
result.AuthorID = n
}
continue
}
str, ok := val.(string)
if !ok {
issues = append(issues, issue("string_type", "body", "payload", field))
} else if field == "title" {
result.Title = str
} else {
result.Body = str
}
}
if post {
if tags, exists := m["tag_slugs"]; exists {
values, ok := tags.([]any)
if !ok {
issues = append(issues, issue("list_type", "body", "payload", "tag_slugs"))
} else {
for i, v := range values {
str, ok := v.(string)
if !ok {
issues = append(issues, issue("string_type", "body", "payload", "tag_slugs", i))
} else {
result.Tags = append(result.Tags, str)
}
}
}
}
}
return result, issues, nil
}
// Match the reference JSON decoder's diagnostic vocabulary and character offsets.
// This affects compatibility errors only; successful input uses encoding/json.
func pythonJSONError(raw []byte) error {
var value any
err := json.Unmarshal(raw, &value)
syntax, ok := err.(*json.SyntaxError)
if !ok {
return fmt.Errorf("Expecting value: line 1 column 1 (char 0)")
}
pos := max(0, int(syntax.Offset)-1)
message := "Expecting value"
text := syntax.Error()
switch {
case strings.Contains(text, "looking for beginning of object key string"):
message = "Expecting property name enclosed in double quotes"
case strings.Contains(text, "after object key"):
message = "Expecting ':' delimiter"
if strings.Contains(text, "key:value") {
message = "Expecting ',' delimiter"
}
case strings.Contains(text, "after array element"):
message = "Expecting ',' delimiter"
case strings.Contains(text, "after top-level value"):
message = "Extra data"
case strings.Contains(text, "escape code"):
message = `Invalid \escape`
pos = max(0, pos-1)
case strings.Contains(text, "in string literal"):
message = "Invalid control character at"
case strings.Contains(text, "unexpected end"):
pos = len(raw)
inString := false
escaped := false
start := 0
for i, c := range raw {
if escaped {
escaped = false
continue
}
if inString && c == '\\' {
escaped = true
continue
}
if c == '"' {
inString = !inString
if inString {
start = i
}
}
}
trimmed := strings.TrimSpace(string(raw))
if inString {
message = "Unterminated string starting at"
pos = start
} else if strings.HasSuffix(trimmed, "{") || strings.HasSuffix(trimmed, ",") {
message = "Expecting property name enclosed in double quotes"
} else if strings.HasSuffix(trimmed, "\"") {
message = "Expecting ':' delimiter"
}
}
// Python counts Unicode characters, not UTF-8 bytes.
prefix := []rune(string(raw[:min(pos, len(raw))]))
line, column := 1, 1
for _, r := range prefix {
if r == '\n' {
line++
column = 1
} else {
column++
}
}
return fmt.Errorf("%s: line %d column %d (char %d)", message, line, column, len(prefix))
}