From e2bdb2835b2d9b26711c48b73f58037e7c8327a1 Mon Sep 17 00:00:00 2001 From: Bo Lu Date: Thu, 14 Nov 2024 18:27:55 +1100 Subject: [PATCH] feat(wasm): add Calendly Wasm FDW (#364) * feat: add Calendly Wasm FDW * update README * format test code --- Cargo.toml | 1 + README.md | 1 + docs/catalog/calendly.md | 282 +++++++++++++++ docs/catalog/index.md | 4 +- docs/catalog/wasm/index.md | 36 +- mkdocs.yaml | 3 +- wasm-wrappers/fdw/calendly_fdw/Cargo.lock | 358 +++++++++++++++++++ wasm-wrappers/fdw/calendly_fdw/Cargo.toml | 23 ++ wasm-wrappers/fdw/calendly_fdw/src/lib.rs | 284 +++++++++++++++ wasm-wrappers/fdw/calendly_fdw/wit/world.wit | 10 + wrappers/dockerfiles/wasm/server.py | 54 +++ wrappers/src/fdw/wasm_fdw/tests.rs | 44 +++ 12 files changed, 1086 insertions(+), 14 deletions(-) create mode 100644 docs/catalog/calendly.md create mode 100644 wasm-wrappers/fdw/calendly_fdw/Cargo.lock create mode 100644 wasm-wrappers/fdw/calendly_fdw/Cargo.toml create mode 100644 wasm-wrappers/fdw/calendly_fdw/src/lib.rs create mode 100644 wasm-wrappers/fdw/calendly_fdw/wit/world.wit diff --git a/Cargo.toml b/Cargo.toml index 1990f97c..ccea6f32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "wrappers", ] exclude = [ + "wasm-wrappers/fdw/calendly_fdw", "wasm-wrappers/fdw/helloworld_fdw", "wasm-wrappers/fdw/snowflake_fdw", "wasm-wrappers/fdw/paddle_fdw", diff --git a/README.md b/README.md index 612aa6a4..7464343a 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ | [Notion](./wasm-wrappers/fdw/notion_fdw) | A Wasm FDW for [Notion](https://www.notion.so/) | ✅ | ❌ | | [Snowflake](./wasm-wrappers/fdw/snowflake_fdw) | A Wasm FDW for [Snowflake](https://www.snowflake.com/) | ✅ | ✅ | | [Paddle](./wasm-wrappers/fdw/paddle_fdw) | A Wasm FDW for [Paddle](https://www.paddle.com/) | ✅ | ✅ | +| [Calendly](./wasm-wrappers/fdw/calendly_fdw) | A Wasm FDW for [Calendly](https://www.calendly.com/) | ✅ | ❌ | ### Warning diff --git a/docs/catalog/calendly.md b/docs/catalog/calendly.md new file mode 100644 index 00000000..6cec2986 --- /dev/null +++ b/docs/catalog/calendly.md @@ -0,0 +1,282 @@ +--- +source: +documentation: +author: supabase +tags: + - wasm + - official +--- + +# Calendly + +[Calendly](https://calendly.com/) is a scheduling platform used for teams to schedule, prepare and follow up on external meetings. + +The Calendly Wrapper is a WebAssembly(Wasm) foreign data wrapper which allows you to read data from your Calendly for use within your Postgres database. + +!!! warning + + Restoring a logical backup of a database with a materialized view using a foreign table can fail. For this reason, either do not use foreign tables in materialized views or use them in databases with physical backups enabled. + +## Supported Data Types + +| Postgres Data Type | Calendly Data Type | +| ------------------ | ------------------ | +| boolean | Boolean | +| bigint | Number | +| double precision | Number | +| text | String | +| timestamp | Time | +| timestamptz | Time | +| jsonb | Json | + +The Calendly API uses JSON formatted data, please refer to [Calendly API docs](https://developer.calendly.com/api-docs) for more details. + +## Available Versions + +| Version | Wasm Package URL | Checksum | +| ------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| 0.1.0 | `https://github.com/supabase/wrappers/releases/download/wasm_calendly_fdw_v0.1.0/calendly_fdw.wasm` | `tbd` | + +## Preparation + +Before you get started, make sure the `wrappers` extension is installed on your database: + +```sql +create extension if not exists wrappers with schema extensions; +``` + +and then create the Wasm foreign data wrapper: + +```sql +create foreign data wrapper wasm_wrapper + handler wasm_fdw_handler + validator wasm_fdw_validator; +``` + +### Secure your credentials (optional) + +By default, Postgres stores FDW credentials inside `pg_catalog.pg_foreign_server` in plain text. Anyone with access to this table will be able to view these credentials. Wrappers is designed to work with [Vault](https://supabase.com/docs/guides/database/vault), which provides an additional level of security for storing credentials. We recommend using Vault to store your credentials. + +```sql +-- Save your Calendly API key in Vault and retrieve the `key_id` +insert into vault.secrets (name, secret) +values ( + 'calendly', + '' -- Calendly personal access token +) +returning key_id; +``` + +### Connecting to Calendly + +We need to provide Postgres with the credentials to access Calendly and any additional options. We can do this using the `create server` command: + +=== "With Vault" + + ```sql + create server calendly_server + foreign data wrapper wasm_wrapper + options ( + fdw_package_url 'https://github.com/supabase/wrappers/releases/download/wasm_calendly_fdw_v0.1.0/calendly_fdw.wasm', + fdw_package_name 'supabase:calendly-fdw', + fdw_package_version '0.1.0', + fdw_package_checksum 'tbd', + -- find your organization uri using foreign table 'calendly.current_user', see below example for details + organization 'https://api.calendly.com/organizations/81da9c7f-3e19-434a-c3d2-0325e375cdef', + api_url 'https://api.calendly.com', -- optional + api_key_id '' -- The Key ID from above. + ); + ``` + +=== "Without Vault" + + ```sql + create server calendly_server + foreign data wrapper wasm_wrapper + options ( + fdw_package_url 'https://github.com/supabase/wrappers/releases/download/wasm_calendly_fdw_v0.1.0/calendly_fdw.wasm', + fdw_package_name 'supabase:calendly-fdw', + fdw_package_version '0.1.0', + fdw_package_checksum 'tbd', + -- find your organization uri using foreign table 'calendly.current_user', see below example for details + organization 'https://api.calendly.com/organizations/81da9c7f-3e19-434a-c3d2-0325e375cdef', + api_url 'https://api.calendly.com', -- optional + api_key 'eyJraWQiOiIxY2UxZ...' -- Calendly personal access token + ); + ``` + +Note the `fdw_package_*` options are required, which specify the Wasm package metadata. You can get the available package version list from [above](#available-versions). + +### Create a schema + +We recommend creating a schema to hold all the foreign tables: + +```sql +create schema if not exists calendly; +``` + +## Creating Foreign Tables + +The Calendly Wrapper supports data reads from below objects in calendly. + +| Integration | Select | Insert | Update | Delete | Truncate | +| ----------------------- | :----: | :----: | :----: | :----: | :------: | +| Current User | ✅ | ❌ | ❌ | ❌ | ❌ | +| Event Types | ✅ | ❌ | ❌ | ❌ | ❌ | +| Groups | ✅ | ❌ | ❌ | ❌ | ❌ | +| Organization Membership | ✅ | ❌ | ❌ | ❌ | ❌ | +| Scheduled Events | ✅ | ❌ | ❌ | ❌ | ❌ | + +For example: + +```sql +-- Get the current user used for the API request +-- Note: we can query this table to retrieve the organization uri: +-- select attrs->>'current_organization' as org_uri +-- from calendly.current_user; +create foreign table calendly.current_user ( + uri text, + slug text, + created_at timestamp, + updated_at timestamp, + attrs jsonb +) + server calendly_server + options ( + object 'current_user' + ); + +create foreign table calendly.event_types ( + uri text, + created_at timestamp, + updated_at timestamp, + attrs jsonb +) + server calendly_server + options ( + object 'event_types' + ); + +create foreign table calendly.groups ( + uri text, + created_at timestamp, + updated_at timestamp, + attrs jsonb +) + server calendly_server + options ( + object 'groups' + ); + +create foreign table calendly.organization_memberships ( + uri text, + created_at timestamp, + updated_at timestamp, + attrs jsonb +) + server calendly_server + options ( + object 'organization_memberships' + ); + +create foreign table calendly.scheduled_events ( + uri text, + created_at timestamp, + updated_at timestamp, + attrs jsonb +) + server calendly_server + options ( + object 'scheduled_events' + ); +``` + +!!! note + + - All the supported columns are listed above, other columns are not allowd. + - The `attrs` is a special column which stores all the object attributes in JSON format, you can extract any attributes needed from it. See more examples below. + +### Foreign table options + +The full list of foreign table options are below: + +- `object` - Object name in Calendly, required. + + Supported objects are listed below: + + | Object name | + | ------------------------ | + | current_user | + | event_types | + | groups | + | organization_memberships | + | scheduled_events | + +## Query Pushdown Support + +This FDW doesn't support query pushdown. + +## Examples + +Below are some examples on how to use Calendly foreign tables. + +### Basic example + +This example will create a "foreign table" inside your Postgres database and query its data. First, we can create a schema to hold all the Calendly foreign tables. + +```sql +create schema if not exists calendly; +``` + +Then create the foreign table and query it, for example: + +```sql +create foreign table calendly.current_user ( + uri text, + slug text, + created_at timestamp, + updated_at timestamp, + attrs jsonb +) + server calendly_server + options ( + object 'current_user' + ); + +-- query current user used for the Calendly API request +select * from calendly.current_user; +``` + +`attrs` is a special column which stores all the object attributes in JSON format, you can extract any attributes needed from it. See more examples below. + +### Query JSON attributes + +```sql +-- extract organization uri from current user +select attrs->>'current_organization' as org_uri +from calendly.current_user; + +-- then update foreign server option using the organization uri +alter server calendly_server options (set organization ''); +``` + +Some other examples, + +```sql +create foreign table calendly.event_types ( + uri text, + created_at timestamp, + updated_at timestamp, + attrs jsonb +) + server calendly_server + options ( + object 'event_types' + ); + +select attrs->'profile'->>'name' as profile_name +from calendly.event_types; + +select attrs->'custom_questions'->0->>'name' as first_question_name +from calendly.event_types; +``` diff --git a/docs/catalog/index.md b/docs/catalog/index.md index 5d44ddbe..17f38211 100644 --- a/docs/catalog/index.md +++ b/docs/catalog/index.md @@ -13,6 +13,7 @@ hide: | Auth0 | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | | AWS Cognito | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | | BigQuery | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | +| Calendly | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | | ClickHouse | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | | Firebase | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | | Logflare | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | @@ -32,6 +33,7 @@ See [Developing a Wasm Wrapper](../guides/create-wasm-wrapper.md) for instructio | Integration | Developer | Docs | Source | | ----------- | :------------------------------: | :----------------------------------: | :------------------------------------------------------------------------------------: | +| Calendly | [Supabase](https://supabase.com) | [Link](calendly.md) | [Link](https://github.com/supabase/wrappers/tree/main/wasm-wrappers/fdw/calendly_fdw) | +| Notion | [Supabase](https://supabase.com) | [Link](notion.md) | [Link](https://github.com/supabase/wrappers/tree/main/wasm-wrappers/fdw/notion_fdw) | | Paddle | [Supabase](https://supabase.com) | [Link](paddle.md) | [Link](https://github.com/supabase/wrappers/tree/main/wasm-wrappers/fdw/paddle_fdw) | | Snowflake | [Supabase](https://supabase.com) | [Link](snowflake.md) | [Link](https://github.com/supabase/wrappers/tree/main/wasm-wrappers/fdw/snowflake_fdw) | -| Notion | [Supabase](https://supabase.com) | [Link](notion.md) | [Link](https://github.com/supabase/wrappers/tree/main/wasm-wrappers/fdw/notion_fdw) | diff --git a/docs/catalog/wasm/index.md b/docs/catalog/wasm/index.md index 01c91ff3..0c7173d3 100644 --- a/docs/catalog/wasm/index.md +++ b/docs/catalog/wasm/index.md @@ -13,6 +13,30 @@ Foreign data wrappers built with Wasm which can be used on Supabase platform.
+- :simple-webassembly:   **[Calendly](../calendly.md)** + + ---- + + Foreign data wrapper for [Calendly](https://calendly.com/). + + Supported by [Supabase](https://www.supabase.com) + + :octicons-tag-24: [v0.1.0](https://github.com/supabase/wrappers/releases/tag/calendly_fdw_v0.1.0)   + :octicons-code-24: [source](https://github.com/supabase/wrappers/tree/wasm_calendly_fdw_v0.1.0/wasm-wrappers/fdw/calendly_fdw)   + :material-file-document: [docs](../calendly.md) + +- :simple-webassembly:   **[Notion](../notion.md)** + + ---- + + Foreign data wrapper for [Notion](https://notion.so/). + + Supported by [Supabase](https://www.supabase.com) + + :octicons-tag-24: [v0.1.0](https://github.com/supabase/wrappers/releases/tag/wasm_notion_fdw_v0.1.0)   + :octicons-code-24: [source](https://github.com/supabase/wrappers/tree/wasm_notion_fdw_v0.1.0/wasm-wrappers/fdw/notion_fdw)   + :material-file-document: [docs](../notion.md) + - :simple-webassembly:   **[Paddle](../paddle.md)** ---- @@ -37,16 +61,4 @@ Foreign data wrappers built with Wasm which can be used on Supabase platform. :octicons-code-24: [source](https://github.com/supabase/wrappers/tree/wasm_snowflake_fdw_v0.1.1/wasm-wrappers/fdw/snowflake_fdw)   :material-file-document: [docs](../snowflake.md) -- :simple-webassembly:   **[Notion](../notion.md)** - - ---- - - Foreign data wrapper for [Notion](https://notion.so/). - - Supported by [Supabase](https://www.supabase.com) - - :octicons-tag-24: [v0.1.0](https://github.com/supabase/wrappers/releases/tag/wasm_notion_fdw_v0.1.0)   - :octicons-code-24: [source](https://github.com/supabase/wrappers/tree/wasm_notion_fdw_v0.1.0/wasm-wrappers/fdw/notion_fdw)   - :material-file-document: [docs](../notion.md) -
diff --git a/mkdocs.yaml b/mkdocs.yaml index 386b9340..cb17513f 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -28,9 +28,10 @@ nav: - SQL Server: 'catalog/mssql.md' - Wasm: - catalog/wasm/index.md + - Calendly: 'catalog/calendly.md' + - Notion: 'catalog/notion.md' - Paddle: 'catalog/paddle.md' - Snowflake: 'catalog/snowflake.md' - - Notion: 'catalog/notion.md' - Guides: - Native vs Wasm Wrappers: 'guides/native-wasm.md' - Query Pushdown: 'guides/query-pushdown.md' diff --git a/wasm-wrappers/fdw/calendly_fdw/Cargo.lock b/wasm-wrappers/fdw/calendly_fdw/Cargo.lock new file mode 100644 index 00000000..41608d8d --- /dev/null +++ b/wasm-wrappers/fdw/calendly_fdw/Cargo.lock @@ -0,0 +1,358 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "calendly_fdw" +version = "0.1.0" +dependencies = [ + "chrono", + "serde_json", + "wit-bindgen-rt", +] + +[[package]] +name = "cc" +version = "1.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baee610e9452a8f6f0a1b6194ec09ff9e2d85dea54432acdae41aa0761c95d70" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "js-sys" +version = "0.3.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.161" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[package]] +name = "proc-macro2" +version = "1.0.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "serde" +version = "1.0.214" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f55c3193aca71c12ad7890f1785d2b73e1b9f63a0bbc353c08ef26fe03fc56b5" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.214" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de523f781f095e28fa605cdce0f8307e451cc0fd14e2eb4cd2e98a355b147766" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.132" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "syn" +version = "2.0.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" + +[[package]] +name = "wasm-bindgen" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen-rt" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29c7526379ace8709ee9ab9f2bb50f112d95581063a59ef3097d9c10153886c9" diff --git a/wasm-wrappers/fdw/calendly_fdw/Cargo.toml b/wasm-wrappers/fdw/calendly_fdw/Cargo.toml new file mode 100644 index 00000000..4cb2b10c --- /dev/null +++ b/wasm-wrappers/fdw/calendly_fdw/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "calendly_fdw" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen-rt = "0.26.0" +serde_json = "1.0" +chrono = "0.4.38" + +[package.metadata.component] +package = "supabase:calendly-fdw" + +[package.metadata.component.dependencies] + +[package.metadata.component.target] +path = "wit" + +[package.metadata.component.target.dependencies] +"supabase:wrappers" = { path = "../../wit" } diff --git a/wasm-wrappers/fdw/calendly_fdw/src/lib.rs b/wasm-wrappers/fdw/calendly_fdw/src/lib.rs new file mode 100644 index 00000000..87a405e5 --- /dev/null +++ b/wasm-wrappers/fdw/calendly_fdw/src/lib.rs @@ -0,0 +1,284 @@ +#[allow(warnings)] +mod bindings; +use serde_json::Value as JsonValue; + +use bindings::{ + exports::supabase::wrappers::routines::Guest, + supabase::wrappers::{ + http, stats, time, + types::{Cell, Column, Context, FdwError, FdwResult, OptionsType, Row, TypeOid}, + utils, + }, +}; + +#[derive(Debug, Default)] +struct CalendlyFdw { + base_url: String, + headers: Vec<(String, String)>, + org: String, + object: String, + src_rows: Vec, + src_idx: usize, +} + +static mut INSTANCE: *mut CalendlyFdw = std::ptr::null_mut::(); +static FDW_NAME: &str = "CalendlyFdw"; + +impl CalendlyFdw { + fn init() { + let instance = Self::default(); + unsafe { + INSTANCE = Box::leak(Box::new(instance)); + } + } + + fn this_mut() -> &'static mut Self { + unsafe { &mut (*INSTANCE) } + } + + // convert Calendly response data field to a cell + fn src_to_cell(&self, src_row: &JsonValue, tgt_col: &Column) -> Result, FdwError> { + let tgt_col_name = tgt_col.name(); + + // put all properties into 'attrs' JSON column + if &tgt_col_name == "attrs" { + return Ok(Some(Cell::Json(src_row.to_string()))); + } + + // only accept certain target column names, all the other properties will be put into + // 'attrs' JSON column + if !matches!( + tgt_col_name.as_str(), + "uri" | "name" | "role" | "slug" | "created_at" | "updated_at" + ) { + return Err(format!( + "target column name {} is not supported", + tgt_col_name + )); + } + + let src = src_row + .as_object() + .and_then(|v| v.get(&tgt_col_name)) + .ok_or(format!("source column '{}' not found", tgt_col_name))?; + + // column type mapping + let cell = match tgt_col.type_oid() { + TypeOid::Bool => src.as_bool().map(Cell::Bool), + TypeOid::F64 => src.as_f64().map(Cell::F64), + TypeOid::I64 => src.as_i64().map(Cell::I64), + TypeOid::String => src.as_str().map(|v| Cell::String(v.to_owned())), + TypeOid::Timestamp => { + if let Some(s) = src.as_str() { + let ts = time::parse_from_rfc3339(s)?; + Some(Cell::Timestamp(ts)) + } else { + None + } + } + TypeOid::Timestamptz => { + if let Some(s) = src.as_str() { + let ts = time::parse_from_rfc3339(s)?; + Some(Cell::Timestamptz(ts)) + } else { + None + } + } + TypeOid::Json => src.as_object().map(|_| Cell::Json(src.to_string())), + _ => { + return Err(format!( + "target column '{}' type is not supported", + tgt_col_name + )); + } + }; + + Ok(cell) + } + + // create a request instance + fn create_request(&self, page_token: &Option) -> Result { + let url = match self.object.as_str() { + "current_user" => format!("{}/users/me", self.base_url), + "event_types" | "groups" | "organization_memberships" | "scheduled_events" => { + let mut qs = vec![ + format!("organization={}", self.org), + "count=100".to_string(), + ]; + + if let Some(ref pt) = page_token { + qs.push(format!("page_token={}", pt)); + } + + format!("{}/{}?{}", self.base_url, self.object, qs.join("&")) + } + + _ => return Err(format!("object {} is not supported", self.object)), + }; + + Ok(http::Request { + method: http::Method::Get, + url, + headers: self.headers.clone(), + body: String::default(), + }) + } + + // make request to Calendly API, including following pagination requests + fn fetch_source_data(&mut self) -> FdwResult { + let mut page_token: Option = None; + + self.src_rows.clear(); + self.src_idx = 0; + + loop { + // create a request and send it + let req = self.create_request(&page_token)?; + let resp = match req.method { + http::Method::Get => http::get(&req)?, + _ => unreachable!("invalid request method"), + }; + + // idle for a while for retry when got rate limited error + // ref: https://developer.calendly.com/api-docs/edca8074633f8-upcoming-changes + if resp.status_code == 429 { + if let Some(retry) = resp.headers.iter().find(|h| h.0 == "x-ratelimit-reset") { + let delay_secs = retry.1.parse::().map_err(|e| e.to_string())?; + time::sleep(delay_secs * 1000); + continue; + } + } + + // transform response to json + let resp_json: JsonValue = + serde_json::from_str(&resp.body).map_err(|e| e.to_string())?; + + // check for errors + http::error_for_status(&resp).map_err(|err| format!("{}: {}", err, resp.body))?; + + // unify response object to array and save source rows + let resp_data = if resp_json.pointer("/collection").is_some() { + resp_json + .pointer("/collection") + .and_then(|v| v.as_array().cloned()) + .ok_or("cannot get query result data")? + } else if resp_json.pointer("/resource").is_some() { + vec![resp_json.pointer("/resource").unwrap().clone()] + } else { + return Err("response format is not supported".to_string()); + }; + self.src_rows.extend(resp_data); + + stats::inc_stats(FDW_NAME, stats::Metric::BytesIn, resp.body.len() as i64); + + // deal with pagination to save next page cursor + page_token = resp_json + .pointer("/pagination/next_page_token") + .and_then(|v| v.as_str().map(|s| s.to_owned())); + if page_token.is_none() { + break; + } + } + + Ok(()) + } +} + +impl Guest for CalendlyFdw { + fn host_version_requirement() -> String { + // semver ref: https://docs.rs/semver/latest/semver/enum.Op.html + "^0.1.0".to_string() + } + + fn init(ctx: &Context) -> FdwResult { + Self::init(); + let this = Self::this_mut(); + + // get foreign server options + let opts = ctx.get_options(OptionsType::Server); + this.org = opts.require("organization")?; + this.base_url = opts.require_or("api_url", "https://api.calendly.com"); + let api_key = match opts.get("api_key") { + Some(key) => key, + None => { + let key_id = opts.require("api_key_id")?; + utils::get_vault_secret(&key_id).unwrap_or_default() + } + }; + + // Calendly api authentication + // ref: https://developer.calendly.com/api-docs/d7755e2f9e5fe-calendly-api + this.headers + .push(("user-agent".to_owned(), "Wrappers Calendly FDW".to_string())); + this.headers + .push(("content-type".to_owned(), "application/json".to_string())); + this.headers + .push(("authorization".to_owned(), format!("Bearer {}", api_key))); + + stats::inc_stats(FDW_NAME, stats::Metric::CreateTimes, 1); + + Ok(()) + } + + fn begin_scan(ctx: &Context) -> FdwResult { + let this = Self::this_mut(); + let opts = ctx.get_options(OptionsType::Table); + this.object = opts.require("object")?; + this.fetch_source_data() + } + + fn iter_scan(ctx: &Context, row: &Row) -> Result, FdwError> { + let this = Self::this_mut(); + + // if all source rows are consumed + if this.src_idx >= this.src_rows.len() { + stats::inc_stats(FDW_NAME, stats::Metric::RowsIn, this.src_rows.len() as i64); + stats::inc_stats(FDW_NAME, stats::Metric::RowsOut, this.src_rows.len() as i64); + return Ok(None); + } + + // convert Calendly row to Postgres row + let src_row = &this.src_rows[this.src_idx]; + for tgt_col in ctx.get_columns() { + let cell = this.src_to_cell(src_row, &tgt_col)?; + row.push(cell.as_ref()); + } + + this.src_idx += 1; + + Ok(Some(0)) + } + + fn re_scan(_ctx: &Context) -> FdwResult { + let this = Self::this_mut(); + this.fetch_source_data() + } + + fn end_scan(_ctx: &Context) -> FdwResult { + let this = Self::this_mut(); + this.src_rows.clear(); + Ok(()) + } + + fn begin_modify(_ctx: &Context) -> FdwResult { + Err("modify on foreign table is not supported".to_owned()) + } + + fn insert(_ctx: &Context, _row: &Row) -> FdwResult { + Ok(()) + } + + fn update(_ctx: &Context, _rowid: Cell, _row: &Row) -> FdwResult { + Ok(()) + } + + fn delete(_ctx: &Context, _rowid: Cell) -> FdwResult { + Ok(()) + } + + fn end_modify(_ctx: &Context) -> FdwResult { + Ok(()) + } +} + +bindings::export!(CalendlyFdw with_types_in bindings); diff --git a/wasm-wrappers/fdw/calendly_fdw/wit/world.wit b/wasm-wrappers/fdw/calendly_fdw/wit/world.wit new file mode 100644 index 00000000..fb102486 --- /dev/null +++ b/wasm-wrappers/fdw/calendly_fdw/wit/world.wit @@ -0,0 +1,10 @@ +package supabase:calendly-fdw@0.1.0; + +world calendly { + import supabase:wrappers/http@0.1.0; + import supabase:wrappers/jwt@0.1.0; + import supabase:wrappers/stats@0.1.0; + import supabase:wrappers/time@0.1.0; + import supabase:wrappers/utils@0.1.0; + export supabase:wrappers/routines@0.1.0; +} diff --git a/wrappers/dockerfiles/wasm/server.py b/wrappers/dockerfiles/wasm/server.py index 9b1a831e..cb438000 100644 --- a/wrappers/dockerfiles/wasm/server.py +++ b/wrappers/dockerfiles/wasm/server.py @@ -118,6 +118,60 @@ def do_GET(self): "url": "https://www.notion.so/test-page3-5a67c86fd0da4d0a9dd7f4cf164e6247", "public_url": null, "request_id": "85a75f82-bd22-414e-a3a7-5c00a9451a1c" +} + ''' + elif fdw == "calendly": + body = ''' +{ + "collection": [ + { + "active": true, + "admin_managed": false, + "booking_method": "instant", + "color": "#8247f5", + "created_at": "2024-11-06T07:22:55.937829Z", + "custom_questions": [ + { + "answer_choices": [], + "enabled": true, + "include_other": false, + "name": "Please share anything that will help prepare for our meeting.", + "position": 0, + "required": false, + "type": "text" + } + ], + "deleted_at": null, + "description_html": null, + "description_plain": null, + "duration": 30, + "duration_options": null, + "internal_note": null, + "kind": "solo", + "locations": null, + "name": "30 Minute Meeting", + "pooling_type": null, + "position": 0, + "profile": { + "name": "Test User", + "owner": "https://api.calendly.com/users/3ea2f4a7-8d91-4342-aeb0-32a13b2236dc", + "type": "User" + }, + "scheduling_url": "https://calendly.com/test-user/30min", + "secret": false, + "slug": "30min", + "type": "StandardEventType", + "updated_at": "2024-11-06T07:22:55.937829Z", + "uri": "https://api.calendly.com/event_types/158ecbf6-79bb-4205-a5fc-a7fefa5883a2" + } + ], + "pagination": { + "count": 1, + "next_page": null, + "next_page_token": null, + "previous_page": null, + "previous_page_token": null + } } ''' else: diff --git a/wrappers/src/fdw/wasm_fdw/tests.rs b/wrappers/src/fdw/wasm_fdw/tests.rs index 9fa944ec..1bd9499d 100644 --- a/wrappers/src/fdw/wasm_fdw/tests.rs +++ b/wrappers/src/fdw/wasm_fdw/tests.rs @@ -156,6 +156,50 @@ mod tests { results, vec!["https://www.notion.so/test-page3-5a67c86fd0da4d0a9dd7f4cf164e6247"] ); + + // Calendly FDW test + c.update( + r#"CREATE SERVER calendly_server + FOREIGN DATA WRAPPER wasm_wrapper + OPTIONS ( + fdw_package_url 'file://../../../wasm-wrappers/fdw/calendly_fdw/target/wasm32-unknown-unknown/release/calendly_fdw.wasm', + fdw_package_name 'supabase:calendly-fdw', + fdw_package_version '>=0.1.0', + organization 'https://api.calendly.com/organizations/xxx', + api_url 'http://localhost:8096/calendly', + api_key '1234567890' + )"#, + None, + None, + ) + .unwrap(); + c.update( + r#" + CREATE FOREIGN TABLE calendly_event_types ( + uri text, + created_at timestamp, + updated_at timestamp, + attrs jsonb + ) + SERVER calendly_server + OPTIONS ( + object 'event_types' + ) + "#, + None, + None, + ) + .unwrap(); + + let results = c + .select("SELECT * FROM calendly_event_types", None, None) + .unwrap() + .filter_map(|r| r.get_by_name::<&str, _>("uri").unwrap()) + .collect::>(); + assert_eq!( + results, + vec!["https://api.calendly.com/event_types/158ecbf6-79bb-4205-a5fc-a7fefa5883a2"] + ); }); } }