initial commit
This commit is contained in:
134
internal/metal/providers/emapi/client.go
Normal file
134
internal/metal/providers/emapi/client.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package emapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
provider "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultHTTPTimeout = 20 * time.Second
|
||||
defaultBaseURL = "https://api.equinix.com/metal/v1"
|
||||
|
||||
authHeader = "X-Auth-Token"
|
||||
consumerHeader = "X-Consumer-Token"
|
||||
staffHeader = "X-Packet-Staff"
|
||||
staffHeaderValue = "true"
|
||||
)
|
||||
|
||||
var DefaultHTTPClient = &http.Client{
|
||||
Timeout: defaultHTTPTimeout,
|
||||
}
|
||||
|
||||
var _ provider.Provider = &Client{}
|
||||
|
||||
type Client struct {
|
||||
logger *zap.SugaredLogger
|
||||
httpClient *http.Client
|
||||
baseURL *url.URL
|
||||
authToken string
|
||||
consumerToken string
|
||||
}
|
||||
|
||||
func (c *Client) Do(req *http.Request, out any) (*http.Response, error) {
|
||||
if c.authToken != "" {
|
||||
req.Header.Set(authHeader, c.authToken)
|
||||
}
|
||||
|
||||
if c.consumerToken != "" {
|
||||
req.Header.Set(consumerHeader, c.consumerToken)
|
||||
}
|
||||
|
||||
if c.authToken != "" && c.consumerToken != "" {
|
||||
req.Header.Set(staffHeader, staffHeaderValue)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if out != nil {
|
||||
defer resp.Body.Close()
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return resp, err
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) DoRequest(ctx context.Context, method, path string, body io.Reader, out any) (*http.Response, error) {
|
||||
path = strings.TrimPrefix(path, c.baseURL.Path)
|
||||
|
||||
pathURL, err := url.Parse(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
url := c.baseURL.JoinPath(pathURL.Path)
|
||||
|
||||
query := url.Query()
|
||||
|
||||
for k, v := range pathURL.Query() {
|
||||
query[k] = v
|
||||
}
|
||||
|
||||
url.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, url.String(), body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.Do(req, out)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return resp, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// New creates a new emapi client
|
||||
func New(options ...Option) (*Client, error) {
|
||||
client := &Client{}
|
||||
|
||||
for _, opt := range options {
|
||||
if err := opt(client); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if client.logger == nil {
|
||||
client.logger = zap.NewNop().Sugar()
|
||||
}
|
||||
|
||||
if client.httpClient == nil {
|
||||
client.httpClient = DefaultHTTPClient
|
||||
}
|
||||
|
||||
if client.baseURL == nil {
|
||||
u, err := url.Parse(defaultBaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse emapi base url %s: %w", defaultBaseURL, err)
|
||||
}
|
||||
|
||||
client.baseURL = u
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
81
internal/metal/providers/emapi/config.go
Normal file
81
internal/metal/providers/emapi/config.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package emapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// Config provides configuration for connecting to the Equinix Metal API provider.
|
||||
type Config struct {
|
||||
// BaseURL is the baseurl to use when connecting to the Equinix Metal API Provider.
|
||||
BaseURL string
|
||||
|
||||
// AuthToken is the token to interact with the Equinix Metal API
|
||||
AuthToken string
|
||||
|
||||
// ConsumerToken is the token to grant higher privileges in the Equinix Metal API
|
||||
ConsumerToken string
|
||||
}
|
||||
|
||||
func (c Config) Populated() bool {
|
||||
return c.AuthToken != "" || c.ConsumerToken != "" || c.BaseURL != ""
|
||||
}
|
||||
|
||||
// WithConfig applies all configurations defined in the config.
|
||||
func WithConfig(config Config) Option {
|
||||
return func(c *Client) error {
|
||||
var options []Option
|
||||
|
||||
if config.BaseURL != "" {
|
||||
options = append(options, WithBaseURL(config.BaseURL))
|
||||
}
|
||||
|
||||
if config.AuthToken != "" {
|
||||
options = append(options, WithAuthToken(config.AuthToken))
|
||||
}
|
||||
|
||||
if config.ConsumerToken != "" {
|
||||
options = append(options, WithConsumerToken(config.ConsumerToken))
|
||||
}
|
||||
|
||||
for _, opt := range options {
|
||||
if err := opt(c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithBaseURL updates the baseurl used by the client.
|
||||
func WithBaseURL(baseURL string) Option {
|
||||
return func(c *Client) error {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse emapi base url %s: %w", baseURL, err)
|
||||
}
|
||||
|
||||
c.baseURL = u
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithAuthToken sets the auth token to authenticate the request with.
|
||||
func WithAuthToken(token string) Option {
|
||||
return func(c *Client) error {
|
||||
c.authToken = token
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithConsumerToken sets the consumer token to elevate privileges for the request.
|
||||
func WithConsumerToken(token string) Option {
|
||||
return func(c *Client) error {
|
||||
c.consumerToken = token
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
5
internal/metal/providers/emapi/errors.go
Normal file
5
internal/metal/providers/emapi/errors.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package emapi
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrBaseURLRequired = errors.New("emapi base url required")
|
||||
84
internal/metal/providers/emapi/memberships.go
Normal file
84
internal/metal/providers/emapi/memberships.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package emapi
|
||||
|
||||
import (
|
||||
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
|
||||
)
|
||||
|
||||
type Roles []string
|
||||
|
||||
type Memberships []*Membership
|
||||
|
||||
func (m Memberships) ToDetailsWithOrganizationDetails(orgDetails *models.OrganizationDetails) []*models.Membership[models.OrganizationDetails] {
|
||||
memberships := make([]*models.Membership[models.OrganizationDetails], len(m))
|
||||
|
||||
var nextIndex int
|
||||
|
||||
for _, membership := range m {
|
||||
memberships[nextIndex] = membership.ToDetailsWithOrganizationDetails(orgDetails)
|
||||
|
||||
if memberships[nextIndex] != nil {
|
||||
nextIndex++
|
||||
}
|
||||
}
|
||||
|
||||
if nextIndex < len(m) {
|
||||
return memberships[:nextIndex]
|
||||
}
|
||||
|
||||
return memberships
|
||||
}
|
||||
|
||||
func (m Memberships) ToDetailsWithProjectDetails(projDetails *models.ProjectDetails) []*models.Membership[models.ProjectDetails] {
|
||||
memberships := make([]*models.Membership[models.ProjectDetails], len(m))
|
||||
|
||||
var nextIndex int
|
||||
|
||||
for _, membership := range m {
|
||||
memberships[nextIndex] = membership.ToDetailsWithProjectDetails(projDetails)
|
||||
|
||||
if memberships[nextIndex] != nil {
|
||||
nextIndex++
|
||||
}
|
||||
}
|
||||
|
||||
if nextIndex < len(m) {
|
||||
return memberships[:nextIndex]
|
||||
}
|
||||
|
||||
return memberships
|
||||
}
|
||||
|
||||
type Membership struct {
|
||||
client *Client
|
||||
|
||||
HREF string `json:"href"`
|
||||
ID string `json:"id"`
|
||||
Roles Roles `json:"roles"`
|
||||
User *User `json:"user"`
|
||||
}
|
||||
|
||||
func (m *Membership) ToDetailsWithOrganizationDetails(orgDetails *models.OrganizationDetails) *models.Membership[models.OrganizationDetails] {
|
||||
if m.ID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.Membership[models.OrganizationDetails]{
|
||||
ID: m.ID,
|
||||
Roles: m.Roles,
|
||||
User: m.User.ToDetails(),
|
||||
Entity: orgDetails,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Membership) ToDetailsWithProjectDetails(projDetails *models.ProjectDetails) *models.Membership[models.ProjectDetails] {
|
||||
if m.ID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.Membership[models.ProjectDetails]{
|
||||
ID: m.ID,
|
||||
Roles: m.Roles,
|
||||
User: m.User.ToDetails(),
|
||||
Entity: projDetails,
|
||||
}
|
||||
}
|
||||
17
internal/metal/providers/emapi/options.go
Normal file
17
internal/metal/providers/emapi/options.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package emapi
|
||||
|
||||
import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Option is a Client configuration Option definition.
|
||||
type Option func(c *Client) error
|
||||
|
||||
// WithLogger sets the logger for the client.
|
||||
func WithLogger(logger *zap.SugaredLogger) Option {
|
||||
return func(c *Client) error {
|
||||
c.logger = logger
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
84
internal/metal/providers/emapi/organizations.go
Normal file
84
internal/metal/providers/emapi/organizations.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package emapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"go.infratographer.com/x/gidx"
|
||||
|
||||
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
organizationsPath = "/organizations"
|
||||
)
|
||||
|
||||
type Organizations []*Organization
|
||||
|
||||
func (o Organizations) ToDetails() []*models.OrganizationDetails {
|
||||
orgs := make([]*models.OrganizationDetails, len(o))
|
||||
|
||||
var nextIndex int
|
||||
|
||||
for _, org := range o {
|
||||
orgs[nextIndex] = org.ToDetails()
|
||||
|
||||
if orgs[nextIndex] != nil {
|
||||
nextIndex++
|
||||
}
|
||||
}
|
||||
|
||||
if nextIndex < len(o) {
|
||||
return orgs[:nextIndex]
|
||||
}
|
||||
|
||||
return orgs
|
||||
}
|
||||
|
||||
type Organization struct {
|
||||
client *Client
|
||||
|
||||
HREF string `json:"href"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Memberships Memberships `json:"memberships"`
|
||||
Projects Projects `json:"projects"`
|
||||
}
|
||||
|
||||
func (o *Organization) ToDetails() *models.OrganizationDetails {
|
||||
if o == nil || o.ID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
details := &models.OrganizationDetails{
|
||||
ID: o.ID,
|
||||
Name: o.Name,
|
||||
Projects: o.Projects.ToDetails(),
|
||||
}
|
||||
|
||||
details.Memberships = o.Memberships.ToDetailsWithOrganizationDetails(details)
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
func (c *Client) getOrganizationWithMemberships(ctx context.Context, id string) (*Organization, error) {
|
||||
var org Organization
|
||||
|
||||
_, err := c.DoRequest(ctx, http.MethodGet, organizationsPath+"/"+id+"?include=memberships.user,projects.memberships.user", nil, &org)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading organization: %w", err)
|
||||
}
|
||||
|
||||
return &org, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetOrganizationDetails(ctx context.Context, id gidx.PrefixedID) (*models.OrganizationDetails, error) {
|
||||
org, err := c.getOrganizationWithMemberships(ctx, id.String()[gidx.PrefixPartLength+1:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return org.ToDetails(), nil
|
||||
}
|
||||
84
internal/metal/providers/emapi/projects.go
Normal file
84
internal/metal/providers/emapi/projects.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package emapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"go.infratographer.com/x/gidx"
|
||||
|
||||
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
projectsPath = "/projects"
|
||||
)
|
||||
|
||||
type Projects []*Project
|
||||
|
||||
func (p Projects) ToDetails() []*models.ProjectDetails {
|
||||
projects := make([]*models.ProjectDetails, len(p))
|
||||
|
||||
var nextIndex int
|
||||
|
||||
for _, project := range p {
|
||||
projects[nextIndex] = project.ToDetails()
|
||||
|
||||
if projects[nextIndex] != nil {
|
||||
nextIndex++
|
||||
}
|
||||
}
|
||||
|
||||
if nextIndex < len(p) {
|
||||
return projects[:nextIndex]
|
||||
}
|
||||
|
||||
return projects
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
client *Client
|
||||
|
||||
HREF string `json:"href"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Memberships Memberships `json:"memberships"`
|
||||
Organization *Organization `json:"organization"`
|
||||
}
|
||||
|
||||
func (p *Project) ToDetails() *models.ProjectDetails {
|
||||
if p == nil || p.ID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
details := &models.ProjectDetails{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Organization: p.Organization.ToDetails(),
|
||||
}
|
||||
|
||||
details.Memberships = p.Memberships.ToDetailsWithProjectDetails(details)
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
func (c *Client) getProject(ctx context.Context, id string) (*Project, error) {
|
||||
var project Project
|
||||
|
||||
_, err := c.DoRequest(ctx, http.MethodGet, c.baseURL.JoinPath(projectsPath, id).String(), nil, &project)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading project: %w", err)
|
||||
}
|
||||
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetProjectDetails(ctx context.Context, id gidx.PrefixedID) (*models.ProjectDetails, error) {
|
||||
project, err := c.getProject(ctx, id.String()[gidx.PrefixPartLength+1:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return project.ToDetails(), nil
|
||||
}
|
||||
80
internal/metal/providers/emapi/users.go
Normal file
80
internal/metal/providers/emapi/users.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package emapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"go.infratographer.com/x/gidx"
|
||||
|
||||
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
usersPath = "/users"
|
||||
)
|
||||
|
||||
type Users []*User
|
||||
|
||||
func (u Users) ToDetails() []*models.UserDetails {
|
||||
users := make([]*models.UserDetails, len(u))
|
||||
|
||||
var nextIndex int
|
||||
|
||||
for _, user := range u {
|
||||
users[nextIndex] = user.ToDetails()
|
||||
|
||||
if users[nextIndex] != nil {
|
||||
nextIndex++
|
||||
}
|
||||
}
|
||||
|
||||
if nextIndex < len(u) {
|
||||
return users[:nextIndex]
|
||||
}
|
||||
|
||||
return users
|
||||
}
|
||||
|
||||
type User struct {
|
||||
client *Client
|
||||
|
||||
HREF string `json:"href"`
|
||||
ID string `json:"id"`
|
||||
FullName string `json:"full_name"`
|
||||
Email string `json:"email"`
|
||||
Projects Projects `json:"projects"`
|
||||
}
|
||||
|
||||
func (u *User) ToDetails() *models.UserDetails {
|
||||
if u.ID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.UserDetails{
|
||||
ID: u.ID,
|
||||
FullName: u.FullName,
|
||||
Organizations: nil,
|
||||
Projects: u.Projects.ToDetails(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) getUser(ctx context.Context, id string) (*User, error) {
|
||||
var user User
|
||||
|
||||
_, err := c.DoRequest(ctx, http.MethodGet, c.baseURL.JoinPath(usersPath, id).String(), nil, &user)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading user: %w", err)
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*models.UserDetails, error) {
|
||||
user, err := c.getUser(ctx, id.String()[gidx.PrefixPartLength+1:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user.ToDetails(), nil
|
||||
}
|
||||
Reference in New Issue
Block a user