Skip to content

Commit c0c6957

Browse files
authored
Add Link to StartOperationOptions (#17)
Encode/Decode Nexus links to/from HTTP headers.
1 parent 6447c77 commit c0c6957

File tree

6 files changed

+641
-2
lines changed

6 files changed

+641
-2
lines changed

Diff for: nexus/api.go

+157
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"fmt"
1111
"mime"
1212
"net/http"
13+
"net/url"
1314
"strings"
1415
"time"
1516
)
@@ -22,6 +23,7 @@ const (
2223
headerOperationState = "Nexus-Operation-State"
2324
headerOperationID = "Nexus-Operation-Id"
2425
headerRequestID = "Nexus-Request-Id"
26+
headerLink = "Nexus-Link"
2527

2628
// HeaderRequestTimeout is the total time to complete a Nexus HTTP request.
2729
HeaderRequestTimeout = "Request-Timeout"
@@ -145,6 +147,33 @@ func addCallbackHeaderToHTTPHeader(nexusHeader Header, httpHeader http.Header) h
145147
return httpHeader
146148
}
147149

150+
func addLinksToHTTPHeader(links []Link, httpHeader http.Header) error {
151+
for _, link := range links {
152+
encodedLink, err := encodeLink(link)
153+
if err != nil {
154+
return err
155+
}
156+
httpHeader.Add(headerLink, encodedLink)
157+
}
158+
return nil
159+
}
160+
161+
func getLinksFromHeader(httpHeader http.Header) ([]Link, error) {
162+
var links []Link
163+
headerValues := httpHeader.Values(headerLink)
164+
if len(headerValues) == 0 {
165+
return nil, nil
166+
}
167+
for _, encodedLink := range strings.Split(strings.Join(headerValues, ","), ",") {
168+
link, err := decodeLink(encodedLink)
169+
if err != nil {
170+
return nil, err
171+
}
172+
links = append(links, link)
173+
}
174+
return links, nil
175+
}
176+
148177
func httpHeaderToNexusHeader(httpHeader http.Header, excludePrefixes ...string) Header {
149178
header := Header{}
150179
headerLoop:
@@ -176,3 +205,131 @@ func addContextTimeoutToHTTPHeader(ctx context.Context, httpHeader http.Header)
176205
httpHeader.Set(HeaderRequestTimeout, time.Until(deadline).String())
177206
return httpHeader
178207
}
208+
209+
// Link contains an URL and a Type that can be used to decode the URL.
210+
// Links can contain any arbitrary information as a percent-encoded URL.
211+
// It can be used to pass information about the caller to the handler, or vice-versa.
212+
type Link struct {
213+
// URL information about the link.
214+
// It must be URL percent-encoded.
215+
URL *url.URL
216+
// Type can describe an actual data type for decoding the URL.
217+
// Valid chars: alphanumeric, '_', '.', '/'
218+
Type string
219+
}
220+
221+
const linkTypeKey = "type"
222+
223+
// decodeLink encodes the link to Nexus-Link header value.
224+
// It follows the same format of HTTP Link header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link
225+
func encodeLink(link Link) (string, error) {
226+
if err := validateLinkURL(link.URL); err != nil {
227+
return "", fmt.Errorf("failed to encode link: %w", err)
228+
}
229+
if err := validateLinkType(link.Type); err != nil {
230+
return "", fmt.Errorf("failed to encode link: %w", err)
231+
}
232+
return fmt.Sprintf(`<%s>; %s="%s"`, link.URL.String(), linkTypeKey, link.Type), nil
233+
}
234+
235+
// decodeLink decodes the Nexus-Link header values.
236+
// It must have the same format of HTTP Link header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link
237+
func decodeLink(encodedLink string) (Link, error) {
238+
var link Link
239+
encodedLink = strings.TrimSpace(encodedLink)
240+
if len(encodedLink) == 0 {
241+
return link, fmt.Errorf("failed to parse link header: value is empty")
242+
}
243+
244+
if encodedLink[0] != '<' {
245+
return link, fmt.Errorf("failed to parse link header: invalid format: %s", encodedLink)
246+
}
247+
urlEnd := strings.Index(encodedLink, ">")
248+
if urlEnd == -1 {
249+
return link, fmt.Errorf("failed to parse link header: invalid format: %s", encodedLink)
250+
}
251+
urlStr := strings.TrimSpace(encodedLink[1:urlEnd])
252+
if len(urlStr) == 0 {
253+
return link, fmt.Errorf("failed to parse link header: url is empty")
254+
}
255+
u, err := url.Parse(urlStr)
256+
if err != nil {
257+
return link, fmt.Errorf("failed to parse link header: invalid url: %s", urlStr)
258+
}
259+
if err := validateLinkURL(u); err != nil {
260+
return link, fmt.Errorf("failed to parse link header: %w", err)
261+
}
262+
link.URL = u
263+
264+
params := strings.Split(encodedLink[urlEnd+1:], ";")
265+
// must contain at least one semi-colon, and first param must be empty since
266+
// it corresponds to the url part parsed above.
267+
if len(params) < 2 {
268+
return link, fmt.Errorf("failed to parse link header: invalid format: %s", encodedLink)
269+
}
270+
if strings.TrimSpace(params[0]) != "" {
271+
return link, fmt.Errorf("failed to parse link header: invalid format: %s", encodedLink)
272+
}
273+
274+
typeKeyFound := false
275+
for _, param := range params[1:] {
276+
param = strings.TrimSpace(param)
277+
if len(param) == 0 {
278+
return link, fmt.Errorf("failed to parse link header: parameter is empty: %s", encodedLink)
279+
}
280+
kv := strings.SplitN(param, "=", 2)
281+
if len(kv) != 2 {
282+
return link, fmt.Errorf("failed to parse link header: invalid parameter format: %s", param)
283+
}
284+
key := strings.TrimSpace(kv[0])
285+
val := strings.TrimSpace(kv[1])
286+
if strings.HasPrefix(val, `"`) != strings.HasSuffix(val, `"`) {
287+
return link, fmt.Errorf(
288+
"failed to parse link header: parameter value missing double-quote: %s",
289+
param,
290+
)
291+
}
292+
if strings.HasPrefix(val, `"`) {
293+
val = val[1 : len(val)-1]
294+
}
295+
if key == linkTypeKey {
296+
if err := validateLinkType(val); err != nil {
297+
return link, fmt.Errorf("failed to parse link header: %w", err)
298+
}
299+
link.Type = val
300+
typeKeyFound = true
301+
}
302+
}
303+
if !typeKeyFound {
304+
return link, fmt.Errorf(
305+
"failed to parse link header: %q key not found: %s",
306+
linkTypeKey,
307+
encodedLink,
308+
)
309+
}
310+
311+
return link, nil
312+
}
313+
314+
func validateLinkURL(value *url.URL) error {
315+
if value == nil || value.String() == "" {
316+
return fmt.Errorf("url is empty")
317+
}
318+
_, err := url.ParseQuery(value.RawQuery)
319+
if err != nil {
320+
return fmt.Errorf("url query not percent-encoded: %s", value)
321+
}
322+
return nil
323+
}
324+
325+
func validateLinkType(value string) error {
326+
if len(value) == 0 {
327+
return fmt.Errorf("link type is empty")
328+
}
329+
for _, c := range value {
330+
if !(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') && !(c >= '0' && c <= '9') && c != '_' && c != '.' && c != '/' {
331+
return fmt.Errorf("link type contains invalid char (valid chars: alphanumeric, '_', '.', '/')")
332+
}
333+
}
334+
return nil
335+
}

0 commit comments

Comments
 (0)