cricinfo-cli-go 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/AGENTS.md +63 -0
  2. package/CONTRIBUTORS.md +75 -0
  3. package/LICENSE +21 -0
  4. package/Makefile +131 -0
  5. package/README.md +130 -0
  6. package/bin/cricinfo.js +44 -0
  7. package/cmd/cricinfo/main.go +15 -0
  8. package/go.mod +10 -0
  9. package/go.sum +10 -0
  10. package/internal/app/app.go +11 -0
  11. package/internal/app/app_test.go +122 -0
  12. package/internal/buildinfo/buildinfo.go +16 -0
  13. package/internal/cli/analysis.go +262 -0
  14. package/internal/cli/analysis_test.go +175 -0
  15. package/internal/cli/competitions.go +154 -0
  16. package/internal/cli/competitions_test.go +165 -0
  17. package/internal/cli/leagues.go +297 -0
  18. package/internal/cli/leagues_test.go +194 -0
  19. package/internal/cli/matches.go +403 -0
  20. package/internal/cli/matches_test.go +413 -0
  21. package/internal/cli/players.go +263 -0
  22. package/internal/cli/players_test.go +384 -0
  23. package/internal/cli/root.go +141 -0
  24. package/internal/cli/search.go +119 -0
  25. package/internal/cli/teams.go +214 -0
  26. package/internal/cli/teams_test.go +192 -0
  27. package/internal/cricinfo/analysis.go +1401 -0
  28. package/internal/cricinfo/analysis_phase15_test.go +267 -0
  29. package/internal/cricinfo/client.go +471 -0
  30. package/internal/cricinfo/client_test.go +280 -0
  31. package/internal/cricinfo/cmd/fixture-refresh/main.go +145 -0
  32. package/internal/cricinfo/competitions.go +405 -0
  33. package/internal/cricinfo/competitions_phase13_test.go +234 -0
  34. package/internal/cricinfo/coverage_ledger.go +122 -0
  35. package/internal/cricinfo/coverage_ledger_test.go +253 -0
  36. package/internal/cricinfo/decode.go +115 -0
  37. package/internal/cricinfo/decode_test.go +100 -0
  38. package/internal/cricinfo/entity_index.go +618 -0
  39. package/internal/cricinfo/entity_index_test.go +175 -0
  40. package/internal/cricinfo/fixture_matrix.go +243 -0
  41. package/internal/cricinfo/fixture_matrix_test.go +49 -0
  42. package/internal/cricinfo/fixtures_test.go +264 -0
  43. package/internal/cricinfo/historical_hydration.go +1641 -0
  44. package/internal/cricinfo/historical_phase14_test.go +542 -0
  45. package/internal/cricinfo/leagues.go +1210 -0
  46. package/internal/cricinfo/leagues_phase12_test.go +324 -0
  47. package/internal/cricinfo/live_leagues_test.go +169 -0
  48. package/internal/cricinfo/live_matches_test.go +203 -0
  49. package/internal/cricinfo/live_matrix_test.go +118 -0
  50. package/internal/cricinfo/live_players_test.go +122 -0
  51. package/internal/cricinfo/live_search_test.go +86 -0
  52. package/internal/cricinfo/live_smoke_test.go +213 -0
  53. package/internal/cricinfo/live_teams_test.go +104 -0
  54. package/internal/cricinfo/matches.go +1508 -0
  55. package/internal/cricinfo/matches_phase7_test.go +207 -0
  56. package/internal/cricinfo/matches_phase9_test.go +253 -0
  57. package/internal/cricinfo/normalize_entities.go +1727 -0
  58. package/internal/cricinfo/normalize_leagues.go +346 -0
  59. package/internal/cricinfo/players.go +1332 -0
  60. package/internal/cricinfo/players_phase10_test.go +174 -0
  61. package/internal/cricinfo/players_phase11_test.go +373 -0
  62. package/internal/cricinfo/render_contract.go +1088 -0
  63. package/internal/cricinfo/render_phase4_test.go +633 -0
  64. package/internal/cricinfo/renderer.go +1689 -0
  65. package/internal/cricinfo/resolver.go +813 -0
  66. package/internal/cricinfo/resolver_test.go +244 -0
  67. package/internal/cricinfo/teams.go +603 -0
  68. package/internal/cricinfo/teams_phase8_test.go +231 -0
  69. package/internal/cricinfo/testdata/fixtures/README.md +43 -0
  70. package/internal/cricinfo/testdata/fixtures/aux-competition-metadata/broadcasts.json +11 -0
  71. package/internal/cricinfo/testdata/fixtures/aux-competition-metadata/officials.json +150 -0
  72. package/internal/cricinfo/testdata/fixtures/details-plays/detail-110.json +157 -0
  73. package/internal/cricinfo/testdata/fixtures/details-plays/detail-52545007.json +145 -0
  74. package/internal/cricinfo/testdata/fixtures/details-plays/detail-52559021.json +143 -0
  75. package/internal/cricinfo/testdata/fixtures/details-plays/plays.json +15 -0
  76. package/internal/cricinfo/testdata/fixtures/endpoint-matrix.tsv +19 -0
  77. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/fow-1.json +12 -0
  78. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/fow.json +42 -0
  79. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/innings-1-2.json +38 -0
  80. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/partnership-1.json +31 -0
  81. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/partnerships.json +42 -0
  82. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar-offdays.json +20 -0
  83. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar-ondays.json +21 -0
  84. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar.json +14 -0
  85. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-2025.json +13 -0
  86. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-group-1.json +13 -0
  87. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-groups.json +11 -0
  88. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-type-1.json +13 -0
  89. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-types.json +11 -0
  90. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/seasons.json +30 -0
  91. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings-item-1.json +72 -0
  92. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings-root.json +3 -0
  93. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings.json +15 -0
  94. package/internal/cricinfo/testdata/fixtures/matches-competitions/competition.json +460 -0
  95. package/internal/cricinfo/testdata/fixtures/matches-competitions/event-1529474.json +86 -0
  96. package/internal/cricinfo/testdata/fixtures/matches-competitions/matchcards-1527966.json +368 -0
  97. package/internal/cricinfo/testdata/fixtures/matches-competitions/situation-1529474.json +10 -0
  98. package/internal/cricinfo/testdata/fixtures/players/athlete-1361257-statistics.json +126 -0
  99. package/internal/cricinfo/testdata/fixtures/players/athlete-1361257.json +113 -0
  100. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores-1-1-statistics-0.json +208 -0
  101. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores-1-2-statistics-0.json +252 -0
  102. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores.json +74 -0
  103. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-statistics-0.json +1008 -0
  104. package/internal/cricinfo/testdata/fixtures/root-discovery/events.json +72 -0
  105. package/internal/cricinfo/testdata/fixtures/root-discovery/root.json +28 -0
  106. package/internal/cricinfo/testdata/fixtures/team-competitor/competitor-789643.json +40 -0
  107. package/internal/cricinfo/testdata/fixtures/team-competitor/leaders-789643.json +353 -0
  108. package/internal/cricinfo/testdata/fixtures/team-competitor/records-789643.json +91 -0
  109. package/internal/cricinfo/testdata/fixtures/team-competitor/roster-1147772-object.json +231 -0
  110. package/internal/cricinfo/testdata/fixtures/team-competitor/roster-1147772.json +235 -0
  111. package/internal/cricinfo/testdata/fixtures/team-competitor/roster-789643.json +322 -0
  112. package/internal/cricinfo/testdata/fixtures/team-competitor/scores-789643.json +19 -0
  113. package/internal/cricinfo/testdata/fixtures/team-competitor/statistics-789643.json +629 -0
  114. package/internal/cricinfo/testdata/fixtures/team-competitor/team-789643-athletes.json +7 -0
  115. package/internal/cricinfo/testdata/fixtures/team-competitor/team-789643.json +67 -0
  116. package/internal/cricinfo/testdata/golden/match-empty.golden +1 -0
  117. package/internal/cricinfo/testdata/golden/match-list.golden +2 -0
  118. package/internal/cricinfo/testdata/golden/match-partial.golden +3 -0
  119. package/internal/cricinfo/types.go +54 -0
  120. package/package.json +51 -0
  121. package/scripts/postinstall.js +153 -0
