Skip to content

Commit

Permalink
feat: Implement environment update functionality in launchdevly ui (#423
Browse files Browse the repository at this point in the history
)

* Implement environment update functionality in launchdevly ui

* Update internal/dev_server/dev_server.go

* Apply suggestions from code review

* Update away from project specific and use recursive paginator

* Refactor to generic implementation, add test

* Move environment

* Remove comment
  • Loading branch information
cdelst authored Sep 12, 2024
1 parent 55350d3 commit 216dc95
Show file tree
Hide file tree
Showing 19 changed files with 945 additions and 56 deletions.
83 changes: 66 additions & 17 deletions internal/dev_server/adapters/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import (
"net/url"
"strconv"

ldapi "github.com/launchdarkly/api-client-go/v14"
"github.com/pkg/errors"

ldapi "github.com/launchdarkly/api-client-go/v14"
)

const ctxKeyApi = ctxKey("adapters.api")
Expand All @@ -24,6 +25,7 @@ func GetApi(ctx context.Context) Api {
type Api interface {
GetSdkKey(ctx context.Context, projectKey, environmentKey string) (string, error)
GetAllFlags(ctx context.Context, projectKey string) ([]ldapi.FeatureFlag, error)
GetProjectEnvironments(ctx context.Context, projectKey string) ([]ldapi.Environment, error)
}

type apiClientApi struct {
Expand Down Expand Up @@ -52,13 +54,59 @@ func (a apiClientApi) GetAllFlags(ctx context.Context, projectKey string) ([]lda
return flags, err
}

func (a apiClientApi) GetProjectEnvironments(ctx context.Context, projectKey string) ([]ldapi.Environment, error) {
log.Printf("Fetching all environments for project '%s'", projectKey)
environments, err := a.getEnvironments(ctx, projectKey, nil)
if err != nil {
err = errors.Wrap(err, "unable to get environments from LD API")
}
return environments, err
}

func (a apiClientApi) getFlags(ctx context.Context, projectKey string, href *string) ([]ldapi.FeatureFlag, error) {
var featureFlags *ldapi.FeatureFlags
return getPaginatedItems(ctx, projectKey, href, func(ctx context.Context, projectKey string, limit, offset *int64) (*ldapi.FeatureFlags, error) {
query := a.apiClient.FeatureFlagsApi.GetFeatureFlags(ctx, projectKey)

if limit != nil {
query = query.Limit(*limit)
}

if offset != nil {
query = query.Offset(*offset)
}

flags, _, err := query.
Execute()
return flags, err
})
}

func (a apiClientApi) getEnvironments(ctx context.Context, projectKey string, href *string) ([]ldapi.Environment, error) {
return getPaginatedItems(ctx, projectKey, href, func(ctx context.Context, projectKey string, limit, offset *int64) (*ldapi.Environments, error) {
request := a.apiClient.EnvironmentsApi.GetEnvironmentsByProject(ctx, projectKey)
if limit != nil {
request = request.Limit(*limit)
}

if offset != nil {
request = request.Offset(*offset)
}

envs, _, err := request.
Execute()
return envs, err
})
}

func getPaginatedItems[T any, R interface {
GetItems() []T
GetLinks() map[string]ldapi.Link
}](ctx context.Context, projectKey string, href *string, fetchFunc func(context.Context, string, *int64, *int64) (R, error)) ([]T, error) {
var result R
var err error

if href == nil {
featureFlags, _, err = a.apiClient.FeatureFlagsApi.GetFeatureFlags(ctx, projectKey).
Summary(false).
Execute()
result, err = fetchFunc(ctx, projectKey, nil, nil)
if err != nil {
return nil, err
}
Expand All @@ -67,24 +115,25 @@ func (a apiClientApi) getFlags(ctx context.Context, projectKey string, href *str
if err != nil {
return nil, errors.Wrapf(err, "unable to parse href for next link: %s", *href)
}
featureFlags, _, err = a.apiClient.FeatureFlagsApi.GetFeatureFlags(ctx, projectKey).
Summary(false).
Limit(limit).
Offset(offset).
Execute()
result, err = fetchFunc(ctx, projectKey, &limit, &offset)
if err != nil {
return nil, err
}
}
flags := featureFlags.Items
if next, ok := featureFlags.Links["next"]; ok && next.Href != nil {
newFlags, err := a.getFlags(ctx, projectKey, next.Href)
if err != nil {
return nil, err

items := result.GetItems()

if links := result.GetLinks(); links != nil {
if next, ok := links["next"]; ok && next.Href != nil {
newItems, err := getPaginatedItems(ctx, projectKey, next.Href, fetchFunc)
if err != nil {
return nil, err
}
items = append(items, newItems...)
}
flags = append(flags, newFlags...)
}
return flags, nil

return items, nil
}

func parseHref(href string) (limit, offset int64, err error) {
Expand Down
148 changes: 148 additions & 0 deletions internal/dev_server/adapters/api_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package adapters

import (
"context"
"testing"

ldapi "github.com/launchdarkly/api-client-go/v14"
"github.com/stretchr/testify/assert"
)

type testItem struct {
ID string
}

type testResult struct {
items []testItem
links map[string]ldapi.Link
}

func (r testResult) GetItems() []testItem {
return r.items
}

func (r testResult) GetLinks() map[string]ldapi.Link {
return r.links
}

func TestGetPaginatedItems(t *testing.T) {
ctx := context.Background()
projectKey := "test-project"

testCases := []struct {
name string
fetchResponses []testResult
expectedItems []testItem
expectedError bool
}{
{
name: "Single page",
fetchResponses: []testResult{
{
items: []testItem{{ID: "1"}, {ID: "2"}},
links: map[string]ldapi.Link{},
},
},
expectedItems: []testItem{{ID: "1"}, {ID: "2"}},
},
{
name: "Multiple pages",
fetchResponses: []testResult{
{
items: []testItem{{ID: "1"}, {ID: "2"}},
links: map[string]ldapi.Link{
"next": {Href: strPtr("http://example.com?limit=2&offset=2")},
},
},
{
items: []testItem{{ID: "3"}, {ID: "4"}},
links: map[string]ldapi.Link{},
},
},
expectedItems: []testItem{{ID: "1"}, {ID: "2"}, {ID: "3"}, {ID: "4"}},
},
{
name: "Error on second page",
fetchResponses: []testResult{
{
items: []testItem{{ID: "1"}, {ID: "2"}},
links: map[string]ldapi.Link{
"next": {Href: strPtr("http://example.com?limit=2&offset=2")},
},
},
},
expectedError: true,
},
{
name: "Empty response",
fetchResponses: []testResult{
{
items: []testItem{},
links: map[string]ldapi.Link{},
},
},
expectedItems: []testItem{},
},
{
name: "Multiple pages with varying item counts",
fetchResponses: []testResult{
{
items: []testItem{{ID: "1"}, {ID: "2"}, {ID: "3"}},
links: map[string]ldapi.Link{
"next": {Href: strPtr("http://example.com?limit=3&offset=3")},
},
},
{
items: []testItem{{ID: "4"}, {ID: "5"}},
links: map[string]ldapi.Link{
"next": {Href: strPtr("http://example.com?limit=3&offset=5")},
},
},
{
items: []testItem{{ID: "6"}},
links: map[string]ldapi.Link{},
},
},
expectedItems: []testItem{{ID: "1"}, {ID: "2"}, {ID: "3"}, {ID: "4"}, {ID: "5"}, {ID: "6"}},
},
{
name: "Invalid next link",
fetchResponses: []testResult{
{
items: []testItem{{ID: "1"}, {ID: "2"}},
links: map[string]ldapi.Link{
"next": {Href: strPtr("invalid-url")},
},
},
},
expectedError: true,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
callCount := 0
fetchFunc := func(ctx context.Context, projectKey string, limit, offset *int64) (testResult, error) {
if callCount >= len(tc.fetchResponses) {
return testResult{}, assert.AnError
}
result := tc.fetchResponses[callCount]
callCount++
return result, nil
}

items, err := getPaginatedItems(ctx, projectKey, nil, fetchFunc)

if tc.expectedError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expectedItems, items)
}
})
}
}

func strPtr(s string) *string {
return &s
}
15 changes: 15 additions & 0 deletions internal/dev_server/adapters/mocks/api.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 32 additions & 0 deletions internal/dev_server/api/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,27 @@ paths:
description: OK. override removed
404:
description: no matching override found
/dev/projects/{projectKey}/environments:
get:
operationId: getProjectsEnvironments
summary: list all environments for the given project
parameters:
- $ref: "#/components/parameters/projectKey"
responses:
200:
description: OK. List of environments
content:
application/json:
schema:
description: list of environments
type: array
items:
$ref: "#/components/schemas/Environment"
uniqueItems: true
404:
$ref: "#/components/responses/ErrorResponse"
400:
$ref: "#/components/responses/ErrorResponse"
components:
parameters:
flagKey:
Expand Down Expand Up @@ -224,6 +245,17 @@ components:
type: integer
x-go-type: int64
description: unix timestamp for the lat time the flag values were synced from the source environment
Environment:
description: Environment
type: object
required:
- key
- name
properties:
key:
type: string
name:
type: string
responses:
FlagOverride:
description: Flag override
Expand Down
Loading

0 comments on commit 216dc95

Please sign in to comment.