aboutsummaryrefslogtreecommitdiff
path: root/cmd/plccompat
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-26 20:20:17 -0800
committerFuwn <[email protected]>2026-02-26 20:20:17 -0800
commite264ed8f36b26c5684ea653ad7299603ccffe02b (patch)
tree95bbb4ad51740d7bcc0d834bd1aaa587df22f233 /cmd/plccompat
parentfix: align PLC compatibility read endpoints with plc.directory schema (diff)
downloadplutia-test-e264ed8f36b26c5684ea653ad7299603ccffe02b.tar.xz
plutia-test-e264ed8f36b26c5684ea653ad7299603ccffe02b.zip
feat: Apply Iku formatting
Diffstat (limited to 'cmd/plccompat')
-rw-r--r--cmd/plccompat/main.go117
1 files changed, 111 insertions, 6 deletions
diff --git a/cmd/plccompat/main.go b/cmd/plccompat/main.go
index 133a766..c0fcf80 100644
--- a/cmd/plccompat/main.go
+++ b/cmd/plccompat/main.go
@@ -54,20 +54,22 @@ func main() {
sampleN = flag.Int("sample", 5, "random DID samples from local export")
seed = flag.Int64("seed", time.Now().UnixNano(), "random seed")
)
+
flag.Parse()
if *count < 1 {
fmt.Fprintln(os.Stderr, "count must be >= 1")
os.Exit(2)
}
+
if *sampleN < 1 {
fmt.Fprintln(os.Stderr, "sample must be >= 1")
os.Exit(2)
}
client := &http.Client{Timeout: 30 * time.Second}
-
dids, tombstone, legacy, err := discoverDIDs(client, strings.TrimRight(*localBase, "/"), 2500, *sampleN, *seed)
+
if err != nil {
fmt.Fprintf(os.Stderr, "discover dids: %v\n", err)
os.Exit(1)
@@ -88,6 +90,7 @@ func main() {
path := "/" + did + suffix
res := compareJSONEndpoint(client, rep.LocalBase, rep.UpstreamBase, path, "did="+did)
rep.Results = append(rep.Results, res)
+
if !res.Compatible {
rep.AllCompatible = false
}
@@ -98,6 +101,7 @@ func main() {
exportPath := fmt.Sprintf("/export?count=%d", *count)
exportRes := compareNDJSONEndpoint(client, rep.LocalBase, rep.UpstreamBase, exportPath)
rep.Results = append(rep.Results, exportRes)
+
if !exportRes.Compatible {
rep.AllCompatible = false
}
@@ -106,6 +110,7 @@ func main() {
missingDID := "did:plc:this-does-not-exist-for-compat-check"
notFoundRes := compareJSONEndpoint(client, rep.LocalBase, rep.UpstreamBase, "/"+missingDID, "missing_did")
rep.Results = append(rep.Results, notFoundRes)
+
if !notFoundRes.Compatible {
rep.AllCompatible = false
}
@@ -114,6 +119,7 @@ func main() {
if tombstone != "" {
goneRes := compareJSONEndpoint(client, rep.LocalBase, rep.UpstreamBase, "/"+tombstone, "tombstone_did")
rep.Results = append(rep.Results, goneRes)
+
if !goneRes.Compatible {
rep.AllCompatible = false
}
@@ -124,7 +130,9 @@ func main() {
}
enc := json.NewEncoder(os.Stdout)
+
enc.SetIndent("", " ")
+
_ = enc.Encode(rep)
if !rep.AllCompatible {
@@ -135,9 +143,11 @@ func main() {
func discoverDIDs(client *http.Client, base string, count int, sampleN int, seed int64) ([]string, string, string, error) {
path := fmt.Sprintf("%s/export?count=%d", base, count)
res, err := doFetch(client, path)
+
if err != nil {
return nil, "", "", err
}
+
if res.status != http.StatusOK {
return nil, "", "", fmt.Errorf("local export status=%d", res.status)
}
@@ -148,7 +158,9 @@ func discoverDIDs(client *http.Client, base string, count int, sampleN int, seed
}
r := bufio.NewScanner(bytes.NewReader(res.body))
+
r.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
+
unique := map[string]struct{}{}
ordered := make([]string, 0, count)
tombstone := ""
@@ -156,21 +168,28 @@ func discoverDIDs(client *http.Client, base string, count int, sampleN int, seed
for r.Scan() {
line := bytes.TrimSpace(r.Bytes())
+
if len(line) == 0 {
continue
}
+
var row rec
+
if err := json.Unmarshal(line, &row); err != nil {
continue
}
+
if row.DID == "" {
continue
}
+
if _, ok := unique[row.DID]; !ok {
unique[row.DID] = struct{}{}
ordered = append(ordered, row.DID)
}
+
typ, _ := row.Operation["type"].(string)
+
switch typ {
case "plc_tombstone", "tombstone":
if tombstone == "" {
@@ -182,6 +201,7 @@ func discoverDIDs(client *http.Client, base string, count int, sampleN int, seed
}
}
}
+
if err := r.Err(); err != nil {
return nil, "", "", err
}
@@ -197,12 +217,15 @@ func discoverDIDs(client *http.Client, base string, count int, sampleN int, seed
rng := rand.New(rand.NewSource(seed))
perm := rng.Perm(len(ordered))
out := make([]string, 0, sampleN+2)
+
for i := 0; i < sampleN; i++ {
out = append(out, ordered[perm[i]])
}
+
if tombstone != "" && !contains(out, tombstone) {
out = append(out, tombstone)
}
+
if legacy != "" && !contains(out, legacy) {
out = append(out, legacy)
}
@@ -212,20 +235,22 @@ func discoverDIDs(client *http.Client, base string, count int, sampleN int, seed
func compareJSONEndpoint(client *http.Client, localBase, upstreamBase, path, ctx string) endpointResult {
res := endpointResult{Endpoint: path, Context: ctx, Compatible: true}
-
localURL := localBase + path
upURL := upstreamBase + path
-
l, lErr := doFetch(client, localURL)
u, uErr := doFetch(client, upURL)
+
if lErr != nil || uErr != nil {
res.Compatible = false
+
if lErr != nil {
res.Issues = append(res.Issues, "local fetch error: "+lErr.Error())
}
+
if uErr != nil {
res.Issues = append(res.Issues, "upstream fetch error: "+uErr.Error())
}
+
return res
}
@@ -238,6 +263,7 @@ func compareJSONEndpoint(client *http.Client, localBase, upstreamBase, path, ctx
res.Compatible = false
res.Issues = append(res.Issues, fmt.Sprintf("status mismatch local=%d upstream=%d", l.status, u.status))
}
+
if normalizeContentType(l.contentType) != normalizeContentType(u.contentType) {
res.Compatible = false
res.Issues = append(res.Issues, fmt.Sprintf("content-type mismatch local=%q upstream=%q", normalizeContentType(l.contentType), normalizeContentType(u.contentType)))
@@ -253,6 +279,7 @@ func compareJSONEndpoint(client *http.Client, localBase, upstreamBase, path, ctx
switch kind {
case "did", "data", "error":
lm, um, strictIssues := compareJSONBodies(l.body, u.body)
+
if lm || um || len(strictIssues) > 0 {
issues = append(issues, strictIssues...)
}
@@ -271,17 +298,20 @@ func compareNDJSONEndpoint(client *http.Client, localBase, upstreamBase, path st
res := endpointResult{Endpoint: path, Context: "export", Compatible: true}
localURL := localBase + path
upURL := upstreamBase + path
-
l, lErr := doFetch(client, localURL)
u, uErr := doFetch(client, upURL)
+
if lErr != nil || uErr != nil {
res.Compatible = false
+
if lErr != nil {
res.Issues = append(res.Issues, "local fetch error: "+lErr.Error())
}
+
if uErr != nil {
res.Issues = append(res.Issues, "upstream fetch error: "+uErr.Error())
}
+
return res
}
@@ -294,6 +324,7 @@ func compareNDJSONEndpoint(client *http.Client, localBase, upstreamBase, path st
res.Compatible = false
res.Issues = append(res.Issues, fmt.Sprintf("status mismatch local=%d upstream=%d", l.status, u.status))
}
+
if normalizeContentType(l.contentType) != normalizeContentType(u.contentType) {
res.Compatible = false
res.Issues = append(res.Issues, fmt.Sprintf("content-type mismatch local=%q upstream=%q", normalizeContentType(l.contentType), normalizeContentType(u.contentType)))
@@ -301,25 +332,32 @@ func compareNDJSONEndpoint(client *http.Client, localBase, upstreamBase, path st
localRows, lErr := parseNDJSON(l.body)
upRows, uErr := parseNDJSON(u.body)
+
if lErr != nil || uErr != nil {
res.Compatible = false
+
if lErr != nil {
res.Issues = append(res.Issues, "local ndjson parse error: "+lErr.Error())
}
+
if uErr != nil {
res.Issues = append(res.Issues, "upstream ndjson parse error: "+uErr.Error())
}
+
return res
}
for i, row := range localRows {
issues := validateExportShape(row)
+
for _, issue := range issues {
res.Issues = append(res.Issues, fmt.Sprintf("local line %d: %s", i+1, issue))
}
}
+
for i, row := range upRows {
issues := validateExportShape(row)
+
for _, issue := range issues {
res.Issues = append(res.Issues, fmt.Sprintf("upstream line %d: %s", i+1, issue))
}
@@ -351,6 +389,7 @@ func endpointKind(path string) string {
func validateEndpointJSONShape(kind string, status int, body []byte, side string) []string {
decoded, err := decodeJSON(body)
+
if err != nil {
return []string{fmt.Sprintf("%s json decode error: %v", side, err)}
}
@@ -362,6 +401,7 @@ func validateEndpointJSONShape(kind string, status int, body []byte, side string
return prefixIssues(side, validateDIDDocumentShape(decoded))
case http.StatusGone:
issues := validateDIDDocumentShape(decoded)
+
if len(issues) == 0 {
return nil
}
@@ -427,7 +467,9 @@ func validateEndpointJSONShape(kind string, status int, body []byte, side string
func decodeJSON(body []byte) (interface{}, error) {
var v interface{}
+
dec := json.NewDecoder(bytes.NewReader(body))
+
dec.UseNumber()
if err := dec.Decode(&v); err != nil {
@@ -443,6 +485,7 @@ func prefixIssues(side string, issues []string) []string {
}
out := make([]string, 0, len(issues))
+
for _, issue := range issues {
out = append(out, fmt.Sprintf("%s %s", side, issue))
}
@@ -452,11 +495,13 @@ func prefixIssues(side string, issues []string) []string {
func validateMessageErrorShape(v interface{}) []string {
obj, ok := v.(map[string]interface{})
+
if !ok {
return []string{"expected object error body"}
}
msg, ok := obj["message"].(string)
+
if !ok || strings.TrimSpace(msg) == "" {
return []string{"missing message string field"}
}
@@ -470,11 +515,13 @@ func validateMessageErrorShape(v interface{}) []string {
func validateDIDDocumentShape(v interface{}) []string {
obj, ok := v.(map[string]interface{})
+
if !ok {
return []string{"expected object did document"}
}
issues := make([]string, 0)
+
if _, ok := obj["@context"].([]interface{}); !ok {
issues = append(issues, "missing @context array")
}
@@ -521,6 +568,7 @@ func validateDIDDocumentShape(v interface{}) []string {
func validatePLCDataShape(v interface{}) []string {
obj, ok := v.(map[string]interface{})
+
if !ok {
return []string{"expected object plc data"}
}
@@ -532,10 +580,11 @@ func validatePLCDataShape(v interface{}) []string {
"alsoKnownAs": "array",
"services": "object",
}
-
issues := make([]string, 0)
+
for key, wantType := range required {
got, ok := obj[key]
+
if !ok {
issues = append(issues, "missing field "+key)
@@ -560,13 +609,16 @@ func validatePLCDataShape(v interface{}) []string {
func validateOperationArray(v interface{}) []string {
rows, ok := v.([]interface{})
+
if !ok {
return []string{"expected array of operations"}
}
issues := make([]string, 0)
+
for idx, row := range rows {
rowIssues := validateOperationShape(row)
+
for _, issue := range rowIssues {
issues = append(issues, fmt.Sprintf("row %d: %s", idx, issue))
}
@@ -577,13 +629,16 @@ func validateOperationArray(v interface{}) []string {
func validateAuditArrayShape(v interface{}) []string {
rows, ok := v.([]interface{})
+
if !ok {
return []string{"expected array of audit entries"}
}
issues := make([]string, 0)
+
for idx, row := range rows {
obj, ok := row.(map[string]interface{})
+
if !ok {
issues = append(issues, fmt.Sprintf("row %d: expected object", idx))
@@ -600,6 +655,7 @@ func validateAuditArrayShape(v interface{}) []string {
for key, want := range required {
val, ok := obj[key]
+
if !ok {
issues = append(issues, fmt.Sprintf("row %d: missing field %s", idx, key))
@@ -623,11 +679,13 @@ func validateAuditArrayShape(v interface{}) []string {
func validateOperationShape(v interface{}) []string {
obj, ok := v.(map[string]interface{})
+
if !ok {
return []string{"expected operation object"}
}
typ, _ := obj["type"].(string)
+
if strings.TrimSpace(typ) == "" {
return []string{"missing type string"}
}
@@ -676,20 +734,27 @@ func validateOperationShape(v interface{}) []string {
func doFetch(client *http.Client, rawURL string) (fetchResp, error) {
u, err := url.Parse(rawURL)
+
if err != nil {
return fetchResp{}, err
}
+
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
+
if err != nil {
return fetchResp{}, err
}
+
resp, err := client.Do(req)
+
if err != nil {
return fetchResp{}, err
}
+
defer resp.Body.Close()
b, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
+
if err != nil {
return fetchResp{}, err
}
@@ -704,28 +769,39 @@ func doFetch(client *http.Client, rawURL string) (fetchResp, error) {
func parseNDJSON(body []byte) ([]interface{}, error) {
out := make([]interface{}, 0)
s := bufio.NewScanner(bytes.NewReader(body))
+
s.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
+
for s.Scan() {
line := bytes.TrimSpace(s.Bytes())
+
if len(line) == 0 {
continue
}
+
var v interface{}
+
dec := json.NewDecoder(bytes.NewReader(line))
+
dec.UseNumber()
+
if err := dec.Decode(&v); err != nil {
return nil, err
}
+
out = append(out, v)
}
+
if err := s.Err(); err != nil {
return nil, err
}
+
return out, nil
}
func validateExportShape(v interface{}) []string {
obj, ok := v.(map[string]interface{})
+
if !ok {
return []string{"expected object line"}
}
@@ -737,14 +813,17 @@ func validateExportShape(v interface{}) []string {
"nullified": "bool",
"createdAt": "string",
}
-
issues := make([]string, 0)
+
for k, want := range required {
vv, ok := obj[k]
+
if !ok {
issues = append(issues, "missing field "+k)
+
continue
}
+
if typeName(vv) != want {
issues = append(issues, fmt.Sprintf("field %s type=%s want=%s", k, typeName(vv), want))
}
@@ -757,28 +836,37 @@ func validateExportShape(v interface{}) []string {
}
sort.Strings(issues)
+
return issues
}
func compareJSONBodies(localBody, upBody []byte) (bool, bool, []string) {
var l, u interface{}
+
ld := json.NewDecoder(bytes.NewReader(localBody))
+
ld.UseNumber()
+
if err := ld.Decode(&l); err != nil {
return false, false, []string{"local json decode error: " + err.Error()}
}
+
ud := json.NewDecoder(bytes.NewReader(upBody))
+
ud.UseNumber()
+
if err := ud.Decode(&u); err != nil {
return false, false, []string{"upstream json decode error: " + err.Error()}
}
lm, um, issues := diffAny(l, u, "$")
+
return lm, um, issues
}
func diffAny(local, upstream interface{}, path string) (hasLocalMismatch bool, hasUpMismatch bool, issues []string) {
lt, ut := typeName(local), typeName(upstream)
+
if lt != ut {
return true, true, []string{fmt.Sprintf("%s type mismatch local=%s upstream=%s", path, lt, ut)}
}
@@ -786,43 +874,55 @@ func diffAny(local, upstream interface{}, path string) (hasLocalMismatch bool, h
switch l := local.(type) {
case map[string]interface{}:
u := upstream.(map[string]interface{})
+
for k := range u {
if _, ok := l[k]; !ok {
hasLocalMismatch = true
issues = append(issues, fmt.Sprintf("%s missing field in local: %s", path, k))
}
}
+
for k := range l {
if _, ok := u[k]; !ok {
hasUpMismatch = true
issues = append(issues, fmt.Sprintf("%s extra field in local: %s", path, k))
}
}
+
for k := range l {
uv, ok := u[k]
+
if !ok {
continue
}
+
lm, um, sub := diffAny(l[k], uv, path+"."+k)
+
if lm {
hasLocalMismatch = true
}
+
if um {
hasUpMismatch = true
}
+
issues = append(issues, sub...)
}
case []interface{}:
u := upstream.([]interface{})
n := min(len(l), len(u))
+
for i := 0; i < n; i++ {
lm, um, sub := diffAny(l[i], u[i], fmt.Sprintf("%s[%d]", path, i))
+
if lm {
hasLocalMismatch = true
}
+
if um {
hasUpMismatch = true
}
+
issues = append(issues, sub...)
}
}
@@ -851,10 +951,13 @@ func typeName(v interface{}) string {
func normalizeContentType(v string) string {
v = strings.TrimSpace(v)
+
if v == "" {
return ""
}
+
parts := strings.Split(v, ";")
+
return strings.ToLower(strings.TrimSpace(parts[0]))
}
@@ -868,6 +971,7 @@ func contains(xs []string, target string) bool {
return true
}
}
+
return false
}
@@ -875,5 +979,6 @@ func min(a, b int) int {
if a < b {
return a
}
+
return b
}