Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d4e56f1
feat(reconcile): add a Webhook kind
rohilsurana Jul 17, 2026
03118fe
docs(reconcile): document the Webhook kind
rohilsurana Jul 17, 2026
e772c6d
feat(reconcile): make webhook subscribed_events optional, empty means…
rohilsurana Jul 18, 2026
49bcca0
docs(reconcile): note webhook subscribed_events is optional
rohilsurana Jul 18, 2026
a8ffd18
feat(reconcile): treat webhook subscribed_events as the full desired set
rohilsurana Jul 19, 2026
63acd9c
docs(reconcile): describe webhook subscribed_events as the full desir…
rohilsurana Jul 19, 2026
385797d
fix(reconcile): dedup webhook subscribed_events to avoid spurious plans
rohilsurana Jul 19, 2026
cb9c649
test(reconcile): assert a webhook update preserves its state
rohilsurana Jul 19, 2026
14828be
docs(reconcile): tidy reconcile command help wrapping
rohilsurana Jul 19, 2026
8db057c
fix(webhook): validate url and state and enforce url uniqueness serve…
rohilsurana Jul 20, 2026
9f2cc05
fix(webhook): map webhook validation errors to proper status codes
rohilsurana Jul 20, 2026
05029c8
feat(reconcile): implement Validate for the Webhook kind
rohilsurana Jul 20, 2026
ac9457c
fix(webhook): map list and delete webhook errors to status codes
rohilsurana Jul 21, 2026
9c7aa14
fix(webhook): restrict urls to http(s) and normalize them before matc…
rohilsurana Jul 21, 2026
e7a4043
fix(reconcile): converge webhook description and state to their defaults
rohilsurana Jul 22, 2026
05a899b
docs(reconcile): describe webhook description and state as default-co…
rohilsurana Jul 23, 2026
0039399
fix(webhook): reject http(s) urls with no host
rohilsurana Jul 23, 2026
987cce5
docs(reconcile): note webhook url is http(s) and name the missing end…
rohilsurana Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions cmd/reconcile.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ func ReconcileCommand(cliConfig *Config) *cli.Command {
Make platform resources match a desired-state YAML file, through the admin API.

Kinds: PlatformUser (platform admins and members), Permission (custom
permissions), Role (platform-level roles), and Preference (platform
settings). Deleting a permission or a custom role needs an explicit
'delete: true' on its entry; nothing is deleted by omission, and a predefined
role cannot be deleted. A preference left out of the file resets to its
default. Log in as a superuser (for example the bootstrap service account)
with --header.
permissions), Role (platform-level roles), Preference (platform
settings), and Webhook (webhook endpoints). Deleting a permission, a
custom role, or a webhook needs an explicit 'delete: true' on its entry;
nothing is deleted by omission, and a predefined role cannot be deleted. A
preference left out of the file resets to its default. Log in as a superuser
(for example the bootstrap service account) with --header.

