From 06303c6905d01b313480a39817c604a3cf6b46e1 Mon Sep 17 00:00:00 2001 From: Moe Jangda Date: Thu, 1 Aug 2024 17:14:01 -0500 Subject: [PATCH] fix: setting global config and secrets when using db config provider (#2237) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Problem Setting global config when using db config provider allows creating the same config name over and over again. e.g. ```bash ❯ ftl config set KEY "OKAY1" --db ❯ ftl config get KEY "OKAY1" ❯ ftl config set KEY "OKAY2" --db ❯ ftlprod config get KEY "OKAY1" ``` running `ftl config list` reveals the problem: ```bash ❯ ftlprod config list KEY KEY ``` This problem occurs because of the compound unique constraint [here](https://github.com/TBD54566975/ftl/blob/8dd18e78e9404832f378101e3cee6f1a8b514f66/backend/controller/sql/schema/20231103205514_init.sql#L518). In PostgreSQL, `NULL` values are treated as distinct, so when you have a unique constraint on multiple columns where one of the columns can be `NULL`, each `NULL` is treated as a different value. This means that multiple rows can have `NULL` in the module column and the same value in the name column without violating the unique constraint. # Fix This PR proposes treating a `NULL` module value as `''` when creating the unique compound constraint > [!NOTE] > I believe the following would also work > ```sql > > ALTER TABLE module_configuration > ALTER COLUMN module SET DEFAULT ''; > ``` > [!NOTE] > I went with `''` to prevent colliding with potential module names and i chose _not_ to default module to `''` to prevent application code from treating `''` as a not nil value though go does treat `''` as the zero value for a string but im not sure how that works with `optional.Option`. --------- Co-authored-by: Jiyoon Koo Co-authored-by: Wes --- ...0101_edit-db-secrets-and-configuration.sql | 10 + cmd/ftl-controller/main.go | 2 +- common/configuration/dal/dal_test.go | 313 ++++++++++++------ .../configuration/db_config_provider_test.go | 29 ++ common/configuration/sql/queries.sql | 8 +- common/configuration/sql/queries.sql.go | 8 +- lsp/hoveritems.go | 4 +- 7 files changed, 260 insertions(+), 114 deletions(-) create mode 100644 backend/controller/sql/schema/20240801160101_edit-db-secrets-and-configuration.sql diff --git a/backend/controller/sql/schema/20240801160101_edit-db-secrets-and-configuration.sql b/backend/controller/sql/schema/20240801160101_edit-db-secrets-and-configuration.sql new file mode 100644 index 0000000000..a69bd43dba --- /dev/null +++ b/backend/controller/sql/schema/20240801160101_edit-db-secrets-and-configuration.sql @@ -0,0 +1,10 @@ +-- migrate:up + +CREATE UNIQUE INDEX module_config_name_unique + ON module_configuration ((COALESCE(module, '')), name); + +CREATE UNIQUE INDEX module_secret_name_unique + ON module_secrets ((COALESCE(module, '')), name); + +-- migrate:down + diff --git a/cmd/ftl-controller/main.go b/cmd/ftl-controller/main.go index 86601ca847..557a6f8dac 100644 --- a/cmd/ftl-controller/main.go +++ b/cmd/ftl-controller/main.go @@ -56,7 +56,7 @@ func main() { kctx.FatalIfErrorf(err) configProviders := []cf.Provider[cf.Configuration]{cf.NewDBConfigProvider(configDal)} configResolver := cf.NewDBConfigResolver(configDal) - cm, err := cf.New[cf.Configuration](ctx, configResolver, configProviders) + cm, err := cf.New(ctx, configResolver, configProviders) kctx.FatalIfErrorf(err) ctx = cf.ContextWithConfig(ctx, cm) diff --git a/common/configuration/dal/dal_test.go b/common/configuration/dal/dal_test.go index 8539361a2e..b555e7489c 100644 --- a/common/configuration/dal/dal_test.go +++ b/common/configuration/dal/dal_test.go @@ -2,122 +2,229 @@ package dal import ( "context" + "errors" + "fmt" "testing" - "github.com/alecthomas/assert/v2" - "github.com/alecthomas/types/optional" - - "github.com/TBD54566975/ftl/backend/controller/sql" "github.com/TBD54566975/ftl/backend/controller/sql/sqltest" + libdal "github.com/TBD54566975/ftl/backend/dal" "github.com/TBD54566975/ftl/internal/log" + "github.com/alecthomas/assert/v2" + "github.com/alecthomas/types/optional" ) -func TestModuleConfiguration(t *testing.T) { - ctx := log.ContextWithNewDefaultLogger(context.Background()) - conn := sqltest.OpenForTesting(ctx, t) - dal, err := New(ctx, conn) - assert.NoError(t, err) - assert.NotZero(t, dal) - - tests := []struct { - TestName string - ModuleSet optional.Option[string] - ModuleGet optional.Option[string] - PresetGlobal bool - }{ - { - "SetModuleGetModule", - optional.Some("echo"), - optional.Some("echo"), - false, - }, - { - "SetGlobalGetGlobal", - optional.None[string](), - optional.None[string](), - false, - }, - { - "SetGlobalGetModule", - optional.None[string](), - optional.Some("echo"), - false, - }, - { - "SetModuleOverridesGlobal", - optional.Some("echo"), - optional.Some("echo"), - true, - }, - } - - b := []byte(`"asdf"`) - for _, test := range tests { - t.Run(test.TestName, func(t *testing.T) { - if test.PresetGlobal { - err := dal.SetModuleConfiguration(ctx, optional.None[string](), "configname", []byte(`"qwerty"`)) - assert.NoError(t, err) - } - err := dal.SetModuleConfiguration(ctx, test.ModuleSet, "configname", b) - assert.NoError(t, err) - gotBytes, err := dal.GetModuleConfiguration(ctx, test.ModuleGet, "configname") - assert.NoError(t, err) - assert.Equal(t, b, gotBytes) - err = dal.UnsetModuleConfiguration(ctx, test.ModuleGet, "configname") - assert.NoError(t, err) - }) - } - - t.Run("List", func(t *testing.T) { - sortedList := []sql.ModuleConfiguration{ - { - Module: optional.Some("echo"), - Name: "a", - }, - { - Module: optional.Some("echo"), - Name: "b", - }, - { - Module: optional.None[string](), - Name: "a", - }, - } - - // Insert entries in a separate order from how they should be returned to - // test sorting logic in the SQL query - err := dal.SetModuleConfiguration(ctx, sortedList[1].Module, sortedList[1].Name, []byte(`""`)) - assert.NoError(t, err) - err = dal.SetModuleConfiguration(ctx, sortedList[2].Module, sortedList[2].Name, []byte(`""`)) - assert.NoError(t, err) - err = dal.SetModuleConfiguration(ctx, sortedList[0].Module, sortedList[0].Name, []byte(`""`)) - assert.NoError(t, err) - - gotList, err := dal.ListModuleConfiguration(ctx) - assert.NoError(t, err) - for i := range sortedList { - assert.Equal(t, sortedList[i].Module, gotList[i].Module) - assert.Equal(t, sortedList[i].Name, gotList[i].Name) - } +func TestDALConfiguration(t *testing.T) { + t.Run("ModuleConfiguration", func(t *testing.T) { + ctx := log.ContextWithNewDefaultLogger(context.Background()) + conn := sqltest.OpenForTesting(ctx, t) + dal, err := New(ctx, conn) + assert.NoError(t, err) + assert.NotZero(t, dal) + + err = dal.SetModuleConfiguration(ctx, optional.Some("echo"), "my_config", []byte(`""`)) + assert.NoError(t, err) + + value, err := dal.GetModuleConfiguration(ctx, optional.Some("echo"), "my_config") + assert.NoError(t, err) + assert.Equal(t, []byte(`""`), value) + + err = dal.UnsetModuleConfiguration(ctx, optional.Some("echo"), "my_config") + assert.NoError(t, err) + + value, err = dal.GetModuleConfiguration(ctx, optional.Some("echo"), "my_config") + assert.Error(t, err) + assert.True(t, errors.Is(err, libdal.ErrNotFound)) + assert.Zero(t, value) + }) + + t.Run("GlobalConfiguration", func(t *testing.T) { + ctx := log.ContextWithNewDefaultLogger(context.Background()) + conn := sqltest.OpenForTesting(ctx, t) + dal, err := New(ctx, conn) + assert.NoError(t, err) + assert.NotZero(t, dal) + + err = dal.SetModuleConfiguration(ctx, optional.None[string](), "my_config", []byte(`""`)) + assert.NoError(t, err) + + value, err := dal.GetModuleConfiguration(ctx, optional.None[string](), "my_config") + assert.NoError(t, err) + assert.Equal(t, []byte(`""`), value) + + err = dal.UnsetModuleConfiguration(ctx, optional.None[string](), "my_config") + assert.NoError(t, err) + + value, err = dal.GetModuleConfiguration(ctx, optional.None[string](), "my_config") + fmt.Printf("value: %v\n", value) + assert.Error(t, err) + assert.True(t, errors.Is(err, libdal.ErrNotFound)) + assert.Zero(t, value) + }) + + t.Run("SetSameGlobalConfigTwice", func(t *testing.T) { + ctx := log.ContextWithNewDefaultLogger(context.Background()) + conn := sqltest.OpenForTesting(ctx, t) + dal, err := New(ctx, conn) + assert.NoError(t, err) + assert.NotZero(t, dal) + + err = dal.SetModuleConfiguration(ctx, optional.None[string](), "my_config", []byte(`""`)) + assert.NoError(t, err) + + err = dal.SetModuleConfiguration(ctx, optional.None[string](), "my_config", []byte(`"hehe"`)) + assert.NoError(t, err) + + value, err := dal.GetModuleConfiguration(ctx, optional.None[string](), "my_config") + assert.NoError(t, err) + assert.Equal(t, []byte(`"hehe"`), value) + }) + + t.Run("SetModuleOverridesGlobal", func(t *testing.T) { + ctx := log.ContextWithNewDefaultLogger(context.Background()) + conn := sqltest.OpenForTesting(ctx, t) + dal, err := New(ctx, conn) + assert.NoError(t, err) + assert.NotZero(t, dal) + + err = dal.SetModuleConfiguration(ctx, optional.None[string](), "my_config", []byte(`""`)) + assert.NoError(t, err) + err = dal.SetModuleConfiguration(ctx, optional.Some("echo"), "my_config", []byte(`"hehe"`)) + assert.NoError(t, err) + + value, err := dal.GetModuleConfiguration(ctx, optional.Some("echo"), "my_config") + assert.NoError(t, err) + assert.Equal(t, []byte(`"hehe"`), value) }) t.Run("HandlesConflicts", func(t *testing.T) { - err := dal.SetModuleConfiguration(ctx, optional.Some("echo"), "my_config", []byte(`""`)) + ctx := log.ContextWithNewDefaultLogger(context.Background()) + conn := sqltest.OpenForTesting(ctx, t) + dal, err := New(ctx, conn) + assert.NoError(t, err) + assert.NotZero(t, dal) + + err = dal.SetModuleConfiguration(ctx, optional.Some("echo"), "my_config", []byte(`""`)) assert.NoError(t, err) err = dal.SetModuleConfiguration(ctx, optional.Some("echo"), "my_config", []byte(`""`)) assert.NoError(t, err) + + err = dal.SetModuleConfiguration(ctx, optional.None[string](), "my_config", []byte(`""`)) + assert.NoError(t, err) + err = dal.SetModuleConfiguration(ctx, optional.None[string](), "my_config", []byte(`"hehe"`)) + assert.NoError(t, err) + + value, err := dal.GetModuleConfiguration(ctx, optional.None[string](), "my_config") + assert.NoError(t, err) + assert.Equal(t, []byte(`"hehe"`), value) }) } -func TestModuleSecrets(t *testing.T) { - ctx := log.ContextWithNewDefaultLogger(context.Background()) - conn := sqltest.OpenForTesting(ctx, t) - dal, err := New(ctx, conn) - assert.NoError(t, err) - assert.NotZero(t, dal) - - err = dal.SetModuleSecretURL(ctx, optional.Some("echo"), "my_secret", "asm://echo.my_secret") - assert.NoError(t, err) - err = dal.SetModuleSecretURL(ctx, optional.Some("echo"), "my_secret", "asm://echo.my_secret") - assert.NoError(t, err) +func TestDALSecrets(t *testing.T) { + t.Run("ModuleSecret", func(t *testing.T) { + ctx := log.ContextWithNewDefaultLogger(context.Background()) + conn := sqltest.OpenForTesting(ctx, t) + dal, err := New(ctx, conn) + assert.NoError(t, err) + assert.NotZero(t, dal) + + err = dal.SetModuleSecretURL(ctx, optional.Some("echo"), "my_secret", "http://example.com") + assert.NoError(t, err) + + value, err := dal.GetModuleSecretURL(ctx, optional.Some("echo"), "my_secret") + assert.NoError(t, err) + assert.Equal(t, "http://example.com", value) + + err = dal.UnsetModuleSecret(ctx, optional.Some("echo"), "my_secret") + assert.NoError(t, err) + + value, err = dal.GetModuleSecretURL(ctx, optional.Some("echo"), "my_secret") + assert.Error(t, err) + assert.True(t, errors.Is(err, libdal.ErrNotFound)) + assert.Zero(t, value) + }) + + t.Run("GlobalSecret", func(t *testing.T) { + ctx := log.ContextWithNewDefaultLogger(context.Background()) + conn := sqltest.OpenForTesting(ctx, t) + dal, err := New(ctx, conn) + assert.NoError(t, err) + assert.NotZero(t, dal) + + err = dal.SetModuleSecretURL(ctx, optional.None[string](), "my_secret", "http://example.com") + assert.NoError(t, err) + + value, err := dal.GetModuleSecretURL(ctx, optional.None[string](), "my_secret") + assert.NoError(t, err) + assert.Equal(t, "http://example.com", value) + + err = dal.UnsetModuleSecret(ctx, optional.None[string](), "my_secret") + assert.NoError(t, err) + + value, err = dal.GetModuleSecretURL(ctx, optional.None[string](), "my_secret") + assert.Error(t, err) + assert.True(t, errors.Is(err, libdal.ErrNotFound)) + assert.Zero(t, value) + }) + + t.Run("SetSameGlobalSecretTwice", func(t *testing.T) { + ctx := log.ContextWithNewDefaultLogger(context.Background()) + conn := sqltest.OpenForTesting(ctx, t) + dal, err := New(ctx, conn) + assert.NoError(t, err) + assert.NotZero(t, dal) + + err = dal.SetModuleSecretURL(ctx, optional.None[string](), "my_secret", "http://example.com") + assert.NoError(t, err) + + err = dal.SetModuleSecretURL(ctx, optional.None[string](), "my_secret", "http://example2.com") + assert.NoError(t, err) + + value, err := dal.GetModuleSecretURL(ctx, optional.None[string](), "my_secret") + assert.NoError(t, err) + assert.Equal(t, "http://example2.com", value) + }) + + t.Run("SetModuleOverridesGlobal", func(t *testing.T) { + ctx := log.ContextWithNewDefaultLogger(context.Background()) + conn := sqltest.OpenForTesting(ctx, t) + dal, err := New(ctx, conn) + assert.NoError(t, err) + assert.NotZero(t, dal) + + err = dal.SetModuleSecretURL(ctx, optional.None[string](), "my_secret", "http://example.com") + assert.NoError(t, err) + err = dal.SetModuleSecretURL(ctx, optional.Some("echo"), "my_secret", "http://example2.com") + assert.NoError(t, err) + + value, err := dal.GetModuleSecretURL(ctx, optional.Some("echo"), "my_secret") + assert.NoError(t, err) + assert.Equal(t, "http://example2.com", value) + }) + + t.Run("HandlesConflicts", func(t *testing.T) { + ctx := log.ContextWithNewDefaultLogger(context.Background()) + conn := sqltest.OpenForTesting(ctx, t) + dal, err := New(ctx, conn) + assert.NoError(t, err) + assert.NotZero(t, dal) + + err = dal.SetModuleSecretURL(ctx, optional.Some("echo"), "my_secret", "http://example.com") + assert.NoError(t, err) + err = dal.SetModuleSecretURL(ctx, optional.Some("echo"), "my_secret", "http://example2.com") + assert.NoError(t, err) + + value, err := dal.GetModuleSecretURL(ctx, optional.Some("echo"), "my_secret") + assert.NoError(t, err) + assert.Equal(t, "http://example2.com", value) + + err = dal.SetModuleSecretURL(ctx, optional.None[string](), "my_secret", "http://example.com") + assert.NoError(t, err) + err = dal.SetModuleSecretURL(ctx, optional.None[string](), "my_secret", "http://example2.com") + assert.NoError(t, err) + + value, err = dal.GetModuleSecretURL(ctx, optional.None[string](), "my_secret") + assert.NoError(t, err) + assert.Equal(t, "http://example2.com", value) + }) + } diff --git a/common/configuration/db_config_provider_test.go b/common/configuration/db_config_provider_test.go index 3920399d12..4048ddf5f5 100644 --- a/common/configuration/db_config_provider_test.go +++ b/common/configuration/db_config_provider_test.go @@ -49,3 +49,32 @@ func TestDBConfigProvider(t *testing.T) { }) assert.NoError(t, err) } + +func TestDBConfigProvider_Global(t *testing.T) { + t.Run("works", func(t *testing.T) { + ctx := context.Background() + provider := NewDBConfigProvider(mockDBConfigProviderDAL{}) + + gotBytes, err := provider.Load(ctx, Ref{ + Module: optional.None[string](), + Name: "configname", + }, &url.URL{Scheme: "db"}) + assert.NoError(t, err) + assert.Equal(t, b, gotBytes) + + gotURL, err := provider.Store(ctx, Ref{ + Module: optional.None[string](), + Name: "configname", + }, b) + assert.NoError(t, err) + assert.Equal(t, &url.URL{Scheme: "db"}, gotURL) + + err = provider.Delete(ctx, Ref{ + Module: optional.None[string](), + Name: "configname", + }) + assert.NoError(t, err) + }) + + // TODO: maybe add a unit test to assert failure to create same global config twice. not sure how to wire up the mocks for this +} diff --git a/common/configuration/sql/queries.sql b/common/configuration/sql/queries.sql index 9862adbd46..92378929a3 100644 --- a/common/configuration/sql/queries.sql +++ b/common/configuration/sql/queries.sql @@ -15,11 +15,11 @@ ORDER BY module, name; -- name: SetModuleConfiguration :exec INSERT INTO module_configuration (module, name, value) VALUES ($1, $2, $3) -ON CONFLICT (module, name) DO UPDATE SET value = $3; +ON CONFLICT ((COALESCE(module, '')), name) DO UPDATE SET value = $3; -- name: UnsetModuleConfiguration :exec DELETE FROM module_configuration -WHERE module = @module AND name = @name; +WHERE COALESCE(module, '') = COALESCE(@module, '') AND name = @name; -- name: GetModuleSecretURL :one SELECT url @@ -38,8 +38,8 @@ ORDER BY module, name; -- name: SetModuleSecretURL :exec INSERT INTO module_secrets (module, name, url) VALUES ($1, $2, $3) -ON CONFLICT (module, name) DO UPDATE SET url = $3; +ON CONFLICT ((COALESCE(module, '')), name) DO UPDATE SET url = $3; -- name: UnsetModuleSecret :exec DELETE FROM module_secrets -WHERE module = @module AND name = @name; +WHERE COALESCE(module, '') = COALESCE(@module, '') AND name = @name; diff --git a/common/configuration/sql/queries.sql.go b/common/configuration/sql/queries.sql.go index 99f3c30475..f611ce598c 100644 --- a/common/configuration/sql/queries.sql.go +++ b/common/configuration/sql/queries.sql.go @@ -112,7 +112,7 @@ func (q *Queries) ListModuleSecrets(ctx context.Context) ([]ModuleSecret, error) const setModuleConfiguration = `-- name: SetModuleConfiguration :exec INSERT INTO module_configuration (module, name, value) VALUES ($1, $2, $3) -ON CONFLICT (module, name) DO UPDATE SET value = $3 +ON CONFLICT ((COALESCE(module, '')), name) DO UPDATE SET value = $3 ` func (q *Queries) SetModuleConfiguration(ctx context.Context, module optional.Option[string], name string, value []byte) error { @@ -123,7 +123,7 @@ func (q *Queries) SetModuleConfiguration(ctx context.Context, module optional.Op const setModuleSecretURL = `-- name: SetModuleSecretURL :exec INSERT INTO module_secrets (module, name, url) VALUES ($1, $2, $3) -ON CONFLICT (module, name) DO UPDATE SET url = $3 +ON CONFLICT ((COALESCE(module, '')), name) DO UPDATE SET url = $3 ` func (q *Queries) SetModuleSecretURL(ctx context.Context, module optional.Option[string], name string, url string) error { @@ -133,7 +133,7 @@ func (q *Queries) SetModuleSecretURL(ctx context.Context, module optional.Option const unsetModuleConfiguration = `-- name: UnsetModuleConfiguration :exec DELETE FROM module_configuration -WHERE module = $1 AND name = $2 +WHERE COALESCE(module, '') = COALESCE($1, '') AND name = $2 ` func (q *Queries) UnsetModuleConfiguration(ctx context.Context, module optional.Option[string], name string) error { @@ -143,7 +143,7 @@ func (q *Queries) UnsetModuleConfiguration(ctx context.Context, module optional. const unsetModuleSecret = `-- name: UnsetModuleSecret :exec DELETE FROM module_secrets -WHERE module = $1 AND name = $2 +WHERE COALESCE(module, '') = COALESCE($1, '') AND name = $2 ` func (q *Queries) UnsetModuleSecret(ctx context.Context, module optional.Option[string], name string) error { diff --git a/lsp/hoveritems.go b/lsp/hoveritems.go index 7ad9b973d5..ddd63c82ec 100644 --- a/lsp/hoveritems.go +++ b/lsp/hoveritems.go @@ -4,9 +4,9 @@ package lsp var hoverMap = map[string]string{ "//ftl:cron": "## Cron\n\nA cron job is an Empty verb that will be called on a schedule. The syntax is described [here](https://pubs.opengroup.org/onlinepubs/9699919799.2018edition/utilities/crontab.html).\n\nYou can also use a shorthand syntax for the cron job, supporting seconds (`s`), minutes (`m`), hours (`h`), and specific days of the week (e.g. `Mon`).\n\n### Examples\n\nThe following function will be called hourly:\n\n```go\n//ftl:cron 0 * * * *\nfunc Hourly(ctx context.Context) error {\n // ...\n}\n```\n\nEvery 12 hours, starting at UTC midnight:\n\n```go\n//ftl:cron 12h\nfunc TwiceADay(ctx context.Context) error {\n // ...\n}\n```\n\nEvery Monday at UTC midnight:\n\n```go\n//ftl:cron Mon\nfunc Mondays(ctx context.Context) error {\n // ...\n}\n```\n\n", "//ftl:enum": "## Type enums (sum types)\n\n[Sum types](https://en.wikipedia.org/wiki/Tagged_union) are supported by FTL's type system, but aren't directly supported by Go. However they can be approximated with the use of [sealed interfaces](https://blog.chewxy.com/2018/03/18/golang-interfaces/). To declare a sum type in FTL use the comment directive `//ftl:enum`:\n\n```go\n//ftl:enum\ntype Animal interface { animal() }\n\ntype Cat struct {}\nfunc (Cat) animal() {}\n\ntype Dog struct {}\nfunc (Dog) animal() {}\n```\n## Value enums\n\nA value enum is an enumerated set of string or integer values.\n\n```go\n//ftl:enum\ntype Colour string\n\nconst (\n Red Colour = \"red\"\n Green Colour = \"green\"\n Blue Colour = \"blue\"\n)\n```\n", - "//ftl:ingress": "## HTTP Ingress\n\nVerbs annotated with `ftl:ingress` will be exposed via HTTP (`http` is the default ingress type). These endpoints will then be available on one of our default `ingress` ports (local development defaults to `http://localhost:8891`).\n\nThe following will be available at `http://localhost:8891/http/users/123/posts?postId=456`.\n\n```go\ntype GetRequest struct {\n\tUserID string `json:\"userId\"`\n\tPostID string `json:\"postId\"`\n}\n\ntype GetResponse struct {\n\tMessage string `json:\"msg\"`\n}\n\n//ftl:ingress GET /http/users/{userId}/posts\nfunc Get(ctx context.Context, req builtin.HttpRequest[GetRequest]) (builtin.HttpResponse[GetResponse, ErrorResponse], error) {\n // ...\n}\n```\n\n> **NOTE!**\n> The `req` and `resp` types of HTTP `ingress` [verbs](../verbs) must be `builtin.HttpRequest` and `builtin.HttpResponse` respectively. These types provide the necessary fields for HTTP `ingress` (`headers`, `statusCode`, etc.)\n> \n> You will need to import `ftl/builtin`.\n\nKey points to note\n\n- `path`, `query`, and `body` parameters are automatically mapped to the `req` and `resp` structures. In the example above, `{userId}` is extracted from the path parameter and `postId` is extracted from the query parameter.\n- `ingress` verbs will be automatically exported by default.\n", + "//ftl:ingress": "## HTTP Ingress\n\nVerbs annotated with `ftl:ingress` will be exposed via HTTP (`http` is the default ingress type). These endpoints will then be available on one of our default `ingress` ports (local development defaults to `http://localhost:8891`).\n\nThe following will be available at `http://localhost:8891/http/users/123/posts?postId=456`.\n\n```go\ntype GetRequest struct {\n\tUserID string `json:\"userId\"`\n\tPostID string `json:\"postId\"`\n}\n\ntype GetResponse struct {\n\tMessage string `json:\"msg\"`\n}\n\n//ftl:ingress GET /http/users/{userId}/posts\nfunc Get(ctx context.Context, req builtin.HttpRequest[GetRequest]) (builtin.HttpResponse[GetResponse, ErrorResponse], error) {\n // ...\n}\n```\n\n> **NOTE!**\n> The `req` and `resp` types of HTTP `ingress` [verbs](../verbs) must be `builtin.HttpRequest` and `builtin.HttpResponse` respectively. These types provide the necessary fields for HTTP `ingress` (`headers`, `statusCode`, etc.)\n>\n> You will need to import `ftl/builtin`.\n\nKey points:\n\n- `ingress` verbs will be automatically exported by default.\n\n## Field mapping\n\nGiven the following request verb:\n\n```go\ntype GetRequest struct {\n\tUserID string `json:\"userId\"`\n\tTag ftl.Option[string] `json:\"tag\"`\n\tPostID string `json:\"postId\"`\n}\n\ntype GetResponse struct {\n\tMessage string `json:\"msg\"`\n}\n\n//ftl:ingress http GET /users/{userId}/posts/{postId}\nfunc Get(ctx context.Context, req builtin.HttpRequest[GetRequest]) (builtin.HttpResponse[GetResponse, string], error) {\n\treturn builtin.HttpResponse[GetResponse, string]{\n\t\tHeaders: map[string][]string{\"Get\": {\"Header from FTL\"}},\n\t\tBody: ftl.Some(GetResponse{\n\t\t\tMessage: fmt.Sprintf(\"UserID: %s, PostID: %s, Tag: %s\", req.Body.UserID, req.Body.PostID, req.Body.Tag.Default(\"none\")),\n\t\t}),\n\t}, nil\n}\n```\n\n`path`, `query`, and `body` parameters are automatically mapped to the `req` structure.\n\nFor example, this curl request will map `userId` to `req.Body.UserID` and `postId` to `req.Body.PostID`, and `tag` to `req.Body.Tag`:\n\n```sh\ncurl -i http://localhost:8891/users/123/posts/456?tag=ftl\n```\n\nThe response here will be:\n\n```json\n{\n \"msg\": \"UserID: 123, PostID: 456, Tag: ftl\"\n}\n```\n\n#### Optional fields\n\nOptional fields are represented by the `ftl.Option` type. The `Option` type is a wrapper around the actual type and can be `Some` or `None`. In the example above, the `Tag` field is optional.\n\n```sh\ncurl -i http://localhost:8891/users/123/posts/456\n```\n\nBecause the `tag` query parameter is not provided, the response will be:\n\n```json\n{\n \"msg\": \"UserID: 123, PostID: 456, Tag: none\"\n}\n```\n\n#### Casing\n\nField names use lowerCamelCase by default. You can override this by using the `json` tag.\n\n## SumTypes\n\nGiven the following request verb:\n\n```go\n//ftl:enum export\ntype SumType interface {\n\ttag()\n}\n\ntype A string\n\nfunc (A) tag() {}\n\ntype B []string\n\nfunc (B) tag() {}\n\n//ftl:ingress http GET /typeenum\nfunc TypeEnum(ctx context.Context, req builtin.HttpRequest[SumType]) (builtin.HttpResponse[SumType, string], error) {\n\treturn builtin.HttpResponse[SumType, string]{Body: ftl.Some(req.Body)}, nil\n}\n```\n\nThe following curl request will map the `SumType` name and value to the `req.Body`:\n\n```sh\ncurl -X GET \"http://localhost:8891/typeenum\" \\\n -H \"Content-Type: application/json\" \\\n --data '{\"name\": \"A\", \"value\": \"sample\"}'\n```\n\nThe response will be:\n\n```json\n{\n \"name\": \"A\",\n \"value\": \"sample\"\n}\n```\n\n## Encoding query params as JSON\n\nComplex query params can also be encoded as JSON using the `@json` query parameter. For example:\n\n> `{\"tag\":\"ftl\"}` url-encoded is `%7B%22tag%22%3A%22ftl%22%7D`\n\n```bash\ncurl -i http://localhost:8891/users/123/posts/456?@json=%7B%22tag%22%3A%22ftl%22%7D\n```\n", "//ftl:retry": "## Retries\n\nAny verb called asynchronously (specifically, PubSub subscribers and FSM states), may optionally specify a basic exponential backoff retry policy via a Go comment directive. The directive has the following syntax:\n\n```go\n//ftl:retry [] []\n```\n\n`attempts` and `max-backoff` default to unlimited if not specified.\n\nFor example, the following function will retry up to 10 times, with a delay of 5s, 10s, 20s, 40s, 60s, 60s, etc.\n\n```go\n//ftl:retry 10 5s 1m\nfunc Invoiced(ctx context.Context, in Invoice) error {\n // ...\n}\n```\n", "//ftl:subscribe": "## PubSub\n\nFTL has first-class support for PubSub, modelled on the concepts of topics (where events are sent), subscriptions (a cursor over the topic), and subscribers (functions events are delivered to). Subscribers are, as you would expect, sinks. Each subscription is a cursor over the topic it is associated with. Each topic may have multiple subscriptions. Each subscription may have multiple subscribers, in which case events will be distributed among them.\n\nFirst, declare a new topic:\n\n```go\nvar Invoices = ftl.Topic[Invoice](\"invoices\")\n```\n\nThen declare each subscription on the topic:\n\n```go\nvar _ = ftl.Subscription(Invoices, \"emailInvoices\")\n```\n\nAnd finally define a Sink to consume from the subscription:\n\n```go\n//ftl:subscribe emailInvoices\nfunc SendInvoiceEmail(ctx context.Context, in Invoice) error {\n // ...\n}\n```\n\nEvents can be published to a topic like so:\n\n```go\nInvoices.Publish(ctx, Invoice{...})\n```\n\n> **NOTE!**\n> PubSub topics cannot be published to from outside the module that declared them, they can only be subscribed to. That is, if a topic is declared in module `A`, module `B` cannot publish to it.\n", - "//ftl:typealias": "## Type aliases\n\nA type alias is an alternate name for an existing type. It can be declared like so:\n\n```go\n//ftl:typealias\ntype Alias Target\n```\n\neg.\n\n```go\n//ftl:typealias\ntype UserID string\n```\n", + "//ftl:typealias": "## Type aliases\n\nA type alias is an alternate name for an existing type. It can be declared like so:\n\n```go\n//ftl:typealias\ntype Alias Target\n```\nor\n```go\n//ftl:typealias\ntype Alias = Target\n```\n\neg.\n\n```go\n//ftl:typealias\ntype UserID string\n\n//ftl:typealias\ntype UserToken = string\n```\n", "//ftl:verb": "## Verbs\n\n## Defining Verbs\n\nTo declare a Verb, write a normal Go function with the following signature, annotated with the Go [comment directive](https://tip.golang.org/doc/comment#syntax) `//ftl:verb`:\n\n```go\n//ftl:verb\nfunc F(context.Context, In) (Out, error) { }\n```\n\neg.\n\n```go\ntype EchoRequest struct {}\n\ntype EchoResponse struct {}\n\n//ftl:verb\nfunc Echo(ctx context.Context, in EchoRequest) (EchoResponse, error) {\n // ...\n}\n```\n\nBy default verbs are only [visible](../visibility) to other verbs in the same module.\n\n## Calling Verbs\n\nTo call a verb use `ftl.Call()`. eg.\n\n```go\nout, err := ftl.Call(ctx, echo.Echo, echo.EchoRequest{})\n```\n", }