-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathcommon.go
More file actions
278 lines (248 loc) · 9.16 KB
/
Copy pathcommon.go
File metadata and controls
278 lines (248 loc) · 9.16 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
package apify
import (
"encoding/json"
"net/http"
"net/url"
"runtime"
"strconv"
"strings"
)
// dataEnvelope unwraps the top-level `{ "data": ... }` wrapper used by most Apify endpoints.
type dataEnvelope[T any] struct {
Data T `json:"data"`
}
// parseDataEnvelope parses a JSON response body wrapped in a `data` envelope.
func parseDataEnvelope[T any](body []byte) (T, error) {
var env dataEnvelope[T]
err := json.Unmarshal(body, &env)
return env.Data, err
}
// Error type strings the API returns for missing resources.
const (
recordNotFoundType = "record-not-found"
recordOrTokenNotFoundType = "record-or-token-not-found"
notFoundStatusCode = 404
)
// isNotFound reports whether err represents a "resource not found" API error, mirroring
// the reference clients' catchNotFoundOrThrow: a get/delete/head on a missing resource
// should resolve to "absent" rather than raise.
func isNotFound(err error) bool {
apiErr, ok := AsAPIError(err)
if !ok {
return false
}
if apiErr.StatusCode != notFoundStatusCode {
return false
}
return apiErr.Type == recordNotFoundType ||
apiErr.Type == recordOrTokenNotFoundType ||
apiErr.HTTPMethod == http.MethodHead
}
// QueryParams is an ordered collection of query parameters that omits absent values and
// encodes booleans as 1/0, matching the Apify API conventions.
type QueryParams struct {
pairs [][2]string
}
// NewQueryParams returns an empty QueryParams.
func NewQueryParams() *QueryParams { return &QueryParams{} }
// AddString adds a string parameter if value is non-nil.
func (q *QueryParams) AddString(key string, value *string) *QueryParams {
if value != nil {
q.pairs = append(q.pairs, [2]string{key, *value})
}
return q
}
// AddInt adds an integer parameter if value is non-nil.
func (q *QueryParams) AddInt(key string, value *int64) *QueryParams {
if value != nil {
q.pairs = append(q.pairs, [2]string{key, strconv.FormatInt(*value, 10)})
}
return q
}
// AddFloat adds a floating-point parameter if value is non-nil.
func (q *QueryParams) AddFloat(key string, value *float64) *QueryParams {
if value != nil {
q.pairs = append(q.pairs, [2]string{key, strconv.FormatFloat(*value, 'f', -1, 64)})
}
return q
}
// AddBool adds a boolean parameter, encoded as 1/0, if value is non-nil.
func (q *QueryParams) AddBool(key string, value *bool) *QueryParams {
if value != nil {
v := "0"
if *value {
v = "1"
}
q.pairs = append(q.pairs, [2]string{key, v})
}
return q
}
// AddCSV adds a comma-joined list parameter if value is non-empty.
func (q *QueryParams) AddCSV(key string, value []string) *QueryParams {
if len(value) > 0 {
q.pairs = append(q.pairs, [2]string{key, strings.Join(value, ",")})
}
return q
}
// addRaw appends an already-stringified key/value pair.
func (q *QueryParams) addRaw(key, value string) *QueryParams {
q.pairs = append(q.pairs, [2]string{key, value})
return q
}
// IsEmpty reports whether no parameters were added.
func (q *QueryParams) IsEmpty() bool { return len(q.pairs) == 0 }
// clone returns a copy of q.
func (q *QueryParams) clone() *QueryParams {
out := &QueryParams{pairs: make([][2]string, len(q.pairs))}
copy(out.pairs, q.pairs)
return out
}
// extend appends all pairs from other to q.
func (q *QueryParams) extend(other *QueryParams) {
if other == nil {
return
}
q.pairs = append(q.pairs, other.pairs...)
}
// applyToURL appends the parameters to rawURL as a query string.
func (q *QueryParams) applyToURL(rawURL string) string {
if len(q.pairs) == 0 {
return rawURL
}
var b strings.Builder
for i, p := range q.pairs {
if i > 0 {
b.WriteByte('&')
}
b.WriteString(url.QueryEscape(p[0]))
b.WriteByte('=')
b.WriteString(url.QueryEscape(p[1]))
}
sep := "?"
if strings.Contains(rawURL, "?") {
sep = "&"
}
return rawURL + sep + b.String()
}
// ListOptions holds the standard offset/limit pagination shared by most list endpoints.
type ListOptions struct {
// Offset is the number of items to skip from the beginning of the list.
Offset *int64
// Limit is the maximum number of items to return.
Limit *int64
// Desc, if true, returns items newest-first.
Desc *bool
}
func (o ListOptions) apply(q *QueryParams) {
q.AddInt("offset", o.Offset).AddInt("limit", o.Limit).AddBool("desc", o.Desc)
}
// StorageListOptions holds the options shared by the storage collection list endpoints
// (GET /v2/datasets, /v2/key-value-stores, /v2/request-queues), which add `unnamed` and
// `ownership` filters on top of the standard pagination.
type StorageListOptions struct {
// Offset is the number of items to skip from the beginning of the list.
Offset *int64
// Limit is the maximum number of items to return.
Limit *int64
// Desc, if true, returns items newest-first.
Desc *bool
// Unnamed, if true, includes unnamed storages in the result.
Unnamed *bool
// Ownership filters by ownership (e.g. "OWNED" / "ACCESSIBLE").
Ownership *string
}
func (o StorageListOptions) apply(q *QueryParams) {
q.AddInt("offset", o.Offset).
AddInt("limit", o.Limit).
AddBool("desc", o.Desc).
AddBool("unnamed", o.Unnamed).
AddString("ownership", o.Ownership)
}
// Ptr returns a pointer to v. It is a convenience for setting the optional, pointer-typed
// fields on the option structs (e.g. Limit, Desc, My) without needing a named local variable:
//
// client.Actors().List(ctx, apify.ActorListOptions{My: apify.Ptr(true), Limit: apify.Ptr(int64(10))})
func Ptr[T any](v T) *T { return &v }
// PaginationList is a single page of an offset/limit-paginated list.
//
// The pagination metadata (Total, Offset, Limit, Count, Desc) accompanies the Items slice.
// Note: Total reflects the API's reported total, which can briefly lag immediately after a
// write (e.g. right after PushItems) because the count is computed asynchronously — re-read
// after a short delay if you need an exact post-write total.
type PaginationList[T any] struct {
// Total is the total number of items available across all pages.
Total int64 `json:"total"`
// Offset is the number of items skipped at the start.
Offset int64 `json:"offset"`
// Limit is the maximum number of items the API would return for this request.
Limit int64 `json:"limit"`
// Count is the number of items actually returned in this page.
Count int64 `json:"count"`
// Desc reports whether the items are in descending order.
Desc bool `json:"desc"`
// Items are the items of this page.
Items []T `json:"items"`
}
// osTokenOverrides maps Go's runtime.GOOS values to the platform names the reference Apify JS
// client emits in the User-Agent OS token. That token comes from Node's os.platform(), which
// spells a few platforms differently from Go's runtime.GOOS:
// - "windows" -> "win32"
// - "solaris" -> "sunos", and Go's "illumos" (a Solaris/OpenSolaris derivative that Node/libuv
// also reports as "sunos") -> "sunos"
// - "ios" -> "darwin" (Node/libuv reports Apple platforms uniformly as "darwin")
//
// Every other GOOS value already matches os.platform() verbatim (e.g. "linux", "darwin",
// "android", "freebsd", "openbsd", "aix"), so it is passed through unchanged.
var osTokenOverrides = map[string]string{
"windows": "win32",
"solaris": "sunos",
"illumos": "sunos",
"ios": "darwin",
}
// platformToken maps a Go GOOS value to the aligned User-Agent OS token (see osTokenOverrides).
// It is a pure function of its argument so the mapping can be unit-tested for every platform,
// not just the one the tests happen to run on.
func platformToken(goos string) string {
if mapped, ok := osTokenOverrides[goos]; ok {
return mapped
}
return goos
}
// osToken returns the User-Agent OS token for the current platform. runtime.GOOS is the idiomatic
// Go source of the platform identifier; platformToken aligns the two names that Go spells
// differently from the reference clients.
func osToken() string {
return platformToken(runtime.GOOS)
}
// BuildUserAgent builds the User-Agent header value mandated by the client requirements:
// `ApifyClient/{version} ({os}; {language version}); isAtHome/{isAtHome}`.
//
// isAtHome is driven solely by the platform's APIFY_IS_AT_HOME environment variable (matching
// the requirements and the reference JS client, which reads it via @apify/consts) and is
// rendered lowercase (true/false). The {os} token uses osToken so it matches the platform names
// the reference clients emit.
func BuildUserAgent(suffix string, isAtHomeFn func() bool) string {
atHome := "false"
if isAtHomeFn() {
atHome = "true"
}
ua := "ApifyClient/" + ClientVersion + " (" + osToken() + "; Go/" + goVersion() + "); isAtHome/" + atHome
if suffix != "" {
ua += "; " + suffix
}
return ua
}
// goVersion returns the runtime Go version (e.g. "1.24.7"), stripping the leading "go".
func goVersion() string {
return strings.TrimPrefix(runtime.Version(), "go")
}
// toSafeID encodes a resource id so it is safe to embed in a URL path. Apify uses the
// `username~resourcename` form, so the first `/` of an id is replaced with `~`.
func toSafeID(id string) string {
return strings.Replace(id, "/", "~", 1)
}
// encodePathSegment percent-encodes a single URL path segment, so that values
// interpolated into the path (record keys, request IDs) cannot break out of the segment.
func encodePathSegment(input string) string {
return url.PathEscape(input)
}