@@ -0,0 +1,471 @@
1
+ package cricinfo
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "io"
9
+ "math"
10
+ "math/rand"
11
+ "net/http"
12
+ "net/url"
13
+ "strings"
14
+ "time"
15
+
16
+ "github.com/amxv/cricinfo-cli/internal/buildinfo"
17
+ )
18
+
19
+ const (
20
+ DefaultBaseURL = "http://core.espnuk.org/v2/sports/cricket"
21
+ defaultTimeout = 8 * time.Second
22
+ defaultMaxRetries = 3
23
+ defaultRetryBaseDelay = 200 * time.Millisecond
24
+ defaultRetryMaxDelay = 2 * time.Second
25
+ defaultRetryJitter = 0.25
26
+ defaultMaxRefHops = 8
27
+ defaultRetryStatusCode = 500
28
+ )
29
+
30
+ var (
31
+ ErrMissingRef = errors.New("missing ref")
32
+ ErrPointerLoop = errors.New("pointer loop detected")
33
+ ErrPointerChainLimit = errors.New("pointer chain exceeded max hops")
34
+ )
35
+
36
+ // Config controls transport behavior for Cricinfo requests.
37
+ type Config struct {
38
+ BaseURL string
39
+ HTTPClient *http.Client
40
+ Timeout time.Duration
41
+ MaxRetries int
42
+ RetryBaseDelay time.Duration
43
+ RetryMaxDelay time.Duration
44
+ RetryJitter float64
45
+ UserAgent string
46
+ MaxRefHops int
47
+ Sleep func(context.Context, time.Duration) error
48
+ RandomFloat64 func() float64
49
+ }
50
+
51
+ // HTTPStatusError captures non-2xx responses.
52
+ type HTTPStatusError struct {
53
+ URL string
54
+ StatusCode int
55
+ Body string
56
+ }
57
+
58
+ func (e *HTTPStatusError) Error() string {
59
+ if e.Body == "" {
60
+ return fmt.Sprintf("request %q returned status %d", e.URL, e.StatusCode)
61
+ }
62
+ return fmt.Sprintf("request %q returned status %d: %s", e.URL, e.StatusCode, e.Body)
63
+ }
64
+
65
+ // Client is a Cricinfo HTTP transport with retry and ref traversal helpers.
66
+ type Client struct {
67
+ baseURL *url.URL
68
+ httpClient *http.Client
69
+ timeout time.Duration
70
+ maxRetries int
71
+ retryBaseDelay time.Duration
72
+ retryMaxDelay time.Duration
73
+ retryJitter float64
74
+ userAgent string
75
+ maxRefHops int
76
+ sleep func(context.Context, time.Duration) error
77
+ randomFloat64 func() float64
78
+ }
79
+
80
+ // NewClient creates a configured Cricinfo client.
81
+ func NewClient(cfg Config) (*Client, error) {
82
+ baseURL := strings.TrimSpace(cfg.BaseURL)
83
+ if baseURL == "" {
84
+ baseURL = DefaultBaseURL
85
+ }
86
+
87
+ parsedBase, err := url.Parse(baseURL)
88
+ if err != nil {
89
+ return nil, fmt.Errorf("parse base url: %w", err)
90
+ }
91
+ if !parsedBase.IsAbs() {
92
+ return nil, fmt.Errorf("base url must be absolute: %q", baseURL)
93
+ }
94
+
95
+ timeout := cfg.Timeout
96
+ if timeout <= 0 {
97
+ timeout = defaultTimeout
98
+ }
99
+
100
+ maxRetries := cfg.MaxRetries
101
+ if maxRetries < 0 {
102
+ maxRetries = defaultMaxRetries
103
+ }
104
+
105
+ retryBaseDelay := cfg.RetryBaseDelay
106
+ if retryBaseDelay <= 0 {
107
+ retryBaseDelay = defaultRetryBaseDelay
108
+ }
109
+
110
+ retryMaxDelay := cfg.RetryMaxDelay
111
+ if retryMaxDelay <= 0 {
112
+ retryMaxDelay = defaultRetryMaxDelay
113
+ }
114
+ if retryMaxDelay < retryBaseDelay {
115
+ retryMaxDelay = retryBaseDelay
116
+ }
117
+
118
+ retryJitter := cfg.RetryJitter
119
+ if retryJitter < 0 {
120
+ retryJitter = 0
121
+ }
122
+ if retryJitter > 1 {
123
+ retryJitter = 1
124
+ }
125
+ if cfg.RetryJitter == 0 {
126
+ retryJitter = defaultRetryJitter
127
+ }
128
+
129
+ userAgent := strings.TrimSpace(cfg.UserAgent)
130
+ if userAgent == "" {
131
+ userAgent = fmt.Sprintf("cricinfo-cli/%s", buildinfo.CurrentVersion())
132
+ }
133
+
134
+ maxRefHops := cfg.MaxRefHops
135
+ if maxRefHops <= 0 {
136
+ maxRefHops = defaultMaxRefHops
137
+ }
138
+
139
+ httpClient := cfg.HTTPClient
140
+ if httpClient == nil {
141
+ httpClient = &http.Client{}
142
+ }
143
+
144
+ sleep := cfg.Sleep
145
+ if sleep == nil {
146
+ sleep = defaultSleep
147
+ }
148
+
149
+ randomFloat64 := cfg.RandomFloat64
150
+ if randomFloat64 == nil {
151
+ randomFloat64 = rand.Float64
152
+ }
153
+
154
+ return &Client{
155
+ baseURL: parsedBase,
156
+ httpClient: httpClient,
157
+ timeout: timeout,
158
+ maxRetries: maxRetries,
159
+ retryBaseDelay: retryBaseDelay,
160
+ retryMaxDelay: retryMaxDelay,
161
+ retryJitter: retryJitter,
162
+ userAgent: userAgent,
163
+ maxRefHops: maxRefHops,
164
+ sleep: sleep,
165
+ randomFloat64: randomFloat64,
166
+ }, nil
167
+ }
168
+
169
+ // Fetch gets a JSON resource and returns request/canonical metadata.
170
+ func (c *Client) Fetch(ctx context.Context, ref string) (*Document, error) {
171
+ requestURL, err := c.resolveRef(ref)
172
+ if err != nil {
173
+ return nil, err
174
+ }
175
+
176
+ attempts := c.maxRetries + 1
177
+ for attempt := 0; attempt < attempts; attempt++ {
178
+ attemptCtx := ctx
179
+ cancel := func() {}
180
+ if c.timeout > 0 {
181
+ attemptCtx, cancel = context.WithTimeout(ctx, c.timeout)
182
+ }
183
+
184
+ req, reqErr := http.NewRequestWithContext(attemptCtx, http.MethodGet, requestURL, nil)
185
+ if reqErr != nil {
186
+ cancel()
187
+ return nil, fmt.Errorf("create request %q: %w", requestURL, reqErr)
188
+ }
189
+ req.Header.Set("User-Agent", c.userAgent)
190
+
191
+ resp, doErr := c.httpClient.Do(req)
192
+ if doErr != nil {
193
+ cancel()
194
+ if attempt < c.maxRetries && c.shouldRetryError(doErr, ctx) {
195
+ if sleepErr := c.sleep(ctx, c.retryDelay(attempt)); sleepErr != nil {
196
+ return nil, sleepErr
197
+ }
198
+ continue
199
+ }
200
+ return nil, fmt.Errorf("request %q failed: %w", requestURL, doErr)
201
+ }
202
+
203
+ body, readErr := io.ReadAll(resp.Body)
204
+ cancel()
205
+ _ = resp.Body.Close()
206
+ if readErr != nil {
207
+ return nil, fmt.Errorf("read response %q: %w", requestURL, readErr)
208
+ }
209
+
210
+ if resp.StatusCode >= defaultRetryStatusCode && attempt < c.maxRetries {
211
+ if sleepErr := c.sleep(ctx, c.retryDelay(attempt)); sleepErr != nil {
212
+ return nil, sleepErr
213
+ }
214
+ continue
215
+ }
216
+
217
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
218
+ return nil, &HTTPStatusError{
219
+ URL: requestURL,
220
+ StatusCode: resp.StatusCode,
221
+ Body: sanitizeBodyPreview(body),
222
+ }
223
+ }
224
+
225
+ canonicalRef, canonicalErr := extractCanonicalRef(body)
226
+ if canonicalErr != nil {
227
+ return nil, fmt.Errorf("decode canonical ref for %q: %w", requestURL, canonicalErr)
228
+ }
229
+
230
+ if canonicalRef != "" {
231
+ canonicalRef, canonicalErr = resolveURL(requestURL, canonicalRef)
232
+ if canonicalErr != nil {
233
+ return nil, fmt.Errorf("resolve canonical ref %q from %q: %w", canonicalRef, requestURL, canonicalErr)
234
+ }
235
+ } else {
236
+ canonicalRef = requestURL
237
+ }
238
+
239
+ return &Document{
240
+ RequestedRef: requestURL,
241
+ CanonicalRef: canonicalRef,
242
+ StatusCode: resp.StatusCode,
243
+ Body: body,
244
+ }, nil
245
+ }
246
+
247
+ return nil, fmt.Errorf("request %q failed after retries", requestURL)
248
+ }
249
+
250
+ // GetJSON fetches and decodes a JSON payload.
251
+ func (c *Client) GetJSON(ctx context.Context, ref string, target any) (*Document, error) {
252
+ doc, err := c.Fetch(ctx, ref)
253
+ if err != nil {
254
+ return nil, err
255
+ }
256
+ if err := json.Unmarshal(doc.Body, target); err != nil {
257
+ return nil, fmt.Errorf("decode %q: %w", doc.CanonicalRef, err)
258
+ }
259
+ return doc, nil
260
+ }
261
+
262
+ // FollowRef fetches a Ref target when present.
263
+ func (c *Client) FollowRef(ctx context.Context, ref *Ref) (*Document, error) {
264
+ if ref == nil || strings.TrimSpace(ref.URL) == "" {
265
+ return nil, ErrMissingRef
266
+ }
267
+ return c.Fetch(ctx, ref.URL)
268
+ }
269
+
270
+ // ResolveRefChain follows pointer-only resources until the payload has real fields.
271
+ func (c *Client) ResolveRefChain(ctx context.Context, ref string) (*ResolvedDocument, error) {
272
+ requestedRef, err := c.resolveRef(ref)
273
+ if err != nil {
274
+ return nil, err
275
+ }
276
+
277
+ currentRef := requestedRef
278
+ traversed := make([]string, 0, c.maxRefHops+1)
279
+ seen := map[string]struct{}{}
280
+
281
+ for hop := 0; hop <= c.maxRefHops; hop++ {
282
+ doc, fetchErr := c.Fetch(ctx, currentRef)
283
+ if fetchErr != nil {
284
+ return nil, fetchErr
285
+ }
286
+
287
+ traversed = append(traversed, currentRef)
288
+ seen[currentRef] = struct{}{}
289
+
290
+ nextRef, isPointer, ptrErr := extractPointerRef(doc.Body)
291
+ if ptrErr != nil {
292
+ return nil, fmt.Errorf("decode pointer ref for %q: %w", currentRef, ptrErr)
293
+ }
294
+
295
+ if !isPointer {
296
+ return &ResolvedDocument{
297
+ RequestedRef: requestedRef,
298
+ CanonicalRef: doc.CanonicalRef,
299
+ TraversedRef: traversed,
300
+ StatusCode: doc.StatusCode,
301
+ Body: doc.Body,
302
+ }, nil
303
+ }
304
+
305
+ nextRef, err = resolveURL(currentRef, nextRef)
306
+ if err != nil {
307
+ return nil, fmt.Errorf("resolve pointer ref %q from %q: %w", nextRef, currentRef, err)
308
+ }
309
+
310
+ if _, ok := seen[nextRef]; ok {
311
+ return nil, fmt.Errorf("%w: %q", ErrPointerLoop, nextRef)
312
+ }
313
+
314
+ currentRef = nextRef
315
+ }
316
+
317
+ return nil, fmt.Errorf("%w (start: %s)", ErrPointerChainLimit, requestedRef)
318
+ }
319
+
320
+ func (c *Client) resolveRef(ref string) (string, error) {
321
+ ref = strings.TrimSpace(ref)
322
+ if strings.HasPrefix(ref, "/") {
323
+ apiRoot := strings.TrimRight(c.baseURL.Path, "/")
324
+ if apiRoot != "" && apiRoot != "/" &&
325
+ !strings.HasPrefix(ref, apiRoot+"/") &&
326
+ ref != apiRoot &&
327
+ !strings.HasPrefix(ref, "/v2/") {
328
+ ref = strings.TrimPrefix(ref, "/")
329
+ }
330
+ }
331
+
332
+ resolved, err := resolveURL(c.baseURL.String(), ref)
333
+ if err != nil {
334
+ return "", fmt.Errorf("resolve ref %q: %w", ref, err)
335
+ }
336
+ return resolved, nil
337
+ }
338
+
339
+ func (c *Client) shouldRetryError(err error, parentCtx context.Context) bool {
340
+ if errors.Is(err, context.Canceled) {
341
+ return false
342
+ }
343
+
344
+ if errors.Is(err, context.DeadlineExceeded) {
345
+ return parentCtx.Err() == nil
346
+ }
347
+
348
+ return parentCtx.Err() == nil
349
+ }
350
+
351
+ func (c *Client) retryDelay(attempt int) time.Duration {
352
+ delay := float64(c.retryBaseDelay)
353
+ delay *= math.Pow(2, float64(attempt))
354
+ if delay > float64(c.retryMaxDelay) {
355
+ delay = float64(c.retryMaxDelay)
356
+ }
357
+
358
+ if c.retryJitter > 0 {
359
+ multiplier := 1 + ((c.randomFloat64()*2)-1)*c.retryJitter
360
+ if multiplier < 0 {
361
+ multiplier = 0
362
+ }
363
+ delay *= multiplier
364
+ }
365
+
366
+ if delay > float64(c.retryMaxDelay) {
367
+ delay = float64(c.retryMaxDelay)
368
+ }
369
+
370
+ return time.Duration(delay)
371
+ }
372
+
373
+ func defaultSleep(ctx context.Context, d time.Duration) error {
374
+ if d <= 0 {
375
+ return nil
376
+ }
377
+
378
+ t := time.NewTimer(d)
379
+ defer t.Stop()
380
+
381
+ select {
382
+ case <-ctx.Done():
383
+ return ctx.Err()
384
+ case <-t.C:
385
+ return nil
386
+ }
387
+ }
388
+
389
+ func sanitizeBodyPreview(body []byte) string {
390
+ trimmed := strings.TrimSpace(string(body))
391
+ trimmed = strings.ReplaceAll(trimmed, "\n", " ")
392
+ if len(trimmed) > 240 {
393
+ return trimmed[:240] + "..."
394
+ }
395
+ return trimmed
396
+ }
397
+
398
+ func resolveURL(baseValue, ref string) (string, error) {
399
+ ref = strings.TrimSpace(ref)
400
+ if ref == "" {
401
+ return "", fmt.Errorf("empty ref")
402
+ }
403
+
404
+ relative, err := url.Parse(ref)
405
+ if err != nil {
406
+ return "", err
407
+ }
408
+ if relative.IsAbs() {
409
+ return relative.String(), nil
410
+ }
411
+
412
+ if baseValue == "" {
413
+ return "", fmt.Errorf("base url is required for relative ref %q", ref)
414
+ }
415
+
416
+ baseURL, err := url.Parse(baseValue)
417
+ if err != nil {
418
+ return "", err
419
+ }
420
+ if !baseURL.IsAbs() {
421
+ return "", fmt.Errorf("base url must be absolute: %q", baseValue)
422
+ }
423
+
424
+ if !strings.HasSuffix(baseURL.Path, "/") {
425
+ baseURL.Path = strings.TrimRight(baseURL.Path, "/") + "/"
426
+ }
427
+
428
+ return baseURL.ResolveReference(relative).String(), nil
429
+ }
430
+
431
+ func extractCanonicalRef(data []byte) (string, error) {
432
+ var top map[string]json.RawMessage
433
+ if err := json.Unmarshal(data, &top); err != nil {
434
+ return "", nil
435
+ }
436
+
437
+ raw, ok := top["$ref"]
438
+ if !ok || string(raw) == "null" {
439
+ return "", nil
440
+ }
441
+
442
+ var ref string
443
+ if err := json.Unmarshal(raw, &ref); err != nil {
444
+ return "", err
445
+ }
446
+
447
+ return strings.TrimSpace(ref), nil
448
+ }
449
+
450
+ func extractPointerRef(data []byte) (string, bool, error) {
451
+ var top map[string]json.RawMessage
452
+ if err := json.Unmarshal(data, &top); err != nil {
453
+ return "", false, nil
454
+ }
455
+
456
+ raw, ok := top["$ref"]
457
+ if !ok {
458
+ return "", false, nil
459
+ }
460
+
461
+ var ref string
462
+ if err := json.Unmarshal(raw, &ref); err != nil {
463
+ return "", false, err
464
+ }
465
+
466
+ if len(top) == 1 {
467
+ return strings.TrimSpace(ref), true, nil
468
+ }
469
+
470
+ return "", false, nil
471
+ }