Skip to content

Commit 6f231b0

Browse files
committed
support/datastore: add read-only http datastore.
1 parent a2f3853 commit 6f231b0

3 files changed

Lines changed: 614 additions & 0 deletions

File tree

support/datastore/datastore.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ func NewDataStore(ctx context.Context, datastoreConfig DataStoreConfig) (DataSto
4343
return NewGCSDataStore(ctx, datastoreConfig)
4444
case "S3":
4545
return NewS3DataStore(ctx, datastoreConfig)
46+
case "HTTP":
47+
return NewHTTPDataStore(datastoreConfig)
4648

4749
default:
4850
return nil, fmt.Errorf("invalid datastore type %v, not supported", datastoreConfig.Type)

support/datastore/http.go

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
package datastore
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/url"
10+
"os"
11+
"strconv"
12+
"strings"
13+
"time"
14+
15+
"github.com/stellar/go/support/log"
16+
)
17+
18+
// HTTPDataStore implements DataStore for HTTP(S) endpoints.
19+
// This is designed for read-only access to publicly available files.
20+
type HTTPDataStore struct {
21+
client *http.Client
22+
baseURL string
23+
headers map[string]string
24+
}
25+
26+
func NewHTTPDataStore(datastoreConfig DataStoreConfig) (DataStore, error) {
27+
baseURL, ok := datastoreConfig.Params["base_url"]
28+
if !ok {
29+
return nil, errors.New("invalid HTTP config, no base_url")
30+
}
31+
32+
parsedURL, err := url.Parse(baseURL)
33+
if err != nil {
34+
return nil, fmt.Errorf("invalid base_url: %w", err)
35+
}
36+
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
37+
return nil, errors.New("base_url must use http or https scheme")
38+
}
39+
40+
if !strings.HasSuffix(baseURL, "/") {
41+
baseURL = baseURL + "/"
42+
}
43+
44+
timeout := 30 * time.Second
45+
if timeoutStr, ok := datastoreConfig.Params["timeout"]; ok {
46+
parsedTimeout, err := time.ParseDuration(timeoutStr)
47+
if err != nil {
48+
return nil, fmt.Errorf("invalid timeout: %w", err)
49+
}
50+
timeout = parsedTimeout
51+
}
52+
53+
headers := make(map[string]string)
54+
for key, value := range datastoreConfig.Params {
55+
if strings.HasPrefix(key, "header_") {
56+
headerName := strings.TrimPrefix(key, "header_")
57+
headers[headerName] = value
58+
}
59+
}
60+
61+
client := &http.Client{
62+
Timeout: timeout,
63+
}
64+
65+
return &HTTPDataStore{
66+
client: client,
67+
baseURL: baseURL,
68+
headers: headers,
69+
}, nil
70+
}
71+
72+
func (h *HTTPDataStore) buildURL(filePath string) string {
73+
return h.baseURL + filePath
74+
}
75+
76+
func (h *HTTPDataStore) addHeaders(req *http.Request) {
77+
for key, value := range h.headers {
78+
req.Header.Set(key, value)
79+
}
80+
}
81+
82+
func (h *HTTPDataStore) checkHTTPStatus(resp *http.Response, filePath string) error {
83+
switch resp.StatusCode {
84+
case http.StatusOK:
85+
return nil
86+
case http.StatusNotFound:
87+
return os.ErrNotExist
88+
default:
89+
return fmt.Errorf("HTTP error %d for file %s", resp.StatusCode, filePath)
90+
}
91+
}
92+
93+
func (h *HTTPDataStore) doHeadRequest(ctx context.Context, filePath string) (*http.Response, error) {
94+
requestURL := h.buildURL(filePath)
95+
req, err := http.NewRequestWithContext(ctx, "HEAD", requestURL, nil)
96+
if err != nil {
97+
return nil, fmt.Errorf("failed to create HEAD request for %s: %w", filePath, err)
98+
}
99+
h.addHeaders(req)
100+
101+
resp, err := h.client.Do(req)
102+
if err != nil {
103+
return nil, fmt.Errorf("HEAD request failed for %s: %w", filePath, err)
104+
}
105+
106+
if err := h.checkHTTPStatus(resp, filePath); err != nil {
107+
resp.Body.Close()
108+
return nil, err
109+
}
110+
111+
return resp, nil
112+
}
113+
114+
// GetFileMetadata retrieves basic metadata for a file via HTTP HEAD request.
115+
func (h *HTTPDataStore) GetFileMetadata(ctx context.Context, filePath string) (map[string]string, error) {
116+
resp, err := h.doHeadRequest(ctx, filePath)
117+
if err != nil {
118+
return nil, err
119+
}
120+
defer resp.Body.Close()
121+
122+
metadata := make(map[string]string)
123+
for key, values := range resp.Header {
124+
if len(values) > 0 {
125+
metadata[strings.ToLower(key)] = values[0]
126+
}
127+
}
128+
129+
return metadata, nil
130+
}
131+
132+
// GetFileLastModified retrieves the last modified time from HTTP headers.
133+
func (h *HTTPDataStore) GetFileLastModified(ctx context.Context, filePath string) (time.Time, error) {
134+
metadata, err := h.GetFileMetadata(ctx, filePath)
135+
if err != nil {
136+
return time.Time{}, err
137+
}
138+
139+
if lastModified, ok := metadata["last-modified"]; ok {
140+
return http.ParseTime(lastModified)
141+
}
142+
143+
return time.Time{}, errors.New("last-modified header not found")
144+
}
145+
146+
// GetFile downloads a file from the HTTP endpoint.
147+
func (h *HTTPDataStore) GetFile(ctx context.Context, filePath string) (io.ReadCloser, error) {
148+
requestURL := h.buildURL(filePath)
149+
req, err := http.NewRequestWithContext(ctx, "GET", requestURL, nil)
150+
if err != nil {
151+
return nil, fmt.Errorf("failed to create GET request for %s: %w", filePath, err)
152+
}
153+
h.addHeaders(req)
154+
155+
resp, err := h.client.Do(req)
156+
if err != nil {
157+
log.Debugf("Error retrieving file '%s': %v", filePath, err)
158+
return nil, fmt.Errorf("GET request failed for %s: %w", filePath, err)
159+
}
160+
161+
if err := h.checkHTTPStatus(resp, filePath); err != nil {
162+
resp.Body.Close()
163+
return nil, err
164+
}
165+
166+
log.Debugf("File retrieved successfully: %s", filePath)
167+
return resp.Body, nil
168+
}
169+
170+
// PutFile is not supported for HTTP datastore.
171+
func (h *HTTPDataStore) PutFile(ctx context.Context, path string, in io.WriterTo, metaData map[string]string) error {
172+
return errors.New("HTTP datastore is read-only, PutFile not supported")
173+
}
174+
175+
// PutFileIfNotExists is not supported for HTTP datastore.
176+
func (h *HTTPDataStore) PutFileIfNotExists(ctx context.Context, path string, in io.WriterTo, metaData map[string]string) (bool, error) {
177+
return false, errors.New("HTTP datastore is read-only, PutFileIfNotExists not supported")
178+
}
179+
180+
// Exists checks if a file exists by making a HEAD request.
181+
func (h *HTTPDataStore) Exists(ctx context.Context, filePath string) (bool, error) {
182+
resp, err := h.doHeadRequest(ctx, filePath)
183+
if err != nil {
184+
if errors.Is(err, os.ErrNotExist) {
185+
return false, nil
186+
}
187+
return false, err
188+
}
189+
defer resp.Body.Close()
190+
191+
return true, nil
192+
}
193+
194+
// Size retrieves the file size from Content-Length header.
195+
func (h *HTTPDataStore) Size(ctx context.Context, filePath string) (int64, error) {
196+
metadata, err := h.GetFileMetadata(ctx, filePath)
197+
if err != nil {
198+
return 0, err
199+
}
200+
201+
if contentLength, ok := metadata["content-length"]; ok {
202+
size, err := strconv.ParseInt(contentLength, 10, 64)
203+
if err != nil {
204+
return 0, fmt.Errorf("invalid content-length header: %s", contentLength)
205+
}
206+
return size, nil
207+
}
208+
209+
return 0, errors.New("content-length header not found")
210+
}
211+
212+
// ListFilePaths is not supported for HTTP datastore.
213+
func (h *HTTPDataStore) ListFilePaths(ctx context.Context, prefix string, limit int) ([]string, error) {
214+
return nil, errors.New("HTTP datastore does not support listing files")
215+
}
216+
217+
// Close does nothing for HTTPDataStore as it does not maintain a persistent connection.
218+
func (h *HTTPDataStore) Close() error {
219+
return nil
220+
}

0 commit comments

Comments
 (0)