-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathclient.go
More file actions
505 lines (456 loc) · 15.5 KB
/
Copy pathclient.go
File metadata and controls
505 lines (456 loc) · 15.5 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
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
// Package epo_ops provides a Go client for the European Patent Office's Open Patent Services (OPS) API v3.2.
//
// This library provides an idiomatic Go interface to interact with the EPO's Open Patent Services,
// allowing you to retrieve patent bibliographic data, claims, descriptions, search for patents,
// get patent family information, download images, and more.
//
// Example usage:
//
// config := &ops.Config{
// ConsumerKey: "your-consumer-key",
// ConsumerSecret: "your-consumer-secret",
// }
//
// client, err := ops.NewClient(config)
// if err != nil {
// log.Fatal(err)
// }
//
// ctx := context.Background()
// biblio, err := client.GetBiblio(ctx, "publication", "docdb", "EP1000000")
// if err != nil {
// log.Fatal(err)
// }
package epo_ops
//go:generate oapi-codegen -package generated -generate types openapi.yaml -o generated/types_gen.go
//go:generate oapi-codegen -package generated -generate client openapi.yaml -o generated/client_gen.go
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/patent-dev/epo-ops/generated"
)
// Version is the library version. It surfaces through the default User-Agent.
const Version = "1.7.0"
// DefaultUserAgent identifies this library in outbound requests.
const DefaultUserAgent = "epo-ops-go/" + Version + " (patent.dev; +https://github.com/patent-dev/epo-ops)"
// Client is the main EPO OPS API client.
type Client struct {
config *Config
httpClient *http.Client
authenticator *Authenticator
generated *generated.Client
quota *quotaTracker
// downloadHTTPClient serves image/document downloads. It has no
// whole-request timeout (large pages may stream longer than Timeout);
// instead the transport bounds the wait for response headers.
downloadHTTPClient *http.Client
generatedDownload *generated.Client
}
// getAcceptHeader returns the appropriate Accept header value based on the endpoint type.
// The EPO OPS API requires different Accept headers for different service endpoints.
func getAcceptHeader(endpoint string) string {
switch endpoint {
case EndpointBiblio, EndpointAbstract:
return "application/exchange+xml"
case EndpointFulltext, EndpointClaims, EndpointDescription:
return "application/fulltext+xml"
case EndpointFamily, EndpointLegal, EndpointSearch:
return "application/ops+xml"
case EndpointRegister:
return "application/register+xml"
case EndpointImages:
return "application/tiff"
default:
return "application/xml"
}
}
// getEndpointFromPath extracts the endpoint type from the URL path.
// This is used to determine the appropriate Accept header.
func getEndpointFromPath(path string) string {
if strings.Contains(path, "/published-data/publication/") {
// Parse the constituent (biblio, abstract, claims, description, fulltext)
parts := strings.Split(path, "/")
for i, part := range parts {
if part == "publication" && i+3 < len(parts) {
constituent := parts[i+3]
switch constituent {
case "biblio":
return EndpointBiblio
case "abstract":
return EndpointAbstract
case "claims":
return EndpointClaims
case "description":
return EndpointDescription
case "fulltext":
return EndpointFulltext
}
}
}
}
if strings.Contains(path, "/family/") {
return EndpointFamily
}
if strings.Contains(path, "/legal") {
return EndpointLegal
}
if strings.Contains(path, "/register") {
return EndpointRegister
}
if strings.Contains(path, "/published-data/search") {
return EndpointSearch
}
if strings.Contains(path, "/published-data/images") {
return EndpointImages
}
return ""
}
// uaTransport adds the User-Agent header to every outgoing request.
type uaTransport struct {
base http.RoundTripper
userAgent string
}
func (t *uaTransport) RoundTrip(req *http.Request) (*http.Response, error) {
r := req.Clone(req.Context())
r.Header.Set("User-Agent", t.userAgent)
return t.base.RoundTrip(r)
}
// authTransport wraps an http.RoundTripper to add OAuth2 Bearer token to requests.
type authTransport struct {
base http.RoundTripper
authenticator *Authenticator
}
func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Get valid token
token, err := t.authenticator.GetToken(req.Context())
if err != nil {
return nil, err
}
// Clone request to avoid modifying original
req2 := req.Clone(req.Context())
req2.Header.Set("Authorization", "Bearer "+token)
// Set Accept header based on endpoint type
endpoint := getEndpointFromPath(req.URL.Path)
if endpoint != "" {
acceptHeader := getAcceptHeader(endpoint)
req2.Header.Set("Accept", acceptHeader)
}
// Perform request
return t.base.RoundTrip(req2)
}
// NewClient creates a new EPO OPS API client.
//
// The caller's Config is copied, never mutated. Zero values mean "use the
// default"; the documented sentinels opt out of a behavior entirely:
// MaxRetries: -1 disables retries, Timeout: -1 disables the client timeout.
func NewClient(config *Config) (*Client, error) {
if config == nil {
config = DefaultConfig()
}
// Defensive copy so a Config reused across clients is never mutated.
cfg := *config
// Validate required fields
if cfg.ConsumerKey == "" {
return nil, &ConfigError{Message: "ConsumerKey is required"}
}
if cfg.ConsumerSecret == "" {
return nil, &ConfigError{Message: "ConsumerSecret is required"}
}
// Set defaults if not provided; resolve the opt-out sentinels.
if cfg.BaseURL == "" {
cfg.BaseURL = "https://ops.epo.org/3.2/rest-services"
}
switch {
case cfg.MaxRetries < 0:
cfg.MaxRetries = 0 // -1: no retries
case cfg.MaxRetries == 0:
cfg.MaxRetries = 3
}
if cfg.RetryDelay == 0 {
cfg.RetryDelay = 1 * time.Second
}
switch {
case cfg.Timeout < 0:
cfg.Timeout = 0 // -1: no client timeout
case cfg.Timeout == 0:
cfg.Timeout = 30 * time.Second
}
// Resolve the default User-Agent for all outbound traffic.
userAgent := cfg.UserAgent
if userAgent == "" {
userAgent = DefaultUserAgent
}
// Resolve the base transport shared by API and token requests, so an injected
// transport (e.g. egress rate limiting) governs all outbound EPO traffic. The
// User-Agent is applied at this shared layer so token, data, and retry requests
// all carry it while still funnelling through any injected transport.
base := cfg.Transport
if base == nil {
base = http.DefaultTransport
}
base = &uaTransport{base: base, userAgent: userAgent}
// Create base HTTP client (used for token requests)
baseClient := &http.Client{
Timeout: cfg.Timeout,
Transport: base,
}
// Create authenticator
authenticator := NewAuthenticator(cfg.ConsumerKey, cfg.ConsumerSecret, baseClient)
// Override auth URL if specified in config (mainly for testing)
if cfg.AuthURL != "" {
authenticator.authURL = cfg.AuthURL
}
// Wire the optional token store so tokens persist across clients.
authenticator.store = cfg.TokenStore
// Create HTTP client with auth transport
httpClient := &http.Client{
Timeout: cfg.Timeout,
Transport: &authTransport{
base: base,
authenticator: authenticator,
},
}
// Create generated client
genClient, err := generated.NewClient(cfg.BaseURL, generated.WithHTTPClient(httpClient))
if err != nil {
return nil, err
}
// Download client: no whole-request timeout, so a large image page may
// stream longer than Timeout. Without an injected transport, a cloned
// default transport bounds the wait for response headers instead; an
// injected transport is used as-is (it owns its own limits).
downloadBase := base
if cfg.Transport == nil {
t := http.DefaultTransport.(*http.Transport).Clone()
t.ResponseHeaderTimeout = cfg.Timeout
downloadBase = &uaTransport{base: t, userAgent: userAgent}
}
downloadHTTPClient := &http.Client{
Transport: &authTransport{
base: downloadBase,
authenticator: authenticator,
},
}
genDownloadClient, err := generated.NewClient(cfg.BaseURL, generated.WithHTTPClient(downloadHTTPClient))
if err != nil {
return nil, err
}
return &Client{
config: &cfg,
httpClient: httpClient,
authenticator: authenticator,
generated: genClient,
quota: "aTracker{},
downloadHTTPClient: downloadHTTPClient,
generatedDownload: genDownloadClient,
}, nil
}
// executeRequest is a common helper that executes an HTTP request with retry logic and 401 handling.
// Returns the response body as bytes.
func (c *Client) executeRequest(ctx context.Context, fn func() (*http.Response, error)) ([]byte, error) {
// Execute with retry logic. The retry loop handles 401 token refresh
// (clear token + refresh + retry once) so it composes correctly with the
// backoff retries for transient 5xx responses.
resp, err := c.retryableRequest(ctx, fn)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
// Parse and store quota information from headers
quotaInfo := ParseQuotaHeaders(resp.Header)
c.quota.Update(quotaInfo)
// Read response body
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
// Check status code
if resp.StatusCode != http.StatusOK {
return nil, c.handleErrorResponse(resp.StatusCode, resp.Header, body)
}
// Some endpoints return HTTP 200 with a JSON error envelope wrapped in
// an XML processing instruction. Surface it as a typed error so XML
// parsers downstream are not asked to parse JSON.
if jsonErr := parseEPOJSONErrorBody(body); jsonErr != nil {
return nil, jsonErr
}
return body, nil
}
// makeRequest executes an HTTP request with retry logic and returns the response body as a string.
func (c *Client) makeRequest(ctx context.Context, fn func() (*http.Response, error)) (string, error) {
body, err := c.executeRequest(ctx, fn)
if err != nil {
return "", err
}
return string(body), nil
}
// makeBinaryRequest executes an HTTP request with retry logic and returns the response body as bytes.
// This is used for binary data like images.
func (c *Client) makeBinaryRequest(ctx context.Context, fn func() (*http.Response, error)) ([]byte, error) {
return c.executeRequest(ctx, fn)
}
// handleErrorResponse converts HTTP error responses into appropriate error types.
func (c *Client) handleErrorResponse(statusCode int, header http.Header, body []byte) error {
retryAfter := ""
if header != nil {
retryAfter = header.Get("Retry-After")
}
// Try to parse structured XML error first
opsErr, err := parseErrorXML(body, statusCode)
if err == nil && opsErr != nil {
// Map specific error codes to appropriate error types
switch opsErr.Code {
case "CLIENT.InvalidReference", "SERVER.EntityNotFound", "HTTP.404":
return &NotFoundError{
Message: opsErr.Message,
}
case "CLIENT.InvalidAccessToken", "CLIENT.MissingAccessToken", "HTTP.401":
return &AuthError{
StatusCode: statusCode,
Message: opsErr.Message,
}
case "SERVER.RateLimitExceeded", "SERVER.QuotaPerWeekExceeded", "HTTP.429":
return &QuotaExceededError{
Message: opsErr.Message,
RetryAfter: retryAfter,
}
case "HTTP.503":
return &ServiceUnavailableError{
StatusCode: statusCode,
Message: opsErr.Message,
RetryAfter: retryAfter,
}
default:
// A 403 only means quota/rate limiting when the EPO error code
// explicitly says so; a bare 403 is a forbidden/access error.
if statusCode == http.StatusForbidden {
if isQuotaErrorCode(opsErr.Code) {
return &QuotaExceededError{Message: opsErr.Message, RetryAfter: retryAfter}
}
return &ForbiddenError{StatusCode: statusCode, Message: opsErr.Message}
}
// Return the parsed OPSError for other codes
return opsErr
}
}
// Fall back to status-code-based error handling if XML parsing fails
switch statusCode {
case http.StatusNotFound:
return &NotFoundError{
Message: string(body),
}
case http.StatusUnauthorized:
return &AuthError{
StatusCode: statusCode,
Message: string(body),
}
case http.StatusForbidden:
// Without a parseable quota code, treat a bare 403 as forbidden rather
// than quota exceeded.
return &ForbiddenError{
StatusCode: statusCode,
Message: string(body),
}
case http.StatusTooManyRequests:
return &QuotaExceededError{
Message: string(body),
RetryAfter: retryAfter,
}
case http.StatusServiceUnavailable:
return &ServiceUnavailableError{
StatusCode: statusCode,
Message: string(body),
RetryAfter: retryAfter,
}
case http.StatusRequestEntityTooLarge:
// An oversized response (e.g. a huge patent family). Non-retryable;
// the typed OPSError carries the status so callers can branch on it.
return &OPSError{
HTTPStatus: statusCode,
Code: "HTTP.413",
Message: string(body),
}
default:
return fmt.Errorf("HTTP %d: %s", statusCode, string(body))
}
}
// isQuotaErrorCode reports whether an EPO error code denotes a quota or rate
// limit condition (the only cases where a 403 should map to QuotaExceededError).
func isQuotaErrorCode(code string) bool {
switch code {
case "SERVER.RateLimitExceeded", "SERVER.QuotaPerWeekExceeded",
"SERVER.QuotaPerHourExceeded", "HTTP.429":
return true
default:
return strings.Contains(code, "Quota") || strings.Contains(code, "RateLimit")
}
}
// formatBulkBody joins patent numbers into the newline-separated body used by
// the EPO OPS bulk POST endpoints.
func formatBulkBody(numbers []string) string {
return strings.Join(numbers, "\n")
}
// GetLastQuota returns the last quota information from API responses.
// Returns nil if no API calls have been made yet.
//
// Quota tracking helps monitor fair use limits (4GB/week for non-paying users).
// The returned QuotaInfo includes:
// - Status: "green" (<50%), "yellow" (50-75%), "red" (>75%), "black" (blocked)
// - Individual: Quota for individual users
// - Registered: Quota for registered/paying users
// - Images: Separate quota for image downloads
func (c *Client) GetLastQuota() *QuotaInfo {
return c.quota.Get()
}
// GetUsageStats retrieves usage statistics from the EPO OPS Data Usage API.
//
// The Data Usage API provides historical usage data for quota monitoring and analysis.
// Usage statistics are updated within 10 minutes of each hour and aligned on midnight
// UTC/GMT boundaries. This API does not count against quotas.
//
// Parameters:
// - timeRange: Time range in one of two formats:
// - Single date: "dd/mm/yyyy" (e.g., "01/01/2024")
// - Date range: "dd/mm/yyyy~dd/mm/yyyy" (e.g., "01/01/2024~07/01/2024")
//
// Returns:
// - UsageStats containing usage entries with timestamps, response sizes, and message counts
// - error if the time range format is invalid or the request fails
//
// Example:
//
// // Get usage for a specific date
// stats, err := client.GetUsageStats(ctx, "01/01/2024")
// if err != nil {
// log.Fatal(err)
// }
//
// // Get usage for a date range
// stats, err := client.GetUsageStats(ctx, "01/01/2024~07/01/2024")
// for _, entry := range stats.Entries {
// fmt.Printf("Time: %d, Size: %d bytes, Messages: %d\n",
// entry.Timestamp, entry.TotalResponseSize, entry.MessageCount)
// }
func (c *Client) GetUsageStats(ctx context.Context, timeRange string) (*UsageStats, error) {
// Validate time range format
if err := ValidateTimeRange(timeRange); err != nil {
return nil, err
}
// Use generated client stub (endpoint now included in OpenAPI spec via convert-openapi.sh)
params := &generated.GetUsageStatisticsParams{
TimeRange: timeRange,
}
// Execute request using generated stub
jsonData, err := c.makeRequest(ctx, func() (*http.Response, error) {
return c.generated.GetUsageStatistics(ctx, params)
})
if err != nil {
return nil, err
}
// Parse JSON response
return parseUsageStats(jsonData, timeRange)
}