Use "frontier export <kind>" to print the current state in this file format.
`),
Expand Down Expand Up @@ -88,6 +88,7 @@ func buildReconcileRegistry(host, header string) (map[string]reconcile.Reconcile
reconcile.KindPermission: reconcile.NewPermissionReconciler(api, header),
reconcile.KindRole: reconcile.NewRoleReconciler(api, header),
reconcile.KindPreference: reconcile.NewPreferenceReconciler(api, header),
reconcile.KindWebhook: reconcile.NewWebhookReconciler(adminClient, header),
}, nil
}

Expand Down
60 changes: 60 additions & 0 deletions core/webhook/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"fmt"
"log/slog"
"net/url"
"strings"
"time"

"github.com/raystack/frontier/pkg/server/consts"
Expand Down Expand Up @@ -48,6 +50,13 @@ func (s Service) CreateEndpoint(ctx context.Context, endpoint Endpoint) (Endpoin
if endpoint.State == "" {
endpoint.State = Enabled
}
endpoint.URL = strings.TrimSpace(endpoint.URL)
if err := validateEndpoint(endpoint); err != nil {
return Endpoint{}, err
}
if err := s.ensureURLIsFree(ctx, endpoint.URL, endpoint.ID); err != nil {
return Endpoint{}, err
}
Comment on lines +53 to +59

@coderabbitai coderabbitai Bot Jul 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

TOCTOU race on URL uniqueness, plus a full table scan on every write.

ensureURLIsFree lists all endpoints and checks in Go, then Create/UpdateByID runs as a separate, unsynchronized step. Two concurrent CreateEndpoint calls for the same URL can both pass the check and both persist — exactly the "two endpoints sharing a url" scenario that internal/reconcile/webhook.go's diffWebhooks has to special-case as an unrecoverable error ("the url identity is ambiguous... delete the extra one by hand"). This check-then-act pattern is unreliable without a DB-level unique constraint or a lock/transaction spanning the check and the write.

Separately, s.eRepo.List(ctx, EndpointFilter{}) fetches every endpoint on every create/update, which won't scale as the number of webhooks grows.

Recommend enforcing uniqueness with a unique index at the storage layer (with the app-level check kept as a fast-path/better error message), and, if a targeted lookup by URL is available on the repository, using that instead of a full list scan.

Also applies to: 77-83, 108-123

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring this on purpose, tracked under "Not in this PR" in the description. The real fix is a DB unique index on webhook_endpoints.url, which is a migration with prod-data implications, so it lands separately. Until then the reconcile diff fails loudly and names the duplicate ids to clean up, and the app-level check stays as a fast path with a clear error. The full-list scan on each write goes away with that same follow-up (a lookup by url).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rohilsurana, acknowledged. Deferring the database-level uniqueness constraint is reasonable given the migration and production-data implications. The current application-level guard plus reconcile-time duplicate detection provides a clear interim failure mode.

Would you like me to open a GitHub follow-up issue for the unique index and targeted URL lookup?


✏️ Learnings added
Learnt from: rohilsurana
Repo: raystack/frontier PR: 1772
File: core/webhook/service.go:53-59
Timestamp: 2026-07-23T06:48:13.000Z
Learning: For `core/webhook/service.go`, uniqueness of `webhook_endpoints.url` is intentionally deferred to a separate production-data-aware migration that adds a database unique index. Until then, the service-level `ensureURLIsFree` full-list check is an interim fast-path, and webhook reconciliation detects duplicate URL identities and reports the duplicate endpoint IDs for manual cleanup. A targeted repository lookup by URL is planned with that follow-up.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


// generate a random secret in hex
secretHex, err := crypt.NewEncryptionKeyInHex()
Expand All @@ -65,6 +74,13 @@ func (s Service) UpdateEndpoint(ctx context.Context, endpoint Endpoint) (Endpoin
if endpoint.ID == "" {
return Endpoint{}, ErrInvalidUUID
}
endpoint.URL = strings.TrimSpace(endpoint.URL)
if err := validateEndpoint(endpoint); err != nil {
return Endpoint{}, err
}
if err := s.ensureURLIsFree(ctx, endpoint.URL, endpoint.ID); err != nil {
return Endpoint{}, err
}
updated, err := s.eRepo.UpdateByID(ctx, endpoint)
if err != nil {
return Endpoint{}, err
Expand All @@ -73,6 +89,50 @@ func (s Service) UpdateEndpoint(ctx context.Context, endpoint Endpoint) (Endpoin
return updated, nil
}

// validateEndpoint checks the operator-supplied fields the reconcile flow relies
// on. The URL is that flow's identity for an endpoint and the state is managed
// as enabled/disabled, so the server only stores values that reconcile can
// represent and round-trip: a valid absolute URL, and a known state.
func validateEndpoint(endpoint Endpoint) error {
u, err := url.Parse(endpoint.URL)
if err != nil || !u.IsAbs() {
return fmt.Errorf("%w: url must be a valid absolute URL", ErrInvalidDetail)
}
// The server dispatches events to this URL, so restrict it to http(s). This
// keeps other schemes (file, gopher, ...) out of the delivery path.
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("%w: url scheme must be http or https", ErrInvalidDetail)
}
Comment thread
rohilsurana marked this conversation as resolved.
// "https://" and "http:///path" parse as absolute http(s) URLs with an empty
// host. A webhook with no host to deliver to is useless, so reject it.
if u.Host == "" {
return fmt.Errorf("%w: url must include a host", ErrInvalidDetail)
}
switch endpoint.State {
case "", Enabled, Disabled:
default:
return fmt.Errorf("%w: state must be %q or %q", ErrInvalidDetail, Enabled, Disabled)
}
return nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// ensureURLIsFree rejects a URL that another endpoint already uses. The reconcile
// flow uses the URL as the endpoint's identity, so two endpoints sharing a URL
// would be ambiguous. excludeID is the endpoint being updated, so it does not
// conflict with itself.
func (s Service) ensureURLIsFree(ctx context.Context, rawURL, excludeID string) error {
existing, err := s.eRepo.List(ctx, EndpointFilter{})
if err != nil {
return err
}
for _, e := range existing {
if e.ID != excludeID && e.URL == rawURL {
return fmt.Errorf("%w: url %q is already used by another webhook", ErrConflict, rawURL)
}
}
return nil
}

func (s Service) DeleteEndpoint(ctx context.Context, id string) error {
return s.eRepo.Delete(ctx, id)
}
Expand Down
104 changes: 104 additions & 0 deletions core/webhook/service_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package webhook_test

import (
"context"
"testing"

"github.com/raystack/frontier/core/webhook"
"github.com/stretchr/testify/assert"
)

// fakeEndpointRepo is an in-memory webhook.EndpointRepository for service tests.
type fakeEndpointRepo struct {
items []webhook.Endpoint
}

func (f *fakeEndpointRepo) Create(_ context.Context, e webhook.Endpoint) (webhook.Endpoint, error) {
f.items = append(f.items, e)
return e, nil
}

func (f *fakeEndpointRepo) UpdateByID(_ context.Context, e webhook.Endpoint) (webhook.Endpoint, error) {
for i := range f.items {
if f.items[i].ID == e.ID {
f.items[i] = e
return e, nil
}
}
return webhook.Endpoint{}, webhook.ErrNotFound
}

func (f *fakeEndpointRepo) Delete(_ context.Context, _ string) error { return nil }

func (f *fakeEndpointRepo) List(_ context.Context, _ webhook.EndpointFilter) ([]webhook.Endpoint, error) {
return f.items, nil
}

func TestServiceCreateEndpointValidation(t *testing.T) {
t.Run("rejects a non-absolute url", func(t *testing.T) {
s := webhook.NewService(&fakeEndpointRepo{})
_, err := s.CreateEndpoint(context.Background(), webhook.Endpoint{URL: "not-a-url"})
assert.ErrorIs(t, err, webhook.ErrInvalidDetail)
})

t.Run("rejects an empty url", func(t *testing.T) {
s := webhook.NewService(&fakeEndpointRepo{})
_, err := s.CreateEndpoint(context.Background(), webhook.Endpoint{URL: " "})
assert.ErrorIs(t, err, webhook.ErrInvalidDetail)
})

t.Run("rejects a non-http(s) scheme", func(t *testing.T) {
s := webhook.NewService(&fakeEndpointRepo{})
_, err := s.CreateEndpoint(context.Background(), webhook.Endpoint{URL: "ftp://a.example/hook"})
assert.ErrorIs(t, err, webhook.ErrInvalidDetail)
})

t.Run("rejects an http(s) url with no host", func(t *testing.T) {
// "https://" and "http:///path" parse as absolute http(s) URLs with an
// empty host, but a webhook with no host to deliver to is useless.
s := webhook.NewService(&fakeEndpointRepo{})
_, err := s.CreateEndpoint(context.Background(), webhook.Endpoint{URL: "https://"})
assert.ErrorIs(t, err, webhook.ErrInvalidDetail)
})

t.Run("rejects an unknown state", func(t *testing.T) {
s := webhook.NewService(&fakeEndpointRepo{})
_, err := s.CreateEndpoint(context.Background(), webhook.Endpoint{URL: "https://a.example/hook", State: "paused"})
assert.ErrorIs(t, err, webhook.ErrInvalidDetail)
})

t.Run("rejects a url another endpoint already uses", func(t *testing.T) {
repo := &fakeEndpointRepo{items: []webhook.Endpoint{{ID: "e1", URL: "https://a.example/hook"}}}
_, err := webhook.NewService(repo).CreateEndpoint(context.Background(), webhook.Endpoint{URL: "https://a.example/hook"})
assert.ErrorIs(t, err, webhook.ErrConflict)
})

t.Run("creates a valid endpoint, defaulting state and generating a secret", func(t *testing.T) {
got, err := webhook.NewService(&fakeEndpointRepo{}).CreateEndpoint(
context.Background(), webhook.Endpoint{URL: "https://a.example/hook"})
assert.NoError(t, err)
assert.Equal(t, webhook.Enabled, got.State) // defaulted
assert.Len(t, got.Secrets, 1) // server-generated signing secret
})
}

func TestServiceUpdateEndpointURLUniqueness(t *testing.T) {
t.Run("rejects a url used by a different endpoint", func(t *testing.T) {
repo := &fakeEndpointRepo{items: []webhook.Endpoint{
{ID: "e1", URL: "https://a.example/hook", State: webhook.Enabled},
{ID: "e2", URL: "https://b.example/hook", State: webhook.Enabled},
}}
_, err := webhook.NewService(repo).UpdateEndpoint(context.Background(),
webhook.Endpoint{ID: "e2", URL: "https://a.example/hook", State: webhook.Enabled})
assert.ErrorIs(t, err, webhook.ErrConflict)
})

t.Run("lets an endpoint keep its own url", func(t *testing.T) {
repo := &fakeEndpointRepo{items: []webhook.Endpoint{
{ID: "e1", URL: "https://a.example/hook", State: webhook.Enabled},
}}
_, err := webhook.NewService(repo).UpdateEndpoint(context.Background(),
webhook.Endpoint{ID: "e1", URL: "https://a.example/hook", State: webhook.Disabled})
assert.NoError(t, err)
})
}
41 changes: 40 additions & 1 deletion docs/content/docs/reconcile.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,45 @@ spec:
- Export writes only the preferences whose value differs from the default, so settings at
their default stay out of the file.

## The Webhook kind

`Webhook` manages webhook endpoints: a URL, the events it subscribes to, and whether it is
enabled. The URL is the identity and never changes.

```yaml
apiVersion: v1
kind: Webhook
spec:
- url: https://hooks.example.org/frontier
description: Ops notifications
subscribed_events:
- app.user.created
- app.group.created
state: enabled
- url: https://old.example.org/frontier
delete: true
```

- The URL must be a valid absolute HTTP(S) URL, and it is the identity. If two endpoints on the
server share a URL, the identity is ambiguous: the plan fails and names the ids so you can
remove the extra one by hand.
Comment on lines +216 to +218

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Document URL trimming in the identity rule.

The reconciliation contract identifies webhooks by trimmed URLs, but this section only says that the URL is the identity. State explicitly that surrounding whitespace is trimmed before validation and identity comparison.

Proposed wording
-- The URL must be a valid absolute HTTP(S) URL, and it is the identity.
+- The URL is trimmed before validation and identity comparison, and must be a valid absolute HTTP(S) URL.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- The URL must be a valid absolute HTTP(S) URL, and it is the identity. If two endpoints on the
server share a URL, the identity is ambiguous: the plan fails and names the ids so you can
remove the extra one by hand.
- The URL is trimmed before validation and identity comparison, and must be a valid absolute HTTP(S) URL. If two endpoints on the
server share a URL, the identity is ambiguous: the plan fails and names the ids so you can
remove the extra one by hand.

- `subscribed_events` is the full set of events the endpoint receives, compared as a set. An
empty list, or leaving the field out, means every event, which is the server default. It is
the complete desired set, not a keep-if-omitted field: dropping it sets the endpoint to all
events rather than keeping the current set, and export always writes it, showing `[]` for an
all-events endpoint. `description` and `state` (`enabled` or `disabled`) follow the same one
field model: a value you write is used as-is, and leaving the field out converges it to the
default. Description defaults to empty, so dropping it clears any description on the server;
state defaults to `enabled`, so dropping it re-enables the endpoint.
- Every endpoint on the server must appear in the file, kept or marked `delete: true`. An
endpoint that is missing fails the plan; nothing is deleted just because it is missing.
- The signing secret is server-owned. The server generates it when the endpoint is created
and never returns it on read, so it is not part of the file, never shows up in a plan, and
can never appear in an export.
- Export leaves out `state` when it is the default `enabled` and `description` when it is
empty, so reconciling an export plans nothing, and headers and metadata set through other
tools are carried through an update untouched.

## Running it

Log in as a superuser. The bootstrap service user exists for exactly this; its client id
Expand Down Expand Up @@ -236,7 +275,7 @@ The kind argument is case-insensitive and accepts a plural, so `platformuser` an

## More kinds

This page covers `PlatformUser`, `Permission`, `Role`, and `Preference`. The design and
This page covers `PlatformUser`, `Permission`, `Role`, `Preference`, and `Webhook`. The design and
the rules every kind follows live in
[RFC 0001](https://github.com/raystack/frontier/blob/main/docs/rfcs/0001-declarative-reconcile.md),
which also lists the kinds proposed next. The flag reference for both commands is in the
Expand Down
11 changes: 6 additions & 5 deletions docs/content/docs/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ List of supported environment variables
Export the current state of a kind as a desired-state YAML file, printed to
stdout. The output is the format `frontier reconcile` reads: reconciling it
changes nothing. Supported kinds: `PlatformUser`, `Permission`, `Role`,
`Preference`. See the
`Preference`, `Webhook`. See the
[Reconcile guide](../reconcile.md) for the file format and the flow.

```
Expand Down Expand Up @@ -249,10 +249,11 @@ View a project
Make platform resources match a desired-state YAML file, through the admin
API. Supported kinds: `PlatformUser` (anyone listed is added, anyone not
listed is removed), `Permission` (custom permissions), `Role`
(platform-level roles), and `Preference` (platform settings, where a setting
left out of the file resets to its default). Deleting a permission or a custom
role needs an explicit `delete: true` on its entry; nothing is deleted by
omission, and a predefined role cannot be deleted. Use
(platform-level roles), `Preference` (platform settings, where a setting
left out of the file resets to its default), and `Webhook` (webhook endpoints).
Deleting a permission, a custom role, or a webhook needs an explicit
`delete: true` on its entry; nothing is deleted by omission, and a predefined
role cannot be deleted. Use
`frontier export` to print the current state in this file format, and see the
[Reconcile guide](../reconcile.md) for the full flow.

