Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions arkruntime/internal/listresponse/normalize.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates.
// SPDX-License-Identifier: Apache-2.0

// Package listresponse adapts legacy null list responses before strict decoding.
package listresponse

import (
"bytes"
"encoding/json"
)

// Normalize changes only top-level data:null tokens, retaining duplicate fields
// and all other bytes so the generated decoder keeps its validation behavior.
func Normalize(data []byte, emptyPage string) []byte {
if bytes.Equal(bytes.Trim(data, " \t\r\n"), []byte("null")) {
return []byte(emptyPage)
}
decoder := json.NewDecoder(bytes.NewReader(data))
first, err := decoder.Token()
if err != nil || first != json.Delim('{') {
return data
}
var normalized []byte
copied := 0
for decoder.More() {
key, err := decoder.Token()
if err != nil {
return data
}
var value json.RawMessage
if err := decoder.Decode(&value); err != nil {
return data
}
if key == "data" && bytes.Equal(value, []byte("null")) {
end := int(decoder.InputOffset())
start := end - len(value)
normalized = append(normalized, data[copied:start]...)
normalized = append(normalized, '[', ']')
copied = end
}
}
if _, err := decoder.Token(); err != nil || copied == 0 {
return data
}
return append(normalized, data[copied:]...)
}
120 changes: 120 additions & 0 deletions arkruntime/list_null_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates.
// SPDX-License-Identifier: Apache-2.0

package arkruntime

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)

type listResponseCase struct {
name string
body string
count int
wantError bool
}

func TestListFilesNullResponse(t *testing.T) {
const fileID = "file-1"
const page = `"object":"file","first_id":"file-1","last_id":"file-1","has_more":true`
cases := []listResponseCase{
{name: "null", body: `null`},
{name: "whitespace", body: " \n null \t"},
{name: "invalid_whitespace", body: "\vnull", wantError: true},
{name: "null_data", body: `{` + page + `,"data":null}`},
{name: "null_data_offsets", body: `{` + page + `,"extra":"中文","d\u0061ta":
null}`},
{name: "empty", body: `{` + page + `,"data":[]}`},
{name: "populated", body: `{` + page + `,"data":[{"id":"file-1","object":"file","bytes":1,"created_at":1,"filename":"test.txt","purpose":"user_data","status":"processed","expire_at":0}]}`, count: 1},
{name: "invalid", body: `{` + page + `,"data":"invalid"}`, wantError: true},
{name: "invalid_duplicate_data", body: `{` + page + `,"data":"invalid","data":null}`, wantError: true},
{name: "invalid_duplicate_pagination", body: `{` + page + `,"data":null,"has_more":"invalid","has_more":true}`, wantError: true},
{name: "valid_duplicate_data", body: `{` + page + `,"data":[],"data":[]}`},
{name: "missing", body: `{` + page + `}`, wantError: true},
{name: "wrong_kind", body: `[]`, wantError: true},
}
runListResponseCases(t, "/files", cases, func(t *testing.T, c *Client, tc listResponseCase) (int, error) {
response, err := c.ListFiles(context.Background(), nil)
if err != nil {
return 0, err
}
if response == nil || response.Data == nil {
t.Fatal("expected non-nil response and list")
}
if tc.name == "null" || tc.name == "whitespace" {
if response.HasMore || response.FirstID != "" || response.LastID != "" {
t.Fatalf("empty page has cursors: %+v", response)
}
} else if response.FirstID != fileID || response.LastID != fileID || !response.HasMore {
t.Fatalf("pagination changed: %+v", response)
}
if len(response.Data) == 1 && response.Data[0].ID != fileID {
t.Fatal("file lost")
}
return len(response.Data), nil
})
}

func TestListSessionResourcesNullResponse(t *testing.T) {
cases := []listResponseCase{
{name: "null", body: `null`},
{name: "whitespace", body: " \n null \t"},
{name: "invalid_whitespace", body: "\vnull", wantError: true},
{name: "null_data", body: `{"data":null}`},
{name: "null_data_offsets", body: `{"extra":"中文","d\u0061ta":
null}`},
{name: "empty", body: `{"data":[]}`},
{name: "populated", body: `{"data":[{"id":"resource-1","type":"file","file_id":"file-1"}]}`, count: 1},
{name: "invalid", body: `{"data":"invalid"}`, wantError: true},
{name: "invalid_duplicate_data", body: `{"data":"invalid","data":null}`, wantError: true},
{name: "valid_duplicate_data", body: `{"data":[],"data":[]}`},
{name: "missing", body: `{}`, wantError: true},
{name: "wrong_kind", body: `[]`, wantError: true},
}
runListResponseCases(t, "/sessions/session-1/resources", cases, func(t *testing.T, c *Client, _ listResponseCase) (int, error) {
response, err := c.ListSessionResources(context.Background(), "session-1")
if err != nil {
return 0, err
}
if response == nil || response.Data == nil {
t.Fatal("expected non-nil response and list")
}
if len(response.Data) == 1 && response.Data[0].ID.Value != "resource-1" {
t.Fatal("resource lost")
}
return len(response.Data), nil
})
}

func runListResponseCases(t *testing.T, path string, cases []listResponseCase, call func(*testing.T, *Client, listResponseCase) (int, error)) {
t.Helper()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
server := listResponseServer(t, path, tc.body)
defer server.Close()
client := NewClientWithApiKey("test", WithBaseUrl(server.URL))
count, err := call(t, client, tc)
if (err != nil) != tc.wantError {
t.Fatalf("error = %v, wantError = %v", err, tc.wantError)
}
if count != tc.count {
t.Fatalf("got %d items, want %d", count, tc.count)
}
})
}
}

func listResponseServer(t *testing.T, path, body string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != path {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, body)
}))
}
7 changes: 7 additions & 0 deletions arkruntime/model/file/file_shim.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"net/http"
"net/url"

"github.com/volcengine/ark-runtime-go/arkruntime/internal/listresponse"
"github.com/volcengine/ark-runtime-go/arkruntime/pkg/apiform"
"github.com/volcengine/ark-runtime-go/arkruntime/pkg/apiquery"
)
Expand All @@ -36,6 +37,12 @@ type FileListResponseWrapper struct {
httpHeader
}

// UnmarshalJSON normalizes legacy null pages without changing generated models.
func (r *FileListResponseWrapper) UnmarshalJSON(data []byte) error {
// Keep this empty page aligned with every required field after schema changes.
return r.FileListResponse.UnmarshalJSON(listresponse.Normalize(data, `{"object":"file","data":[],"first_id":"","last_id":"","has_more":false}`))
}

// FileDeletedResponse wraps FileDeleted so it satisfies model.Response.
type FileDeletedResponse struct {
FileDeleted
Expand Down
6 changes: 6 additions & 0 deletions arkruntime/model/session/session_shim.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"encoding/json"
"net/url"

"github.com/volcengine/ark-runtime-go/arkruntime/internal/listresponse"
"github.com/volcengine/ark-runtime-go/arkruntime/model"
"github.com/volcengine/ark-runtime-go/arkruntime/pkg/apiquery"
)
Expand Down Expand Up @@ -122,6 +123,11 @@ type ListSessionResourcesResponseWrapper struct {
model.HttpHeader
}

// UnmarshalJSON normalizes legacy null pages without changing generated models.
func (r *ListSessionResourcesResponseWrapper) UnmarshalJSON(data []byte) error {
return r.ListSessionResourcesResponse.UnmarshalJSON(listresponse.Normalize(data, `{"data":[]}`))
}

// --- SessionEvent -----------------------------------------------------------

type SendSessionEventsResponseWrapper struct {
Expand Down
Loading