copy regsrc and response from registry repo
keep these in one place for now
This commit is contained in:
parent
12b6ec9241
commit
248a5e4523
|
@ -0,0 +1,158 @@
|
||||||
|
package regsrc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/net/idna"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// InvalidHostString is a placeholder returned when a raw host can't be
|
||||||
|
// converted by IDNA spec. It will never be returned for any host for which
|
||||||
|
// Valid() is true.
|
||||||
|
InvalidHostString = "<invalid host>"
|
||||||
|
|
||||||
|
// urlLabelEndSubRe is a sub-expression that matches any character that's
|
||||||
|
// allowed at the start or end of a URL label according to RFC1123.
|
||||||
|
urlLabelEndSubRe = "[0-9A-Za-z]"
|
||||||
|
|
||||||
|
// urlLabelEndSubRe is a sub-expression that matches any character that's
|
||||||
|
// allowed at in a non-start or end of a URL label according to RFC1123.
|
||||||
|
urlLabelMidSubRe = "[0-9A-Za-z-]"
|
||||||
|
|
||||||
|
// urlLabelUnicodeSubRe is a sub-expression that matches any non-ascii char
|
||||||
|
// in an IDN (Unicode) display URL. It's not strict - there are only ~15k
|
||||||
|
// valid Unicode points in IDN RFC (some with conditions). We are just going
|
||||||
|
// with being liberal with matching and then erroring if we fail to convert
|
||||||
|
// to punycode later (which validates chars fully). This at least ensures
|
||||||
|
// ascii chars dissalowed by the RC1123 parts above don't become legal
|
||||||
|
// again.
|
||||||
|
urlLabelUnicodeSubRe = "[^[:ascii:]]"
|
||||||
|
|
||||||
|
// hostLabelSubRe is the sub-expression that matches a valid hostname label.
|
||||||
|
// It does not anchor the start or end so it can be composed into more
|
||||||
|
// complex RegExps below. Note that for sanity we don't handle disallowing
|
||||||
|
// raw punycode in this regexp (esp. since re2 doesn't support negative
|
||||||
|
// lookbehind, but we can capture it's presence here to check later).
|
||||||
|
hostLabelSubRe = "" +
|
||||||
|
// Match valid initial char, or unicode char
|
||||||
|
"(?:" + urlLabelEndSubRe + "|" + urlLabelUnicodeSubRe + ")" +
|
||||||
|
// Optionally, match 0 to 61 valid URL or Unicode chars,
|
||||||
|
// followed by one valid end char or unicode char
|
||||||
|
"(?:" +
|
||||||
|
"(?:" + urlLabelMidSubRe + "|" + urlLabelUnicodeSubRe + "){0,61}" +
|
||||||
|
"(?:" + urlLabelEndSubRe + "|" + urlLabelUnicodeSubRe + ")" +
|
||||||
|
")?"
|
||||||
|
|
||||||
|
// hostSubRe is the sub-expression that matches a valid host prefix.
|
||||||
|
// Allows custom port.
|
||||||
|
hostSubRe = hostLabelSubRe + "(?:\\." + hostLabelSubRe + ")+(?::\\d+)?"
|
||||||
|
|
||||||
|
// hostRe is a regexp that matches a valid host prefix. Additional
|
||||||
|
// validation of unicode strings is needed for matches.
|
||||||
|
hostRe = regexp.MustCompile("^" + hostSubRe + "$")
|
||||||
|
)
|
||||||
|
|
||||||
|
// FriendlyHost describes a registry instance identified in source strings by a
|
||||||
|
// simple bare hostname like registry.terraform.io.
|
||||||
|
type FriendlyHost struct {
|
||||||
|
Raw string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFriendlyHost(host string) *FriendlyHost {
|
||||||
|
return &FriendlyHost{Raw: host}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFriendlyHost attempts to parse a valid "friendly host" prefix from the
|
||||||
|
// given string. If no valid prefix is found, host will be nil and rest will
|
||||||
|
// contain the full source string. The host prefix must terminate at the end of
|
||||||
|
// the input or at the first / character. If one or more characters exist after
|
||||||
|
// the first /, they will be returned as rest (without the / delimiter).
|
||||||
|
// Hostnames containing punycode WILL be parsed successfully since they may have
|
||||||
|
// come from an internal normalized source string, however should be considered
|
||||||
|
// invalid if the string came from a user directly. This must be checked
|
||||||
|
// explicitly for user-input strings by calling Valid() on the
|
||||||
|
// returned host.
|
||||||
|
func ParseFriendlyHost(source string) (host *FriendlyHost, rest string) {
|
||||||
|
parts := strings.SplitN(source, "/", 2)
|
||||||
|
|
||||||
|
if hostRe.MatchString(parts[0]) {
|
||||||
|
host = &FriendlyHost{Raw: parts[0]}
|
||||||
|
if len(parts) == 2 {
|
||||||
|
rest = parts[1]
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// No match, return whole string as rest along with nil host
|
||||||
|
rest = source
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid returns whether the host prefix is considered valid in any case.
|
||||||
|
// Example of invalid prefixes might include ones that don't conform to the host
|
||||||
|
// name specifications. Not that IDN prefixes containing punycode are not valid
|
||||||
|
// input which we expect to always be in user-input or normalised display form.
|
||||||
|
func (h *FriendlyHost) Valid() bool {
|
||||||
|
if h.Display() == InvalidHostString {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if h.Normalized() == InvalidHostString {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if containsPuny(h.Raw) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display returns the host formatted for display to the user in CLI or web
|
||||||
|
// output.
|
||||||
|
func (h *FriendlyHost) Display() string {
|
||||||
|
parts := strings.SplitN(h.Raw, ":", 2)
|
||||||
|
var err error
|
||||||
|
parts[0], err = idna.Display.ToUnicode(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return InvalidHostString
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ":")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalized returns the host formatted for internal reference or comparison.
|
||||||
|
func (h *FriendlyHost) Normalized() string {
|
||||||
|
// For now IDNA does all the normalisation we need including case-folding
|
||||||
|
// pure ASCII to lower. But breaks if a custom port is included while we
|
||||||
|
// want to allow that and normalize comparison including it,
|
||||||
|
parts := strings.SplitN(h.Raw, ":", 2)
|
||||||
|
var err error
|
||||||
|
parts[0], err = idna.Lookup.ToASCII(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return InvalidHostString
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ":")
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the host formatted as the user originally typed it assuming it
|
||||||
|
// was parsed from user input.
|
||||||
|
func (h *FriendlyHost) String() string {
|
||||||
|
return h.Raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equal compares the FriendlyHost against another instance taking normalization
|
||||||
|
// into account.
|
||||||
|
func (h *FriendlyHost) Equal(other *FriendlyHost) bool {
|
||||||
|
if other == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return h.Normalized() == other.Normalized()
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsPuny(host string) bool {
|
||||||
|
for _, lbl := range strings.Split(host, ".") {
|
||||||
|
if strings.HasPrefix(strings.ToLower(lbl), "xn--") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
|
@ -0,0 +1,122 @@
|
||||||
|
package regsrc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFriendlyHost(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
source string
|
||||||
|
wantHost string
|
||||||
|
wantDisplay string
|
||||||
|
wantNorm string
|
||||||
|
wantValid bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple ascii",
|
||||||
|
source: "registry.terraform.io",
|
||||||
|
wantHost: "registry.terraform.io",
|
||||||
|
wantDisplay: "registry.terraform.io",
|
||||||
|
wantNorm: "registry.terraform.io",
|
||||||
|
wantValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed-case ascii",
|
||||||
|
source: "Registry.TerraForm.io",
|
||||||
|
wantHost: "Registry.TerraForm.io",
|
||||||
|
wantDisplay: "registry.terraform.io", // Display case folded
|
||||||
|
wantNorm: "registry.terraform.io",
|
||||||
|
wantValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IDN",
|
||||||
|
source: "ʎɹʇsıƃǝɹ.ɯɹoɟɐɹɹǝʇ.io",
|
||||||
|
wantHost: "ʎɹʇsıƃǝɹ.ɯɹoɟɐɹɹǝʇ.io",
|
||||||
|
wantDisplay: "ʎɹʇsıƃǝɹ.ɯɹoɟɐɹɹǝʇ.io",
|
||||||
|
wantNorm: "xn--s-fka0wmm0zea7g8b.xn--o-8ta85a3b1dwcda1k.io",
|
||||||
|
wantValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IDN TLD",
|
||||||
|
source: "zhongwen.中国",
|
||||||
|
wantHost: "zhongwen.中国",
|
||||||
|
wantDisplay: "zhongwen.中国",
|
||||||
|
wantNorm: "zhongwen.xn--fiqs8s",
|
||||||
|
wantValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IDN Case Folding",
|
||||||
|
source: "Испытание.com",
|
||||||
|
wantHost: "Испытание.com", // Raw input retains case
|
||||||
|
wantDisplay: "испытание.com", // Display form is unicode but case-folded
|
||||||
|
wantNorm: "xn--80akhbyknj4f.com",
|
||||||
|
wantValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Punycode is invalid as an input format",
|
||||||
|
source: "xn--s-fka0wmm0zea7g8b.xn--o-8ta85a3b1dwcda1k.io",
|
||||||
|
wantHost: "xn--s-fka0wmm0zea7g8b.xn--o-8ta85a3b1dwcda1k.io",
|
||||||
|
wantDisplay: "ʎɹʇsıƃǝɹ.ɯɹoɟɐɹɹǝʇ.io",
|
||||||
|
wantNorm: "xn--s-fka0wmm0zea7g8b.xn--o-8ta85a3b1dwcda1k.io",
|
||||||
|
wantValid: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-host prefix is left alone",
|
||||||
|
source: "foo/bar/baz",
|
||||||
|
wantHost: "",
|
||||||
|
wantDisplay: "",
|
||||||
|
wantNorm: "",
|
||||||
|
wantValid: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
// Matrix each test with prefix and total match variants
|
||||||
|
for _, sfx := range []string{"", "/", "/foo/bar/baz"} {
|
||||||
|
t.Run(tt.name+" suffix:"+sfx, func(t *testing.T) {
|
||||||
|
gotHost, gotRest := ParseFriendlyHost(tt.source + sfx)
|
||||||
|
|
||||||
|
if gotHost == nil {
|
||||||
|
if tt.wantHost != "" {
|
||||||
|
t.Fatalf("ParseFriendlyHost() gotHost = nil, want %v", tt.wantHost)
|
||||||
|
}
|
||||||
|
// If we return nil host, the whole input string should be in rest
|
||||||
|
if gotRest != (tt.source + sfx) {
|
||||||
|
t.Fatalf("ParseFriendlyHost() was nil rest = %s, want %v", gotRest, tt.source+sfx)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.wantHost == "" {
|
||||||
|
t.Fatalf("ParseFriendlyHost() gotHost.Raw = %v, want nil", gotHost.Raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
if v := gotHost.String(); v != tt.wantHost {
|
||||||
|
t.Fatalf("String() = %v, want %v", v, tt.wantHost)
|
||||||
|
}
|
||||||
|
if v := gotHost.Display(); v != tt.wantDisplay {
|
||||||
|
t.Fatalf("Display() = %v, want %v", v, tt.wantDisplay)
|
||||||
|
}
|
||||||
|
if v := gotHost.Normalized(); v != tt.wantNorm {
|
||||||
|
t.Fatalf("Normalized() = %v, want %v", v, tt.wantNorm)
|
||||||
|
}
|
||||||
|
if v := gotHost.Valid(); v != tt.wantValid {
|
||||||
|
t.Fatalf("Valid() = %v, want %v", v, tt.wantValid)
|
||||||
|
}
|
||||||
|
if gotRest != strings.TrimLeft(sfx, "/") {
|
||||||
|
t.Fatalf("ParseFriendlyHost() rest = %v, want %v", gotRest, strings.TrimLeft(sfx, "/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also verify that host compares equal with all the variants.
|
||||||
|
if !gotHost.Equal(&FriendlyHost{Raw: tt.wantDisplay}) {
|
||||||
|
t.Fatalf("Equal() should be true for %s and %s", tt.wantHost, tt.wantValid)
|
||||||
|
}
|
||||||
|
if !gotHost.Equal(&FriendlyHost{Raw: tt.wantNorm}) {
|
||||||
|
t.Fatalf("Equal() should be true for %s and %s", tt.wantHost, tt.wantNorm)
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,162 @@
|
||||||
|
package regsrc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidModuleSource = errors.New("not a valid registry module source")
|
||||||
|
|
||||||
|
// nameSubRe is the sub-expression that matches a valid module namespace or
|
||||||
|
// name. It's strictly a super-set of what GitHub allows for user/org and
|
||||||
|
// repo names respectively, but more restrictive than our original repo-name
|
||||||
|
// regex which allowed periods but could cause ambiguity with hostname
|
||||||
|
// prefixes. It does not anchor the start or end so it can be composed into
|
||||||
|
// more complex RegExps below. Alphanumeric with - and _ allowed in non
|
||||||
|
// leading or trailing positions. Max length 64 chars. (GitHub username is
|
||||||
|
// 38 max.)
|
||||||
|
nameSubRe = "[0-9A-Za-z](?:[0-9A-Za-z-_]{0,62}[0-9A-Za-z])?"
|
||||||
|
|
||||||
|
// providerSubRe is the sub-expression that matches a valid provider. It
|
||||||
|
// does not anchor the start or end so it can be composed into more complex
|
||||||
|
// RegExps below. Only lowercase chars and digits are supported in practice.
|
||||||
|
// Max length 64 chars.
|
||||||
|
providerSubRe = "[0-9a-z]{1,64}"
|
||||||
|
|
||||||
|
// moduleSourceRe is a regular expression that matches the basic
|
||||||
|
// namespace/name/provider[//...] format for registry sources. It assumes
|
||||||
|
// any FriendlyHost prefix has already been removed if present.
|
||||||
|
moduleSourceRe = regexp.MustCompile(
|
||||||
|
fmt.Sprintf("^(%s)\\/(%s)\\/(%s)(?:\\/\\/(.*))?$",
|
||||||
|
nameSubRe, nameSubRe, providerSubRe))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Module describes a Terraform Registry Module source.
|
||||||
|
type Module struct {
|
||||||
|
// RawHost is the friendly host prefix if one was present. It might be nil
|
||||||
|
// if the original source had no host prefix which implies
|
||||||
|
// PublicRegistryHost but is distinct from having an actual pointer to
|
||||||
|
// PublicRegistryHost since it encodes the fact the original string didn't
|
||||||
|
// include a host prefix at all which is significant for recovering actual
|
||||||
|
// input not just normalized form. Most callers should access it with Host()
|
||||||
|
// which will return public registry host instance if it's nil.
|
||||||
|
RawHost *FriendlyHost
|
||||||
|
RawNamespace string
|
||||||
|
RawName string
|
||||||
|
RawProvider string
|
||||||
|
RawSubmodule string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewModule construct a new module source from separate parts. Pass empty
|
||||||
|
// string if host or submodule are not needed.
|
||||||
|
func NewModule(host, namespace, name, provider, submodule string) *Module {
|
||||||
|
m := &Module{
|
||||||
|
RawNamespace: namespace,
|
||||||
|
RawName: name,
|
||||||
|
RawProvider: provider,
|
||||||
|
RawSubmodule: submodule,
|
||||||
|
}
|
||||||
|
if host != "" {
|
||||||
|
m.RawHost = NewFriendlyHost(host)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseModuleSource attempts to parse source as a Terraform registry module
|
||||||
|
// source. If the string is not found to be in a valid format,
|
||||||
|
// ErrInvalidModuleSource is returned. Note that this can only be used on
|
||||||
|
// "input" strings, e.g. either ones supplied by the user or potentially
|
||||||
|
// normalised but in Display form (unicode). It will fail to parse a source with
|
||||||
|
// a punycoded domain since this is not permitted input from a user. If you have
|
||||||
|
// an already normalized string internally, you can compare it without parsing
|
||||||
|
// by comparing with the normalized version of the subject with the normal
|
||||||
|
// string equality operator.
|
||||||
|
func ParseModuleSource(source string) (*Module, error) {
|
||||||
|
// See if there is a friendly host prefix.
|
||||||
|
host, rest := ParseFriendlyHost(source)
|
||||||
|
if host != nil && !host.Valid() {
|
||||||
|
return nil, ErrInvalidModuleSource
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := moduleSourceRe.FindStringSubmatch(rest)
|
||||||
|
if len(matches) < 4 {
|
||||||
|
return nil, ErrInvalidModuleSource
|
||||||
|
}
|
||||||
|
|
||||||
|
m := &Module{
|
||||||
|
RawHost: host,
|
||||||
|
RawNamespace: matches[1],
|
||||||
|
RawName: matches[2],
|
||||||
|
RawProvider: matches[3],
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matches) == 5 {
|
||||||
|
m.RawSubmodule = matches[4]
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display returns the source formatted for display to the user in CLI or web
|
||||||
|
// output.
|
||||||
|
func (m *Module) Display() string {
|
||||||
|
return m.formatWithPrefix(m.normalizedHostPrefix(m.Host().Display()), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalized returns the source formatted for internal reference or comparison.
|
||||||
|
func (m *Module) Normalized() string {
|
||||||
|
return m.formatWithPrefix(m.normalizedHostPrefix(m.Host().Normalized()), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the source formatted as the user originally typed it assuming
|
||||||
|
// it was parsed from user input.
|
||||||
|
func (m *Module) String() string {
|
||||||
|
// Don't normalize public registry hostname - leave it exactly like the user
|
||||||
|
// input it.
|
||||||
|
hostPrefix := ""
|
||||||
|
if m.RawHost != nil {
|
||||||
|
hostPrefix = m.RawHost.String() + "/"
|
||||||
|
}
|
||||||
|
return m.formatWithPrefix(hostPrefix, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equal compares the module source against another instance taking
|
||||||
|
// normalization into account.
|
||||||
|
func (m *Module) Equal(other *Module) bool {
|
||||||
|
return m.Normalized() == other.Normalized()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host returns the FriendlyHost object describing which registry this module is
|
||||||
|
// in. If the original source string had not host component this will return the
|
||||||
|
// PublicRegistryHost.
|
||||||
|
func (m *Module) Host() *FriendlyHost {
|
||||||
|
if m.RawHost == nil {
|
||||||
|
return PublicRegistryHost
|
||||||
|
}
|
||||||
|
return m.RawHost
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) normalizedHostPrefix(host string) string {
|
||||||
|
if m.Host().Equal(PublicRegistryHost) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return host + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) formatWithPrefix(hostPrefix string, preserveCase bool) string {
|
||||||
|
suffix := ""
|
||||||
|
if m.RawSubmodule != "" {
|
||||||
|
suffix = "//" + m.RawSubmodule
|
||||||
|
}
|
||||||
|
str := fmt.Sprintf("%s%s/%s/%s%s", hostPrefix, m.RawNamespace, m.RawName,
|
||||||
|
m.RawProvider, suffix)
|
||||||
|
|
||||||
|
// lower case by default
|
||||||
|
if !preserveCase {
|
||||||
|
return strings.ToLower(str)
|
||||||
|
}
|
||||||
|
return str
|
||||||
|
}
|
|
@ -0,0 +1,131 @@
|
||||||
|
package regsrc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestModule(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
source string
|
||||||
|
wantString string
|
||||||
|
wantDisplay string
|
||||||
|
wantNorm string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "public registry",
|
||||||
|
source: "hashicorp/consul/aws",
|
||||||
|
wantString: "hashicorp/consul/aws",
|
||||||
|
wantDisplay: "hashicorp/consul/aws",
|
||||||
|
wantNorm: "hashicorp/consul/aws",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "public registry, submodule",
|
||||||
|
source: "hashicorp/consul/aws//foo",
|
||||||
|
wantString: "hashicorp/consul/aws//foo",
|
||||||
|
wantDisplay: "hashicorp/consul/aws//foo",
|
||||||
|
wantNorm: "hashicorp/consul/aws//foo",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "public registry, explicit host",
|
||||||
|
source: "registry.terraform.io/hashicorp/consul/aws",
|
||||||
|
wantString: "registry.terraform.io/hashicorp/consul/aws",
|
||||||
|
wantDisplay: "hashicorp/consul/aws",
|
||||||
|
wantNorm: "hashicorp/consul/aws",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "public registry, mixed case",
|
||||||
|
source: "HashiCorp/Consul/aws",
|
||||||
|
wantString: "HashiCorp/Consul/aws",
|
||||||
|
wantDisplay: "hashicorp/consul/aws",
|
||||||
|
wantNorm: "hashicorp/consul/aws",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "private registry, custom port",
|
||||||
|
source: "Example.com:1234/HashiCorp/Consul/aws",
|
||||||
|
wantString: "Example.com:1234/HashiCorp/Consul/aws",
|
||||||
|
wantDisplay: "example.com:1234/hashicorp/consul/aws",
|
||||||
|
wantNorm: "example.com:1234/hashicorp/consul/aws",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IDN registry",
|
||||||
|
source: "Испытание.com/HashiCorp/Consul/aws",
|
||||||
|
wantString: "Испытание.com/HashiCorp/Consul/aws",
|
||||||
|
wantDisplay: "испытание.com/hashicorp/consul/aws",
|
||||||
|
wantNorm: "xn--80akhbyknj4f.com/hashicorp/consul/aws",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IDN registry, submodule, custom port",
|
||||||
|
source: "Испытание.com:1234/HashiCorp/Consul/aws//Foo",
|
||||||
|
wantString: "Испытание.com:1234/HashiCorp/Consul/aws//Foo",
|
||||||
|
// Note we DO lowercase submodule names. This might causes issues on
|
||||||
|
// some filesystems (e.g. HFS+) that are case-sensitive where
|
||||||
|
// //modules/Foo and //modules/foo describe different paths, but
|
||||||
|
// it's less confusing in general just to not support that. Any user
|
||||||
|
// with a module with submodules in both cases is already asking for
|
||||||
|
// portability issues, and terraform can ensure it does
|
||||||
|
// case-insensitive search for the dir in those cases.
|
||||||
|
wantDisplay: "испытание.com:1234/hashicorp/consul/aws//foo",
|
||||||
|
wantNorm: "xn--80akhbyknj4f.com:1234/hashicorp/consul/aws//foo",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid host",
|
||||||
|
source: "---.com/HashiCorp/Consul/aws",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid format",
|
||||||
|
source: "foo/var/baz/qux",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid suffix",
|
||||||
|
source: "foo/var/baz?otherthing",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid host, invalid format",
|
||||||
|
source: "foo.com/var/baz?otherthing",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ParseModuleSource(tt.source)
|
||||||
|
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("ParseModuleSource() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if v := got.String(); v != tt.wantString {
|
||||||
|
t.Fatalf("String() = %v, want %v", v, tt.wantString)
|
||||||
|
}
|
||||||
|
if v := got.Display(); v != tt.wantDisplay {
|
||||||
|
t.Fatalf("Display() = %v, want %v", v, tt.wantDisplay)
|
||||||
|
}
|
||||||
|
if v := got.Normalized(); v != tt.wantNorm {
|
||||||
|
t.Fatalf("Normalized() = %v, want %v", v, tt.wantNorm)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotDisplay, err := ParseModuleSource(tt.wantDisplay)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseModuleSource(wantDisplay) error = %v", err)
|
||||||
|
}
|
||||||
|
if !got.Equal(gotDisplay) {
|
||||||
|
t.Fatalf("Equal() failed for %s and %s", tt.source, tt.wantDisplay)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
// Package regsrc provides helpers for working with source strings that identify
|
||||||
|
// resources within a Terraform registry.
|
||||||
|
package regsrc
|
||||||
|
|
||||||
|
var (
|
||||||
|
// PublicRegistryHost is a FriendlyHost that represents the public registry.
|
||||||
|
PublicRegistryHost = NewFriendlyHost("registry.terraform.io")
|
||||||
|
)
|
|
@ -0,0 +1,193 @@
|
||||||
|
package response
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hashicorp/terraform-registry/api/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Module is the response structure with the data for a single module version.
|
||||||
|
type Module struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
|
||||||
|
//---------------------------------------------------------------
|
||||||
|
// Metadata about the overall module.
|
||||||
|
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
PublishedAt time.Time `json:"published_at"`
|
||||||
|
Downloads int `json:"downloads"`
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModuleDetail represents a module in full detail.
|
||||||
|
type ModuleDetail struct {
|
||||||
|
Module
|
||||||
|
|
||||||
|
//---------------------------------------------------------------
|
||||||
|
// Metadata about the overall module. This is only available when
|
||||||
|
// requesting the specific module (not in list responses).
|
||||||
|
|
||||||
|
// Root is the root module.
|
||||||
|
Root *ModuleSubmodule `json:"root"`
|
||||||
|
|
||||||
|
// Submodules are the other submodules that are available within
|
||||||
|
// this module.
|
||||||
|
Submodules []*ModuleSubmodule `json:"submodules"`
|
||||||
|
|
||||||
|
//---------------------------------------------------------------
|
||||||
|
// The fields below are only set when requesting this specific
|
||||||
|
// module. They are available to easily know all available versions
|
||||||
|
// and providers without multiple API calls.
|
||||||
|
|
||||||
|
Providers []string `json:"providers"` // All available providers
|
||||||
|
Versions []string `json:"versions"` // All versions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModuleSubmodule is the metadata about a specific submodule within
|
||||||
|
// a module. This includes the root module as a special case.
|
||||||
|
type ModuleSubmodule struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Readme string `json:"readme"`
|
||||||
|
Empty bool `json:"empty"`
|
||||||
|
|
||||||
|
Inputs []*ModuleInput `json:"inputs"`
|
||||||
|
Outputs []*ModuleOutput `json:"outputs"`
|
||||||
|
Dependencies []*ModuleDep `json:"dependencies"`
|
||||||
|
Resources []*ModuleResource `json:"resources"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModuleInput is an input for a module.
|
||||||
|
type ModuleInput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Default string `json:"default"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModuleOutput is an output for a module.
|
||||||
|
type ModuleOutput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModuleDep is an output for a module.
|
||||||
|
type ModuleDep struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModuleProviderDep is the output for a provider dependency
|
||||||
|
type ModuleProviderDep struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModuleResource is an output for a module.
|
||||||
|
type ModuleResource struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewModule creates a Module response object from a model.
|
||||||
|
func NewModule(mv *models.ModuleVersion) *Module {
|
||||||
|
m := mv.ModuleProvider.Module
|
||||||
|
mp := mv.ModuleProvider
|
||||||
|
|
||||||
|
// Build the full module
|
||||||
|
return &Module{
|
||||||
|
ID: fmt.Sprintf(
|
||||||
|
"%s/%s/%s/%s",
|
||||||
|
m.Namespace,
|
||||||
|
m.Name,
|
||||||
|
mp.Provider,
|
||||||
|
mv.Version),
|
||||||
|
|
||||||
|
// Base metadata
|
||||||
|
Owner: m.User.Username,
|
||||||
|
Namespace: m.Namespace,
|
||||||
|
Name: m.Name,
|
||||||
|
Version: mv.Version,
|
||||||
|
Provider: mv.ModuleProvider.Provider,
|
||||||
|
Description: mv.Description,
|
||||||
|
Source: mp.Source,
|
||||||
|
PublishedAt: mv.PublishedAt,
|
||||||
|
Downloads: int(mp.Downloads),
|
||||||
|
Verified: m.Verified,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewModuleDetail creates a ModuleDetail response object from a model.
|
||||||
|
func NewModuleDetail(mv *models.ModuleVersion) *ModuleDetail {
|
||||||
|
m := NewModule(mv)
|
||||||
|
|
||||||
|
// Build the submodule response objects
|
||||||
|
var submodules []*ModuleSubmodule
|
||||||
|
var submoduleRoot *ModuleSubmodule
|
||||||
|
for _, sub := range mv.Submodules {
|
||||||
|
resp := NewModuleSubmodule(&sub)
|
||||||
|
|
||||||
|
if sub.Root() {
|
||||||
|
submoduleRoot = resp
|
||||||
|
} else {
|
||||||
|
submodules = append(submodules, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &ModuleDetail{
|
||||||
|
Module: *m,
|
||||||
|
Root: submoduleRoot,
|
||||||
|
Submodules: submodules,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewModuleSubmodule creates a ModuleSubmodule response object from a model.
|
||||||
|
func NewModuleSubmodule(m *models.ModuleSubmodule) *ModuleSubmodule {
|
||||||
|
inputs := make([]*ModuleInput, 0, len(m.Variables))
|
||||||
|
for _, v := range m.Variables {
|
||||||
|
inputs = append(inputs, &ModuleInput{
|
||||||
|
Name: v.Name,
|
||||||
|
Description: v.Description.String,
|
||||||
|
Default: v.Default.String,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
outputs := make([]*ModuleOutput, 0, len(m.Outputs))
|
||||||
|
for _, v := range m.Outputs {
|
||||||
|
outputs = append(outputs, &ModuleOutput{
|
||||||
|
Name: v.Name,
|
||||||
|
Description: v.Description.String,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
deps := make([]*ModuleDep, 0, len(m.Dependencies))
|
||||||
|
for _, v := range m.Dependencies {
|
||||||
|
deps = append(deps, &ModuleDep{
|
||||||
|
Name: v.Name,
|
||||||
|
Source: v.Source,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resources := make([]*ModuleResource, 0, len(m.Resources))
|
||||||
|
for _, v := range m.Resources {
|
||||||
|
resources = append(resources, &ModuleResource{
|
||||||
|
Name: v.Name,
|
||||||
|
Type: v.Type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ModuleSubmodule{
|
||||||
|
Path: m.Path,
|
||||||
|
Readme: m.Readme,
|
||||||
|
Empty: m.Empty,
|
||||||
|
Inputs: inputs,
|
||||||
|
Outputs: outputs,
|
||||||
|
Dependencies: deps,
|
||||||
|
Resources: resources,
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
package response
|
||||||
|
|
||||||
|
// ModuleList is the response structure for a pageable list of modules.
|
||||||
|
type ModuleList struct {
|
||||||
|
Meta PaginationMeta `json:"meta"`
|
||||||
|
Modules []*Module `json:"modules"`
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
package response
|
||||||
|
|
||||||
|
// ModuleProvider represents a single provider for modules.
|
||||||
|
type ModuleProvider struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Downloads int `json:"downloads"`
|
||||||
|
ModuleCount int `json:"module_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModuleProviderList is the response structure for a pageable list of ModuleProviders.
|
||||||
|
type ModuleProviderList struct {
|
||||||
|
Meta PaginationMeta `json:"meta"`
|
||||||
|
Providers []*ModuleProvider `json:"providers"`
|
||||||
|
}
|
|
@ -0,0 +1,131 @@
|
||||||
|
package response
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/hashicorp/terraform-registry/api/regsrc"
|
||||||
|
|
||||||
|
"github.com/hashicorp/terraform-registry/api/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ModuleVersions is the response format that contains all metadata about module
|
||||||
|
// versions needed for terraform CLI to resolve version constraints. See RFC
|
||||||
|
// TF-042 for details on this format.
|
||||||
|
type ModuleVersions struct {
|
||||||
|
Modules []*ModuleProviderVersions `json:"modules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModuleProviderVersions is the response format for a single module instance,
|
||||||
|
// containing metadata about all versions and their dependencies.
|
||||||
|
type ModuleProviderVersions struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
Versions []*ModuleVersion `json:"versions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModuleVersion is the output metadata for a given version needed by CLI to
|
||||||
|
// resolve candidate versions to satisfy requirements.
|
||||||
|
type ModuleVersion struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Root VersionSubmodule `json:"root"`
|
||||||
|
Submodules []*VersionSubmodule `json:"submodules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VersionSubmodule is the output metadata for a submodule within a given
|
||||||
|
// version needed by CLI to resolve candidate versions to satisfy requirements.
|
||||||
|
// When representing the Root in JSON the path is omitted.
|
||||||
|
type VersionSubmodule struct {
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
Providers []*ModuleProviderDep `json:"providers"`
|
||||||
|
Dependencies []*ModuleDep `json:"dependencies"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewModuleVersions populates a ModuleVersions response based on a slice of
|
||||||
|
// ModuleProviders. It is assumed these are fully populated with all versions
|
||||||
|
// submodules and dependencies etc, required in the response, and in the desired
|
||||||
|
// order (i.e. the first mp is the specific one requested and any others are
|
||||||
|
// optionally pre-fetched dependencies.) The host is needed to generate correct
|
||||||
|
// Source strings for all modules and must be the canonical hostname for the
|
||||||
|
// registry instance.
|
||||||
|
func NewModuleVersions(mps []*models.ModuleProvider,
|
||||||
|
host regsrc.FriendlyHost) *ModuleVersions {
|
||||||
|
|
||||||
|
mods := make([]*ModuleProviderVersions, 0, len(mps))
|
||||||
|
for _, mp := range mps {
|
||||||
|
mods = append(mods, NewModuleProviderVersions(mp, host))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ModuleVersions{
|
||||||
|
Modules: mods,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewModuleProviderVersions constructs the metadata about a specific module
|
||||||
|
// for the ModuleVersions response.
|
||||||
|
func NewModuleProviderVersions(mp *models.ModuleProvider,
|
||||||
|
host regsrc.FriendlyHost) *ModuleProviderVersions {
|
||||||
|
|
||||||
|
src := regsrc.NewModule(
|
||||||
|
host.String(),
|
||||||
|
mp.Module.Namespace,
|
||||||
|
mp.Module.Name,
|
||||||
|
mp.Provider,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
versions := make([]*ModuleVersion, 0, len(mp.Versions))
|
||||||
|
for _, mv := range mp.Versions {
|
||||||
|
versions = append(versions, NewModuleVersion(&mv))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ModuleProviderVersions{
|
||||||
|
Source: src.Display(),
|
||||||
|
Versions: versions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewModuleVersion constructs the metadata about a specific module version
|
||||||
|
// for the ModuleVersions response.
|
||||||
|
func NewModuleVersion(mv *models.ModuleVersion) *ModuleVersion {
|
||||||
|
// Build the submodule response objects
|
||||||
|
var submodules []*VersionSubmodule
|
||||||
|
var submoduleRoot VersionSubmodule
|
||||||
|
for _, sub := range mv.Submodules {
|
||||||
|
resp := NewVersionSubmodule(&sub)
|
||||||
|
|
||||||
|
if sub.Root() {
|
||||||
|
submoduleRoot = *resp
|
||||||
|
} else {
|
||||||
|
submodules = append(submodules, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ModuleVersion{
|
||||||
|
Version: mv.Version,
|
||||||
|
Root: submoduleRoot,
|
||||||
|
Submodules: submodules,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewVersionSubmodule constructs a representation of a submodule within a
|
||||||
|
// specific module version for the ModuleVersions response.
|
||||||
|
func NewVersionSubmodule(m *models.ModuleSubmodule) *VersionSubmodule {
|
||||||
|
providerDeps := make([]*ModuleProviderDep, 0, len(m.ProviderDependencies))
|
||||||
|
for _, v := range m.ProviderDependencies {
|
||||||
|
providerDeps = append(providerDeps, &ModuleProviderDep{
|
||||||
|
Name: v.Provider,
|
||||||
|
Version: v.VersionConstraints,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
deps := make([]*ModuleDep, 0, len(m.Dependencies))
|
||||||
|
for _, v := range m.Dependencies {
|
||||||
|
deps = append(deps, &ModuleDep{
|
||||||
|
Name: v.Name,
|
||||||
|
Source: v.Source,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &VersionSubmodule{
|
||||||
|
Path: m.Path,
|
||||||
|
Providers: providerDeps,
|
||||||
|
Dependencies: deps,
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
package response
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PaginationMeta is a structure included in responses for pagination.
|
||||||
|
type PaginationMeta struct {
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
CurrentOffset int `json:"current_offset"`
|
||||||
|
NextOffset *int `json:"next_offset,omitempty"`
|
||||||
|
PrevOffset *int `json:"prev_offset,omitempty"`
|
||||||
|
NextURL string `json:"next_url,omitempty"`
|
||||||
|
PrevURL string `json:"prev_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPaginationMeta populates pagination meta data from result parameters
|
||||||
|
func NewPaginationMeta(offset, limit int, hasMore bool, currentURL string) PaginationMeta {
|
||||||
|
pm := PaginationMeta{
|
||||||
|
Limit: limit,
|
||||||
|
CurrentOffset: offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate next/prev offsets, leave nil if not valid pages
|
||||||
|
nextOffset := offset + limit
|
||||||
|
if hasMore {
|
||||||
|
pm.NextOffset = &nextOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
prevOffset := offset - limit
|
||||||
|
if prevOffset < 0 {
|
||||||
|
prevOffset = 0
|
||||||
|
}
|
||||||
|
if prevOffset < offset {
|
||||||
|
pm.PrevOffset = &prevOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
// If URL format provided, populate URLs. Intentionally swallow URL errors for now, API should
|
||||||
|
// catch missing URLs if we call with bad URL arg (and we care about them being present).
|
||||||
|
if currentURL != "" && pm.NextOffset != nil {
|
||||||
|
pm.NextURL, _ = setQueryParam(currentURL, "offset", *pm.NextOffset, 0)
|
||||||
|
}
|
||||||
|
if currentURL != "" && pm.PrevOffset != nil {
|
||||||
|
pm.PrevURL, _ = setQueryParam(currentURL, "offset", *pm.PrevOffset, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pm
|
||||||
|
}
|
||||||
|
|
||||||
|
func setQueryParam(baseURL, key string, val, defaultVal int) (string, error) {
|
||||||
|
u, err := url.Parse(baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
if val == defaultVal {
|
||||||
|
// elide param if it's the default value
|
||||||
|
q.Del(key)
|
||||||
|
} else {
|
||||||
|
q.Set(key, strconv.Itoa(val))
|
||||||
|
}
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
|
@ -0,0 +1,122 @@
|
||||||
|
package response
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func intPtr(i int) *int {
|
||||||
|
return &i
|
||||||
|
}
|
||||||
|
|
||||||
|
func prettyJSON(o interface{}) (string, error) {
|
||||||
|
bytes, err := json.MarshalIndent(o, "", "\t")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(bytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewPaginationMeta(t *testing.T) {
|
||||||
|
type args struct {
|
||||||
|
offset int
|
||||||
|
limit int
|
||||||
|
hasMore bool
|
||||||
|
currentURL string
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args args
|
||||||
|
wantJSON string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "first page",
|
||||||
|
args: args{0, 10, true, "http://foo.com/v1/bar"},
|
||||||
|
wantJSON: `{
|
||||||
|
"limit": 10,
|
||||||
|
"current_offset": 0,
|
||||||
|
"next_offset": 10,
|
||||||
|
"next_url": "http://foo.com/v1/bar?offset=10"
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "second page",
|
||||||
|
args: args{10, 10, true, "http://foo.com/v1/bar"},
|
||||||
|
wantJSON: `{
|
||||||
|
"limit": 10,
|
||||||
|
"current_offset": 10,
|
||||||
|
"next_offset": 20,
|
||||||
|
"prev_offset": 0,
|
||||||
|
"next_url": "http://foo.com/v1/bar?offset=20",
|
||||||
|
"prev_url": "http://foo.com/v1/bar"
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "last page",
|
||||||
|
args: args{40, 10, false, "http://foo.com/v1/bar"},
|
||||||
|
wantJSON: `{
|
||||||
|
"limit": 10,
|
||||||
|
"current_offset": 40,
|
||||||
|
"prev_offset": 30,
|
||||||
|
"prev_url": "http://foo.com/v1/bar?offset=30"
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "misaligned start ending exactly on boundary",
|
||||||
|
args: args{32, 10, false, "http://foo.com/v1/bar"},
|
||||||
|
wantJSON: `{
|
||||||
|
"limit": 10,
|
||||||
|
"current_offset": 32,
|
||||||
|
"prev_offset": 22,
|
||||||
|
"prev_url": "http://foo.com/v1/bar?offset=22"
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "misaligned start partially through first page",
|
||||||
|
args: args{5, 12, true, "http://foo.com/v1/bar"},
|
||||||
|
wantJSON: `{
|
||||||
|
"limit": 12,
|
||||||
|
"current_offset": 5,
|
||||||
|
"next_offset": 17,
|
||||||
|
"prev_offset": 0,
|
||||||
|
"next_url": "http://foo.com/v1/bar?offset=17",
|
||||||
|
"prev_url": "http://foo.com/v1/bar"
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no current URL",
|
||||||
|
args: args{10, 10, true, ""},
|
||||||
|
wantJSON: `{
|
||||||
|
"limit": 10,
|
||||||
|
"current_offset": 10,
|
||||||
|
"next_offset": 20,
|
||||||
|
"prev_offset": 0
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "#58 regression test",
|
||||||
|
args: args{1, 3, true, ""},
|
||||||
|
wantJSON: `{
|
||||||
|
"limit": 3,
|
||||||
|
"current_offset": 1,
|
||||||
|
"next_offset": 4,
|
||||||
|
"prev_offset": 0
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := NewPaginationMeta(tt.args.offset, tt.args.limit, tt.args.hasMore,
|
||||||
|
tt.args.currentURL)
|
||||||
|
gotJSON, err := prettyJSON(got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to marshal PaginationMeta to JSON: %s", err)
|
||||||
|
}
|
||||||
|
if gotJSON != tt.wantJSON {
|
||||||
|
// prettyJSON makes debugging easier due to the annoying pointer-to-ints, but it
|
||||||
|
// also implicitly tests JSON marshalling as we can see if it's omitting fields etc.
|
||||||
|
t.Fatalf("NewPaginationMeta() =\n%s\n want:\n%s\n", gotJSON, tt.wantJSON)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
package response
|
||||||
|
|
||||||
|
// Redirect causes the frontend to perform a window redirect.
|
||||||
|
type Redirect struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
Loading…
Reference in New Issue