Expand Down
25 changes: 21 additions & 4 deletions internal/api/v1beta1connect/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package v1beta1connect

import (
"context"
"errors"
"fmt"

"connectrpc.com/connect"
Expand All @@ -11,6 +12,22 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"
)

// webhookErrCode maps a webhook service error to the connect status code the
// caller should see, so a bad request reads as invalid-argument rather than an
// internal error.
func webhookErrCode(err error) connect.Code {
switch {
case errors.Is(err, webhook.ErrInvalidDetail), errors.Is(err, webhook.ErrInvalidUUID):
return connect.CodeInvalidArgument
case errors.Is(err, webhook.ErrConflict):
return connect.CodeAlreadyExists
case errors.Is(err, webhook.ErrNotFound):
return connect.CodeNotFound
default:
return connect.CodeInternal
}
}

func (h *ConnectHandler) CreateWebhook(ctx context.Context, req *connect.Request[frontierv1beta1.CreateWebhookRequest]) (*connect.Response[frontierv1beta1.CreateWebhookResponse], error) {
var metaDataMap metadata.Metadata
if req.Msg.GetBody().GetMetadata() != nil {
Expand All @@ -25,7 +42,7 @@ func (h *ConnectHandler) CreateWebhook(ctx context.Context, req *connect.Request
Metadata: metaDataMap,
})
if err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("CreateWebhook: url=%s: %w", req.Msg.GetBody().GetUrl(), err))
return nil, connect.NewError(webhookErrCode(err), fmt.Errorf("CreateWebhook: url=%s: %w", req.Msg.GetBody().GetUrl(), err))
}
endpointPb, err := toProtoWebhookEndpoint(endpoint)
if err != nil {
Expand Down Expand Up @@ -53,7 +70,7 @@ func (h *ConnectHandler) UpdateWebhook(ctx context.Context, req *connect.Request
Metadata: metaDataMap,
})
if err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("UpdateWebhook: webhook_id=%s url=%s: %w", webhookID, req.Msg.GetBody().GetUrl(), err))
return nil, connect.NewError(webhookErrCode(err), fmt.Errorf("UpdateWebhook: webhook_id=%s url=%s: %w", webhookID, req.Msg.GetBody().GetUrl(), err))
}
endpointPb, err := toProtoWebhookEndpoint(endpoint)
if err != nil {
Expand All @@ -68,7 +85,7 @@ func (h *ConnectHandler) ListWebhooks(ctx context.Context, req *connect.Request[
filter := webhook.EndpointFilter{}
endpoints, err := h.webhookService.ListEndpoints(ctx, filter)
if err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ListWebhooks: %w", err))
return nil, connect.NewError(webhookErrCode(err), fmt.Errorf("ListWebhooks: %w", err))
}
var webhooks []*frontierv1beta1.Webhook
for _, endpoint := range endpoints {
Expand All @@ -88,7 +105,7 @@ func (h *ConnectHandler) DeleteWebhook(ctx context.Context, req *connect.Request

err := h.webhookService.DeleteEndpoint(ctx, webhookID)
if err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("DeleteWebhook: webhook_id=%s: %w", webhookID, err))
return nil, connect.NewError(webhookErrCode(err), fmt.Errorf("DeleteWebhook: webhook_id=%s: %w", webhookID, err))
}
return connect.NewResponse(&frontierv1beta1.DeleteWebhookResponse{}), nil
}
Expand Down
20 changes: 20 additions & 0 deletions internal/reconcile/role.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,26 @@ func sortedCopy(in []string) []string {
return out
}

// uniqueSorted returns the input as a sorted, deduplicated slice. It is used for
// set-valued fields whose input can hold duplicates (a hand-written list may
// repeat a value), so the compare and the value sent to the server both use the
// same canonical set.
func uniqueSorted(in []string) []string {
if len(in) == 0 {
return nil
}
set := make(map[string]struct{}, len(in))
for _, v := range in {
set[v] = struct{}{}
}
out := make([]string, 0, len(set))
for v := range set {
out = append(out, v)
}
sort.Strings(out)
return out
}

func stringSetsEqual(a, b []string) bool {
if len(a) != len(b) {
return false
Expand Down
Loading
Loading