Skip to content
Snippets Groups Projects
url.go 17.5 KiB
Newer Older
AlexStocks's avatar
AlexStocks committed
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
fangyincheng's avatar
fangyincheng committed

vito.he's avatar
vito.he committed
package common
fangyincheng's avatar
fangyincheng committed

import (
	"encoding/base64"
fangyincheng's avatar
fangyincheng committed
	"fmt"
zonghaishang's avatar
zonghaishang committed
	"math"
fangyincheng's avatar
fangyincheng committed
	"net"
	"net/url"
	"strconv"
	"strings"
)

import (
邹毅贤's avatar
邹毅贤 committed
	gxset "github.com/dubbogo/gost/container/set"
	"github.com/jinzhu/copier"
fangyincheng's avatar
fangyincheng committed
	perrors "github.com/pkg/errors"
xujianhai666's avatar
xujianhai666 committed
	"github.com/satori/go.uuid"
fangyincheng's avatar
fangyincheng committed
)
vito.he's avatar
vito.he committed
import (
	"github.com/apache/dubbo-go/common/constant"
flycash's avatar
flycash committed
	"github.com/apache/dubbo-go/common/logger"
vito.he's avatar
vito.he committed
)
flycash's avatar
flycash committed
// ///////////////////////////////
flycash's avatar
flycash committed
// ///////////////////////////////
邹毅贤's avatar
邹毅贤 committed
// role constant
	// CONSUMER is consumer role
	// CONFIGURATOR is configurator role
	// ROUTER is router role
	// PROVIDER is provider role
	PROTOCOL = "protocol"
	// DubboNodes Dubbo service node
	DubboNodes = [...]string{"consumers", "configurators", "routers", "providers"}
邹毅贤's avatar
邹毅贤 committed
	// DubboRole Dubbo service role
邹毅贤's avatar
邹毅贤 committed
	DubboRole = [...]string{"consumer", "", "routers", "provider"}
type RoleType int

func (t RoleType) String() string {
	return DubboNodes[t]
}

