blob: a7c532ccfa7df607bcf5b5c87542397ed61c67c5 [file] [log] [blame]
Akron90f65212025-06-12 14:32:55 +02001package service
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "net/url"
10 "strings"
11 "time"
12
13 "github.com/korap/korap-mcp/auth"
14 "github.com/korap/korap-mcp/config"
15)
16
17// Client represents a KorAP API client
18type Client struct {
19 baseURL string
20 httpClient *http.Client
21 oauthClient *auth.OAuthClient
Akron16343052025-06-17 16:16:13 +020022 cache *Cache
Akron90f65212025-06-12 14:32:55 +020023}
24
25// ClientOptions configures the KorAP client
26type ClientOptions struct {
27 BaseURL string
28 Timeout time.Duration
29 OAuthConfig *config.OAuthConfig
Akron16343052025-06-17 16:16:13 +020030 CacheConfig *CacheConfig
Akron90f65212025-06-12 14:32:55 +020031}
32
33// NewClient creates a new KorAP API client
34func NewClient(opts ClientOptions) (*Client, error) {
35 if opts.BaseURL == "" {
36 return nil, fmt.Errorf("base URL is required")
37 }
38
39 // Ensure base URL ends with /
40 baseURL := strings.TrimSuffix(opts.BaseURL, "/") + "/"
41
42 // Set default timeout if not specified
43 timeout := opts.Timeout
44 if timeout == 0 {
45 timeout = 30 * time.Second
46 }
47
48 client := &Client{
49 baseURL: baseURL,
50 httpClient: &http.Client{
51 Timeout: timeout,
52 },
53 }
54
55 // Initialize OAuth client if configuration is provided
56 if opts.OAuthConfig != nil && opts.OAuthConfig.Enabled {
57 oauthClient, err := auth.NewOAuthClient(opts.OAuthConfig)
58 if err != nil {
59 return nil, fmt.Errorf("failed to create OAuth client: %w", err)
60 }
61 client.oauthClient = oauthClient
62 }
63
Akron16343052025-06-17 16:16:13 +020064 // Initialize cache if configuration is provided
65 if opts.CacheConfig != nil {
66 cache, err := NewCache(*opts.CacheConfig)
67 if err != nil {
68 return nil, fmt.Errorf("failed to create cache: %w", err)
69 }
70 client.cache = cache
71 } else {
72 // Use default cache configuration
73 defaultConfig := DefaultCacheConfig()
74 cache, err := NewCache(defaultConfig)
75 if err != nil {
76 return nil, fmt.Errorf("failed to create default cache: %w", err)
77 }
78 client.cache = cache
79 }
80
Akron90f65212025-06-12 14:32:55 +020081 return client, nil
82}
83
84// SetOAuthClient sets the OAuth client for authentication
85func (c *Client) SetOAuthClient(oauthClient *auth.OAuthClient) {
86 c.oauthClient = oauthClient
87}
88
89// IsAuthenticated returns true if the client has valid authentication
90func (c *Client) IsAuthenticated() bool {
91 return c.oauthClient != nil && c.oauthClient.IsAuthenticated()
92}
93
94// AuthenticateWithClientCredentials performs client credentials OAuth flow
95func (c *Client) AuthenticateWithClientCredentials(ctx context.Context) error {
96 if c.oauthClient == nil {
97 return fmt.Errorf("OAuth client not configured")
98 }
99
100 return c.oauthClient.ClientCredentialsFlow(ctx)
101}
102
103// buildURL constructs a full URL from the base URL and endpoint
104func (c *Client) buildURL(endpoint string) (string, error) {
105 endpoint = strings.TrimPrefix(endpoint, "/")
106 if endpoint == "" {
107 return c.baseURL, nil
108 }
109 fullURL, err := url.JoinPath(c.baseURL, endpoint)
110 if err != nil {
111 return "", fmt.Errorf("failed to build URL: %w", err)
112 }
113 return fullURL, nil
114}
115
116// doRequest performs an HTTP request with optional authentication
117func (c *Client) doRequest(ctx context.Context, method, endpoint string, body io.Reader) (*http.Response, error) {
118 fullURL, err := c.buildURL(endpoint)
119 if err != nil {
120 return nil, err
121 }
122
123 req, err := http.NewRequestWithContext(ctx, method, fullURL, body)
124 if err != nil {
125 return nil, fmt.Errorf("failed to create request: %w", err)
126 }
127
128 // Set common headers
129 req.Header.Set("Accept", "application/json")
130 if body != nil {
131 req.Header.Set("Content-Type", "application/json")
132 }
133
134 // Add authentication if available
135 if c.oauthClient != nil && c.oauthClient.IsAuthenticated() {
136 if err := c.oauthClient.AddAuthHeader(req); err != nil {
137 return nil, fmt.Errorf("failed to add auth header: %w", err)
138 }
139 }
140
141 // Use OAuth HTTP client if available, otherwise use default client
142 httpClient := c.httpClient
143 if c.oauthClient != nil {
144 httpClient = c.oauthClient.GetHTTPClient()
145 }
146
147 resp, err := httpClient.Do(req)
148 if err != nil {
149 return nil, fmt.Errorf("request failed: %w", err)
150 }
151
152 return resp, nil
153}
154
155// Get performs a GET request to the specified endpoint
156func (c *Client) Get(ctx context.Context, endpoint string) (*http.Response, error) {
157 return c.doRequest(ctx, http.MethodGet, endpoint, nil)
158}
159
160// Post performs a POST request to the specified endpoint with JSON body
Akron708f3912025-06-17 12:26:02 +0200161func (c *Client) Post(ctx context.Context, endpoint string, body any) (*http.Response, error) {
Akron90f65212025-06-12 14:32:55 +0200162 var bodyReader io.Reader
163 if body != nil {
164 jsonBody, err := json.Marshal(body)
165 if err != nil {
166 return nil, fmt.Errorf("failed to marshal request body: %w", err)
167 }
168 bodyReader = strings.NewReader(string(jsonBody))
169 }
170
171 return c.doRequest(ctx, http.MethodPost, endpoint, bodyReader)
172}
173
Akron16343052025-06-17 16:16:13 +0200174// GetJSON performs a GET request and unmarshals the JSON response with caching
Akron708f3912025-06-17 12:26:02 +0200175func (c *Client) GetJSON(ctx context.Context, endpoint string, target any) error {
Akron16343052025-06-17 16:16:13 +0200176 // Generate cache key for GET requests
177 cacheKey := ""
178 if c.cache != nil {
179 // For GET requests, we can cache based on endpoint and query parameters
180 // Extract query parameters from endpoint if any
181 endpointURL, _ := url.Parse(endpoint)
182 params := make(map[string]any)
183 for key, values := range endpointURL.Query() {
184 if len(values) > 0 {
185 params[key] = values[0]
186 }
187 }
188 cacheKey = c.cache.generateCacheKey("GET", endpointURL.Path, params)
189
190 // Try to get from cache first
191 if cachedData, found := c.cache.Get(ctx, cacheKey); found {
192 if err := json.Unmarshal(cachedData, target); err == nil {
193 return nil
194 }
195 // If unmarshal fails, continue with API call
196 }
197 }
198
Akron90f65212025-06-12 14:32:55 +0200199 resp, err := c.Get(ctx, endpoint)
200 if err != nil {
201 return err
202 }
203 defer resp.Body.Close()
204
205 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
206 return c.handleErrorResponse(resp)
207 }
208
Akron16343052025-06-17 16:16:13 +0200209 // Read response body
210 body, err := io.ReadAll(resp.Body)
211 if err != nil {
212 return fmt.Errorf("failed to read response body: %w", err)
213 }
214
215 // Unmarshal JSON
216 if err := json.Unmarshal(body, target); err != nil {
Akron90f65212025-06-12 14:32:55 +0200217 return fmt.Errorf("failed to decode JSON response: %w", err)
218 }
219
Akron16343052025-06-17 16:16:13 +0200220 // Cache the response for GET requests
221 if c.cache != nil && cacheKey != "" {
222 ttl := c.cache.GetTTLForEndpoint(endpoint)
223 c.cache.Set(ctx, cacheKey, body, ttl)
224 }
225
Akron90f65212025-06-12 14:32:55 +0200226 return nil
227}
228
229// PostJSON performs a POST request and unmarshals the JSON response
Akron708f3912025-06-17 12:26:02 +0200230func (c *Client) PostJSON(ctx context.Context, endpoint string, body any, target any) error {
Akron90f65212025-06-12 14:32:55 +0200231 resp, err := c.Post(ctx, endpoint, body)
232 if err != nil {
233 return err
234 }
235 defer resp.Body.Close()
236
237 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
238 return c.handleErrorResponse(resp)
239 }
240
241 if target != nil {
242 if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
243 return fmt.Errorf("failed to decode JSON response: %w", err)
244 }
245 }
246
247 return nil
248}
249
250// handleErrorResponse processes error responses from the KorAP API
251func (c *Client) handleErrorResponse(resp *http.Response) error {
252 body, err := io.ReadAll(resp.Body)
253 if err != nil {
254 return fmt.Errorf("HTTP %d: failed to read error response", resp.StatusCode)
255 }
256
257 // Try to parse as KorAP error response
258 var errorResp ErrorResponse
259 if json.Unmarshal(body, &errorResp) == nil && errorResp.Error != "" {
260 return &APIError{
261 StatusCode: resp.StatusCode,
262 Message: errorResp.Error,
263 Details: errorResp.ErrorDescription,
264 }
265 }
266
267 // Fallback to generic error
268 return &APIError{
269 StatusCode: resp.StatusCode,
270 Message: fmt.Sprintf("HTTP %d", resp.StatusCode),
271 Details: string(body),
272 }
273}
274
275// GetBaseURL returns the base URL of the KorAP instance
276func (c *Client) GetBaseURL() string {
277 return c.baseURL
278}
279
280// Ping checks if the KorAP server is reachable
281func (c *Client) Ping(ctx context.Context) error {
282 resp, err := c.Get(ctx, "")
283 if err != nil {
284 return fmt.Errorf("failed to ping KorAP server: %w", err)
285 }
286 defer resp.Body.Close()
287
288 if resp.StatusCode >= 500 {
289 return fmt.Errorf("KorAP server error: HTTP %d", resp.StatusCode)
290 }
291
292 return nil
293}
Akron16343052025-06-17 16:16:13 +0200294
295// GetCache returns the cache instance (for testing and monitoring)
296func (c *Client) GetCache() *Cache {
297 return c.cache
298}
299
300// Close closes the client and cleans up resources
301func (c *Client) Close() error {
302 if c.cache != nil {
303 return c.cache.Close()
304 }
305 return nil
306}