// Role returns role by @RoleType
func (t RoleType) Role() string {
	return DubboRole[t]
vito.he's avatar
vito.he committed
type baseUrl struct {
flycash's avatar
flycash committed
	Protocol     string
	Location     string // ip+port
	Ip           string
	Port         string
vito.he's avatar
vito.he committed
	params       url.Values
vito.he's avatar
vito.he committed
	PrimitiveURL string
flycash's avatar
flycash committed
// URL is not thread-safe.
// we fail to define this struct to be immutable object.
// but, those method which will update the URL, including SetParam, SetParams
// are only allowed to be invoked in creating URL instance
// Please keep in mind that this struct is immutable after it has been created and initialized.
type URL struct {
vito.he's avatar
vito.he committed
	baseUrl
	Path     string // like  /com.ikurento.dubbo.UserProvider3
	Username string
	Password string
flycash's avatar
flycash committed
	// special for registry
	SubURL *URL
// Option accepts url
// Option will define a function of handling URL
type option func(*URL)

// WithUsername sets username for url
func WithUsername(username string) option {
	return func(url *URL) {
		url.Username = username
	}
}

// WithPassword sets password for url
func WithPassword(pwd string) option {
	return func(url *URL) {
		url.Password = pwd
	}
}
// WithMethods sets methods for url
func WithMethods(methods []string) option {
	return func(url *URL) {
		url.Methods = methods
	}
}

// WithParams sets params for url
func WithParams(params url.Values) option {
	return func(url *URL) {
vito.he's avatar
vito.he committed
		url.params = params
// WithParamsValue sets params field for url
func WithParamsValue(key, val string) option {
	return func(url *URL) {
vito.he's avatar
vito.he committed
		url.SetParam(key, val)
// WithProtocol sets protocol for url
vito.he's avatar
vito.he committed
func WithProtocol(proto string) option {
	return func(url *URL) {
		url.Protocol = proto
	}
}
vito.he's avatar
vito.he committed
func WithIp(ip string) option {
	return func(url *URL) {
		url.Ip = ip
	}
}

vito.he's avatar
vito.he committed
func WithPort(port string) option {
	return func(url *URL) {
		url.Port = port
	}
}

func WithPath(path string) option {
	return func(url *URL) {
		url.Path = "/" + strings.TrimPrefix(path, "/")
// WithLocation sets location for url
func WithLocation(location string) option {
	return func(url *URL) {
		url.Location = location
	}
}
xujianhai666's avatar
xujianhai666 committed
func WithToken(token string) option {
	return func(url *URL) {
		if len(token) > 0 {
			value := token
			if strings.ToLower(token) == "true" || strings.ToLower(token) == "default" {
flycash's avatar
flycash committed
				u, err := uuid.NewV4()
				if err != nil {
					logger.Errorf("could not generator UUID: %v", err)
flycash's avatar
flycash committed
					return
flycash's avatar
flycash committed
				}
				value = u.String()
xujianhai666's avatar
xujianhai666 committed
			}
			url.SetParam(constant.TOKEN_KEY, value)
		}
	}
}

// NewURLWithOptions will create a new url with options
func NewURLWithOptions(opts ...option) *URL {
	url := &URL{}
	for _, opt := range opts {
		opt(url)
	}
fangyincheng's avatar
fangyincheng committed
	url.Location = url.Ip + ":" + url.Port
// NewURL will create a new url
// the urlString should not be empty
func NewURL(urlString string, opts ...option) (URL, error) {
fangyincheng's avatar
fangyincheng committed
	var (
		err          error
		rawUrlString string
		serviceUrl   *url.URL
		s            = URL{baseUrl: baseUrl{}}
	// new a null instance
	if urlString == "" {
		return s, nil
	}

fangyincheng's avatar
fangyincheng committed
	rawUrlString, err = url.QueryUnescape(urlString)
	if err != nil {
fangyincheng's avatar
fangyincheng committed
		return s, perrors.Errorf("url.QueryUnescape(%s),  error{%v}", urlString, err)
flycash's avatar
flycash committed
	// rawUrlString = "//" + rawUrlString
	if !strings.Contains(rawUrlString, "//") {
		t := URL{baseUrl: baseUrl{}}
		for _, opt := range opts {
			opt(&t)
		}
		rawUrlString = t.Protocol + "://" + rawUrlString
	}
fangyincheng's avatar
fangyincheng committed
	serviceUrl, err = url.Parse(rawUrlString)
	if err != nil {
fangyincheng's avatar
fangyincheng committed
		return s, perrors.Errorf("url.Parse(url string{%s}),  error{%v}", rawUrlString, err)
vito.he's avatar
vito.he committed
	s.params, err = url.ParseQuery(serviceUrl.RawQuery)
fangyincheng's avatar
fangyincheng committed
	if err != nil {
fangyincheng's avatar
fangyincheng committed
		return s, perrors.Errorf("url.ParseQuery(raw url string{%s}),  error{%v}", serviceUrl.RawQuery, err)
vito.he's avatar
vito.he committed
	s.PrimitiveURL = urlString
	s.Protocol = serviceUrl.Scheme
	s.Username = serviceUrl.User.Username()
	s.Password, _ = serviceUrl.User.Password()
vito.he's avatar
vito.he committed
	s.Location = serviceUrl.Host
	s.Path = serviceUrl.Path
	if strings.Contains(s.Location, ":") {
		s.Ip, s.Port, err = net.SplitHostPort(s.Location)
fangyincheng's avatar
fangyincheng committed
		if err != nil {
aliiohs's avatar
aliiohs committed
			return s, perrors.Errorf("net.SplitHostPort(url.Host{%s}), error{%v}", s.Location, err)
fangyincheng's avatar
fangyincheng committed
	return s, nil
}

// URLEqual judge @url and @c is equal or not.
func (c URL) URLEqual(url URL) bool {
fangyincheng's avatar
fangyincheng committed
	c.Ip = ""
	c.Port = ""
vito.he's avatar
vito.he committed
	url.Ip = ""
	url.Port = ""
	cGroup := c.GetParam(constant.GROUP_KEY, "")
	urlGroup := url.GetParam(constant.GROUP_KEY, "")
	cKey := c.Key()
	urlKey := url.Key()

	if cGroup == constant.ANY_VALUE {
		cKey = strings.Replace(cKey, "group=*", "group="+urlGroup, 1)
	} else if urlGroup == constant.ANY_VALUE {
		urlKey = strings.Replace(urlKey, "group=*", "group="+cGroup, 1)
	}
Xargin's avatar
Xargin committed

	// 1. protocol, username, password, ip, port, service name, group, version should be equal
	if cKey != urlKey {
vito.he's avatar
vito.he committed
		return false
	}
Xargin's avatar
Xargin committed

	// 2. if url contains enabled key, should be true, or *
	if url.GetParam(constant.ENABLED_KEY, "true") != "true" && url.GetParam(constant.ENABLED_KEY, "") != constant.ANY_VALUE {
		return false
	}
Xargin's avatar
Xargin committed

flycash's avatar
flycash committed
	// TODO :may need add interface key any value condition
Xargin's avatar
Xargin committed
	return isMatchCategory(url.GetParam(constant.CATEGORY_KEY, constant.DEFAULT_CATEGORY), c.GetParam(constant.CATEGORY_KEY, constant.DEFAULT_CATEGORY))
vito.he's avatar
vito.he committed
}
xg.gao's avatar
xg.gao committed

func isMatchCategory(category1 string, category2 string) bool {
	if len(category2) == 0 {
		return category1 == constant.DEFAULT_CATEGORY
	} else if strings.Contains(category2, constant.ANY_VALUE) {
		return true
	} else if strings.Contains(category2, constant.REMOVE_VALUE_PREFIX) {
		return !strings.Contains(category2, constant.REMOVE_VALUE_PREFIX+category1)
	} else {
vito.he's avatar
vito.he committed
		return strings.Contains(category2, category1)
xg.gao's avatar
xg.gao committed

func (c URL) String() string {
watermelo's avatar
watermelo committed
	var buf strings.Builder
	if len(c.Username) == 0 && len(c.Password) == 0 {
watermelo's avatar
watermelo committed
		buf.WriteString(fmt.Sprintf(
			"%s://%s:%s%s?",
watermelo's avatar
watermelo committed
			c.Protocol, c.Ip, c.Port, c.Path))
	} else {
watermelo's avatar
watermelo committed
		buf.WriteString(fmt.Sprintf(
			"%s://%s:%s@%s:%s%s?",
watermelo's avatar
watermelo committed
			c.Protocol, c.Username, c.Password, c.Ip, c.Port, c.Path))
watermelo's avatar
watermelo committed
	buf.WriteString(c.params.Encode())
	return buf.String()
vito.he's avatar
vito.he committed
func (c URL) Key() string {
	buildString := fmt.Sprintf(
		"%s://%s:%s@%s:%s/?interface=%s&group=%s&version=%s",
fangyincheng's avatar
fangyincheng committed
		c.Protocol, c.Username, c.Password, c.Ip, c.Port, c.Service(), c.GetParam(constant.GROUP_KEY, ""), c.GetParam(constant.VERSION_KEY, ""))
vito.he's avatar
vito.he committed
	return buildString
// ServiceKey gets a unique key of a service.
func (c URL) ServiceKey() string {
	intf := c.GetParam(constant.INTERFACE_KEY, strings.TrimPrefix(c.Path, "/"))
	if intf == "" {
		return ""
	}
watermelo's avatar
watermelo committed
	var buf strings.Builder
	group := c.GetParam(constant.GROUP_KEY, "")
	if group != "" {
		buf.WriteString(group)
		buf.WriteString("/")
	}

	buf.WriteString(intf)

fangyincheng's avatar
fangyincheng committed
	version := c.GetParam(constant.VERSION_KEY, "")
	if version != "" && version != "0.0.0" {
		buf.WriteString(":")
		buf.WriteString(version)
	}

	return buf.String()
vito.he's avatar
vito.he committed
}

Ooo0oO0o0oO's avatar
Ooo0oO0o0oO committed
// ColonSeparatedKey
// The format is "{interface}:[version]:[group]"
func (c *URL) ColonSeparatedKey() string {
	intf := c.GetParam(constant.INTERFACE_KEY, strings.TrimPrefix(c.Path, "/"))
	if intf == "" {
		return ""
	}
watermelo's avatar
watermelo committed
	var buf strings.Builder
	buf.WriteString(intf)
	buf.WriteString(":")
	version := c.GetParam(constant.VERSION_KEY, "")
	if version != "" && version != "0.0.0" {
		buf.WriteString(version)
	}
	group := c.GetParam(constant.GROUP_KEY, "")
	buf.WriteString(":")
	if group != "" {
		buf.WriteString(group)
	}
	return buf.String()
}

// EncodedServiceKey encode the service key
func (c *URL) EncodedServiceKey() string {
	serviceKey := c.ServiceKey()
	return strings.Replace(serviceKey, "/", "*", 1)
}

func (c URL) Service() string {
	service := c.GetParam(constant.INTERFACE_KEY, strings.TrimPrefix(c.Path, "/"))
	if service != "" {
		return service
	} else if c.SubURL != nil {
		service = c.SubURL.GetParam(constant.INTERFACE_KEY, strings.TrimPrefix(c.Path, "/"))
flycash's avatar
flycash committed
		if service != "" { // if url.path is "" then return suburl's path, special for registry url
			return service
		}
	}
	return ""
flycash's avatar
flycash committed
// AddParam will add the key-value pair
// Not thread-safe
// think twice before using it.
func (c *URL) AddParam(key string, value string) {
vito.he's avatar
vito.he committed
	c.params.Add(key, value)
}

flycash's avatar
flycash committed
// SetParam will put the key-value pair into url
// it's not thread safe.
// think twice before you want to use this method
// usually it should only be invoked when you want to initialized an url
vito.he's avatar
vito.he committed
func (c *URL) SetParam(key string, value string) {
	c.params.Set(key, value)
flycash's avatar
flycash committed
// RangeParams will iterate the params
// it's not thread-safe
vito.he's avatar
vito.he committed
func (c *URL) RangeParams(f func(key, value string) bool) {
	for k, v := range c.params {
		if !f(k, v[0]) {
			break
		}
	}
}

func (c URL) GetParam(s string, d string) string {
	r := c.params.Get(s)
	if len(r) == 0 {
vito.he's avatar
vito.he committed

vito.he's avatar
vito.he committed
func (c URL) GetParams() url.Values {
	return c.params
}

// GetParamAndDecoded gets values and decode
aliiohs's avatar
aliiohs committed
func (c URL) GetParamAndDecoded(key string) (string, error) {
	ruleDec, err := base64.URLEncoding.DecodeString(c.GetParam(key, ""))
	value := string(ruleDec)
	return value, err
}
aliiohs's avatar
aliiohs committed
func (c URL) GetRawParam(key string) string {
aliiohs's avatar
aliiohs committed
	switch key {
	case PROTOCOL:
aliiohs's avatar
aliiohs committed
	case "username":
aliiohs's avatar
aliiohs committed
	case "host":
		return strings.Split(c.Location, ":")[0]
aliiohs's avatar
aliiohs committed
	case "password":
aliiohs's avatar
aliiohs committed
	case "port":
aliiohs's avatar
aliiohs committed
	case "path":
aliiohs's avatar
aliiohs committed
	default:
vito.he's avatar
vito.he committed
		return c.GetParam(key, "")
// GetParamBool judge whether @key exists or not
func (c URL) GetParamBool(key string, d bool) bool {
	r, err := strconv.ParseBool(c.GetParam(key, ""))
	if err != nil {
aliiohs's avatar
aliiohs committed
		return d
	}
	return r
}
// GetParamInt gets int value by @key
func (c URL) GetParamInt(key string, d int64) int64 {
	r, err := strconv.Atoi(c.GetParam(key, ""))
	if r == 0 || err != nil {
// GetMethodParamInt gets int method param
func (c URL) GetMethodParamInt(method string, key string, d int64) int64 {
	r, err := strconv.Atoi(c.GetParam("methods."+method+"."+key, ""))
	if r == 0 || err != nil {
// GetMethodParamInt64 gets int64 method param
zonghaishang's avatar
zonghaishang committed
func (c URL) GetMethodParamInt64(method string, key string, d int64) int64 {
	r := c.GetMethodParamInt(method, key, math.MinInt64)
	if r == math.MinInt64 {
		return c.GetParamInt(key, d)
	}
	return r
}

// GetMethodParam gets method param
func (c URL) GetMethodParam(method string, key string, d string) string {
	r := c.GetParam("methods."+method+"."+key, "")
	if r == "" {
// GetMethodParamBool judge whether @method param exists or not
Ooo0oO0o0oO's avatar
Ooo0oO0o0oO committed
func (c URL) GetMethodParamBool(method string, key string, d bool) bool {
	r := c.GetParamBool("methods."+method+"."+key, d)
	return r
}

flycash's avatar
flycash committed
// SetParams will put all key-value pair into url.
// 1. if there already has same key, the value will be override
// 2. it's not thread safe
// 3. think twice when you want to invoke this method
vito.he's avatar
vito.he committed
func (c *URL) SetParams(m url.Values) {
	for k := range m {
		c.SetParam(k, m.Get(k))
aliiohs's avatar
aliiohs committed
// ToMap transfer URL to Map
func (c URL) ToMap() map[string]string {
	paramsMap := make(map[string]string)
vito.he's avatar
vito.he committed
	c.RangeParams(func(key, value string) bool {
		paramsMap[key] = value
		return true
	})

aliiohs's avatar
aliiohs committed
	if c.Protocol != "" {
		paramsMap[PROTOCOL] = c.Protocol
aliiohs's avatar
aliiohs committed
	}
	if c.Username != "" {
		paramsMap["username"] = c.Username
	}
	if c.Password != "" {
		paramsMap["password"] = c.Password
	}
	if c.Location != "" {
		paramsMap["host"] = strings.Split(c.Location, ":")[0]
		var port string
		if strings.Contains(c.Location, ":") {
			port = strings.Split(c.Location, ":")[1]
		} else {
			port = "0"
		}
		paramsMap["port"] = port
aliiohs's avatar
aliiohs committed
	}
	if c.Protocol != "" {
		paramsMap[PROTOCOL] = c.Protocol
aliiohs's avatar
aliiohs committed
	}
	if c.Path != "" {
		paramsMap["path"] = c.Path
	}
aliiohs's avatar
aliiohs committed
	if len(paramsMap) == 0 {
		return nil
	}
aliiohs's avatar
aliiohs committed
	return paramsMap
}

// configuration  > reference config >service config
//  in this function we should merge the reference local url config into the service url from registry.
flycash's avatar
flycash committed
// TODO configuration merge, in the future , the configuration center's config should merge too.

// MergeUrl will merge those two url
// the result is based on serviceUrl, and the key which si only contained in referenceUrl
// will be added into result.
// for example, if serviceUrl contains params (a1->v1, b1->v2) and referenceUrl contains params(a2->v3, b1 -> v4)
// the params of result will be (a1->v1, b1->v2, a2->v3).
// You should notice that the value of b1 is v2, not v4.
// due to URL is not thread-safe, so this method is not thread-safe
vito.he's avatar
vito.he committed
func MergeUrl(serviceUrl *URL, referenceUrl *URL) *URL {
	mergedUrl := serviceUrl.Clone()
flycash's avatar
flycash committed
	// iterator the referenceUrl if serviceUrl not have the key ,merge in
vito.he's avatar
vito.he committed
	referenceUrl.RangeParams(func(key, value string) bool {
		if v := mergedUrl.GetParam(key, ""); len(v) == 0 {
			mergedUrl.SetParam(key, value)
vito.he's avatar
vito.he committed
		return true
flycash's avatar
flycash committed
	// loadBalance,cluster,retries strategy config
	methodConfigMergeFcn := mergeNormalParam(mergedUrl, referenceUrl, []string{constant.LOADBALANCE_KEY, constant.CLUSTER_KEY, constant.RETRIES_KEY, constant.TIMEOUT_KEY})
flycash's avatar
flycash committed
	// remote timestamp
vito.he's avatar
vito.he committed
	if v := serviceUrl.GetParam(constant.TIMESTAMP_KEY, ""); len(v) > 0 {
		mergedUrl.SetParam(constant.REMOTE_TIMESTAMP_KEY, v)
		mergedUrl.SetParam(constant.TIMESTAMP_KEY, referenceUrl.GetParam(constant.TIMESTAMP_KEY, ""))
flycash's avatar
flycash committed
	// finally execute methodConfigMergeFcn
	for _, method := range referenceUrl.Methods {
		for _, fcn := range methodConfigMergeFcn {
			fcn("methods." + method)
		}
	}

	return mergedUrl
}
flycash's avatar
flycash committed
// Clone will copy the url
func (c *URL) Clone() *URL {
	newUrl := &URL{}
	copier.Copy(newUrl, c)
vito.he's avatar
vito.he committed
	newUrl.params = url.Values{}
	c.RangeParams(func(key, value string) bool {
		newUrl.SetParam(key, value)
		return true
	})
vito.he's avatar
vito.he committed

flycash's avatar
flycash committed
func (c *URL) CloneExceptParams(excludeParams *gxset.HashSet) *URL {
	newUrl := &URL{}
	copier.Copy(newUrl, c)
	newUrl.params = url.Values{}
	c.RangeParams(func(key, value string) bool {
		if !excludeParams.Contains(key) {
			newUrl.SetParam(key, value)
		}
		return true
	})
	return newUrl
}

// Copy url based on the reserved parameter's keys.
xg.gao's avatar
xg.gao committed
func (c *URL) CloneWithParams(reserveParams []string) *URL {
	params := url.Values{}
	for _, reserveParam := range reserveParams {
		v := c.GetParam(reserveParam, "")
xg.gao's avatar
xg.gao committed
		if len(v) != 0 {
			params.Set(reserveParam, v)
		}
	}

	return NewURLWithOptions(
		WithProtocol(c.Protocol),
		WithUsername(c.Username),
		WithPassword(c.Password),
		WithIp(c.Ip),
		WithPort(c.Port),
		WithPath(c.Path),
xg.gao's avatar
xg.gao committed
		WithMethods(c.Methods),
Ian Luo's avatar
Ian Luo committed
// IsEquals compares if two URLs equals with each other. Excludes are all parameter keys which should ignored.
func IsEquals(left URL, right URL, excludes ...string) bool {
	if left.Ip != right.Ip || left.Port != right.Port {
		return false
	}

	leftMap := left.ToMap()
	rightMap := right.ToMap()
	for _, exclude := range excludes {
		delete(leftMap, exclude)
		delete(rightMap, exclude)
	}

	if len(leftMap) != len(rightMap) {
		return false
	}

	for lk, lv := range leftMap {
		if rv, ok := rightMap[lk]; !ok {
			return false
		} else if lv != rv {
			return false
		}
	}

	return true
}

vito.he's avatar
vito.he committed
func mergeNormalParam(mergedUrl *URL, referenceUrl *URL, paramKeys []string) []func(method string) {
	methodConfigMergeFcn := make([]func(method string), 0, len(paramKeys))
vito.he's avatar
vito.he committed
	for _, paramKey := range paramKeys {
vito.he's avatar
vito.he committed
		if v := referenceUrl.GetParam(paramKey, ""); len(v) > 0 {
			mergedUrl.SetParam(paramKey, v)
vito.he's avatar
vito.he committed
		}
		methodConfigMergeFcn = append(methodConfigMergeFcn, func(method string) {
vito.he's avatar
vito.he committed
			if v := referenceUrl.GetParam(method+"."+paramKey, ""); len(v) > 0 {
				mergedUrl.SetParam(method+"."+paramKey, v)
vito.he's avatar
vito.he committed
			}
		})
	}
vito.he's avatar
vito.he committed
	return methodConfigMergeFcn
vito.he's avatar
vito.he committed
}
flycash's avatar
flycash committed

flycash's avatar
flycash committed
// URLSlice will be used to sort URL instance
// Instances will be order by URL.String()
flycash's avatar
flycash committed
type URLSlice []URL

flycash's avatar
flycash committed
// nolint
flycash's avatar
flycash committed
func (s URLSlice) Len() int {
	return len(s)
}

flycash's avatar
flycash committed
// nolint
flycash's avatar
flycash committed
func (s URLSlice) Less(i, j int) bool {
	return s[i].String() < s[j].String()
}

flycash's avatar
flycash committed
// nolint
flycash's avatar
flycash committed
func (s URLSlice) Swap(i, j int) {
	s[i], s[j] = s[j], s[i]
}