From 368b4aa0a1e207b115156667dabae0bfa36219a5 Mon Sep 17 00:00:00 2001 From: KokaKiwi Date: Wed, 12 Feb 2025 11:06:01 +0100 Subject: [PATCH] feat: Add components subcommand (#197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Alex Casalboni Co-authored-by: Nicolas Girardot <122389871+NicolasGirardot@users.noreply.github.com> Co-authored-by: Clément B Co-authored-by: Nicolas Girardot <122389871+NicolasGirardot@users.noreply.github.com> Co-authored-by: Alex Casalboni --- Cargo.lock | 293 ++++++++++- Cargo.toml | 6 +- crates/api-client/Cargo.toml | 2 + crates/api-client/openapi.json | 550 +++++++++++++++++--- crates/api-client/src/auth.rs | 10 + crates/api-client/src/lib.rs | 29 ++ crates/api-client/src/upload.rs | 22 + crates/cli/Cargo.toml | 13 +- crates/cli/src/commands/auth/login.rs | 23 +- crates/cli/src/commands/auth/mod.rs | 3 +- crates/cli/src/commands/auth/whoami.rs | 13 +- crates/cli/src/commands/components/build.rs | 26 + crates/cli/src/commands/components/check.rs | 88 ++++ crates/cli/src/commands/components/init.rs | 70 +++ crates/cli/src/commands/components/list.rs | 6 + crates/cli/src/commands/components/mod.rs | 27 + crates/cli/src/commands/components/new.rs | 66 +++ crates/cli/src/commands/components/pull.rs | 6 + crates/cli/src/commands/components/push.rs | 156 ++++++ crates/cli/src/commands/components/test.rs | 173 ++++++ crates/cli/src/commands/macros.rs | 9 +- crates/cli/src/commands/mod.rs | 3 + crates/cli/src/commands/serve.rs | 36 +- crates/cli/src/components/boilerplate.rs | 94 ++++ crates/cli/src/components/manifest.rs | 136 +++++ crates/cli/src/components/mod.rs | 2 + crates/cli/src/config.rs | 9 +- crates/cli/src/logger.rs | 26 + crates/cli/src/main.rs | 26 +- 29 files changed, 1777 insertions(+), 146 deletions(-) create mode 100644 crates/api-client/src/upload.rs create mode 100644 crates/cli/src/commands/components/build.rs create mode 100644 crates/cli/src/commands/components/check.rs create mode 100644 crates/cli/src/commands/components/init.rs create mode 100644 crates/cli/src/commands/components/list.rs create mode 100644 crates/cli/src/commands/components/mod.rs create mode 100644 crates/cli/src/commands/components/new.rs create mode 100644 crates/cli/src/commands/components/pull.rs create mode 100644 crates/cli/src/commands/components/push.rs create mode 100644 crates/cli/src/commands/components/test.rs create mode 100644 crates/cli/src/components/boilerplate.rs create mode 100644 crates/cli/src/components/manifest.rs create mode 100644 crates/cli/src/components/mod.rs diff --git a/Cargo.lock b/Cargo.lock index abe3bce..2a965f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -161,6 +161,9 @@ name = "arbitrary" version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +dependencies = [ + "derive_arbitrary", +] [[package]] name = "async-compression" @@ -241,6 +244,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + [[package]] name = "base64" version = "0.21.7" @@ -384,6 +396,27 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.11+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "camino" version = "1.1.9" @@ -639,6 +672,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "cookie" version = "0.18.1" @@ -800,6 +839,21 @@ dependencies = [ "target-lexicon", ] +[[package]] +name = "crc" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.4.2" @@ -913,6 +967,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "deflate64" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" + [[package]] name = "deranged" version = "0.3.11" @@ -923,6 +983,17 @@ dependencies = [ "serde", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "diff" version = "0.1.13" @@ -937,6 +1008,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1036,6 +1108,12 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" +[[package]] +name = "easy-ext" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc5d6d6a8504f8caedd7de14576464383900cd3840b7033a7a3dce5ac00121ca" + [[package]] name = "edgee" version = "0.7.4" @@ -1043,14 +1121,21 @@ dependencies = [ "anyhow", "clap", "edgee-api-client", + "edgee-components-runtime", "edgee-server", "inquire", + "miette", "openssl", + "reqwest", + "serde", + "serde_json", "serde_yml", "tokio", "toml", "tracing", "tracing-subscriber", + "url", + "zip", ] [[package]] @@ -1061,8 +1146,10 @@ dependencies = [ "bon", "chrono", "dirs 6.0.0", + "easy-ext", "futures", "progenitor", + "progenitor-client", "reqwest", "schemars", "serde", @@ -1521,6 +1608,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "home" version = "0.5.11" @@ -1879,6 +1975,7 @@ dependencies = [ "fxhash", "newline-converter", "once_cell", + "tempfile", "unicode-segmentation", "unicode-width 0.1.14", ] @@ -1911,6 +2008,12 @@ version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_executable" version = "1.0.4" @@ -2041,7 +2144,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" dependencies = [ "cfg-if", - "windows-targets 0.52.6", + "windows-targets 0.48.5", ] [[package]] @@ -2093,12 +2196,28 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lockfree-object-pool" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9374ef4228402d4b7e403e5838cb880d9ee663314b0a900d5a6aabf0c213552e" + [[package]] name = "log" version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + [[package]] name = "mach2" version = "0.4.2" @@ -2138,6 +2257,37 @@ dependencies = [ "rustix", ] +[[package]] +name = "miette" +version = "7.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317f146e2eb7021892722af37cf1b971f0a70c8406f487e24952667616192c64" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "thiserror 1.0.69", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23c9b935fbe1d6cbd1dac857b54a688145e2d93f48db36010514d0f612d0ad67" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "mime" version = "0.3.17" @@ -2368,6 +2518,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "owo-colors" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb37767f6569cd834a413442455e0f066d0d522de8630436e2a1761d9726ba56" + [[package]] name = "parking_lot" version = "0.12.3" @@ -2397,6 +2553,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -2774,6 +2940,7 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2", @@ -3161,6 +3328,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.8" @@ -3242,6 +3420,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "slab" version = "0.4.9" @@ -3300,6 +3484,27 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f44ed3c63152de6a9f90acbea1a110441de43006ea51bcce8f436196a288b" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + [[package]] name = "syn" version = "2.0.96" @@ -3408,6 +3613,26 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "terminal_size" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5352447f921fda68cf61b4101566c0bdb5104eff6804d0678e5227580ab6a4e9" +dependencies = [ + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "textwrap" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d434d3f8967a09480fb04132ebe0a3e088c173e6d0ee7897abbdf4eab0f8b9" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.1.14", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3779,6 +4004,12 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -3824,6 +4055,7 @@ dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -4432,7 +4664,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.48.0", ] [[package]] @@ -4794,6 +5026,20 @@ name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerovec" @@ -4817,6 +5063,49 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae9c1ea7b3a5e1f4b922ff856a129881167511563dc219869afe3787fc0c1a45" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "hmac", + "indexmap 2.7.0", + "lzma-rs", + "memchr", + "pbkdf2", + "rand", + "sha1", + "thiserror 2.0.11", + "time", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zopfli" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5019f391bac5cf252e93bbcc53d039ffd62c7bfb7c150414d61369afe57e946" +dependencies = [ + "bumpalo", + "crc32fast", + "lockfree-object-pool", + "log", + "once_cell", + "simd-adler32", +] + [[package]] name = "zstd" version = "0.13.2" diff --git a/Cargo.toml b/Cargo.toml index 3dc1728..3b7dae9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ chrono = "0.4.38" clap = "4.5.21" cookie = "0.18.1" dirs = "6.0.0" +easy-ext = "1.0.2" futures = "0.3.31" hex = "0.4.3" html-escape = "0.2.13" @@ -39,9 +40,11 @@ json_pretty = "0.1.2" lazy_static = "1.5.0" libflate = "2.1.0" log = "0.4.22" +miette = "7.4.0" openssl = "0.10.70" pin-project = "1.1.7" -progenitor = "0.9.0" +progenitor = "0.9.1" +progenitor-client = "0.9.1" rand = "0.8.5" regex = "1.11.1" reqwest = "0.12.9" @@ -64,6 +67,7 @@ url = "2.5.2" uuid = "1.11.0" wasmtime = "28.0.0" wasmtime-wasi = "28.0.0" +zip = "2.2.2" cargo-llvm-cov = "0.6.15" pretty_assertions = "1.4.1" diff --git a/crates/api-client/Cargo.toml b/crates/api-client/Cargo.toml index 38e1ec2..99d51c1 100644 --- a/crates/api-client/Cargo.toml +++ b/crates/api-client/Cargo.toml @@ -14,8 +14,10 @@ anyhow.workspace = true bon.workspace = true chrono = { workspace = true, features = ["serde"] } dirs.workspace = true +easy-ext.workspace = true futures.workspace = true progenitor.workspace = true +progenitor-client.workspace = true reqwest = { workspace = true, features = ["json"] } schemars = { workspace = true, features = ["chrono"] } serde = { workspace = true, features = ["derive"] } diff --git a/crates/api-client/openapi.json b/crates/api-client/openapi.json index a073036..2f0a667 100644 --- a/crates/api-client/openapi.json +++ b/crates/api-client/openapi.json @@ -851,6 +851,132 @@ } } }, + "/v1/projects/{id}/counters": { + "get": { + "operationId": "getProjectCounters", + "summary": "List all statistics for a given project", + "description": "List all statistics for a given project.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "month", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date", + "description": "The month to filter the statistics." + } + }, + { + "name": "day", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date", + "description": "The day to filter the statistics." + } + } + ], + "responses": { + "200": { + "description": "The retrieved project statistics", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectCounters" + } + } + } + }, + "4XX": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/v1/projects/{id}/components/{componentId}/counters": { + "get": { + "operationId": "getProjectComponentCounters", + "summary": "List all Counters for a given project component", + "description": "List all Counters for a given project component.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "componentId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "month", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date", + "description": "The month to filter the counters." + } + }, + { + "name": "day", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date", + "description": "The day to filter the counters." + } + } + ], + "responses": { + "200": { + "description": "The retrieved project counters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectComponentCounters" + } + } + } + }, + "4XX": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/v1/projects/{id}/domains": { "get": { "operationId": "listProjectDomains", @@ -1563,16 +1689,61 @@ } } } + }, + "post": { + "operationId": "createComponent", + "summary": "Create a Component", + "description": "Create a Component.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ComponentCreateInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The created Component", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Component" + } + } + } + }, + "4XX": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } } }, - "/v1/components/{id}": { + "/v1/components/{orgSlug}/{componentSlug}": { "get": { - "operationId": "getComponent", - "summary": "Get a Component", - "description": "Retrieve a Component.", + "operationId": "getComponentBySlug", + "summary": "Get a Component by Slug", + "description": "Retrieve a Component by Slug.", "parameters": [ { - "name": "id", + "name": "orgSlug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "componentSlug", "in": "path", "required": true, "schema": { @@ -1604,12 +1775,20 @@ } }, "put": { - "operationId": "updateComponent", - "summary": "Update a Component", - "description": "Updates an existing Component.", + "operationId": "updateComponentBySlug", + "summary": "Update a Component by Slug", + "description": "Updates an existing Component by Slug.", "parameters": [ { - "name": "id", + "name": "orgSlug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "componentSlug", "in": "path", "required": true, "schema": { @@ -1651,14 +1830,22 @@ } } }, - "/v1/components/{id}/versions": { + "/v1/components/{orgSlug}/{componentSlug}/versions": { "post": { "operationId": "createComponentVersion", "summary": "Create a Component Version", "description": "Create a Component Version.", "parameters": [ { - "name": "id", + "name": "orgSlug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "componentSlug", "in": "path", "required": true, "schema": { @@ -1700,14 +1887,14 @@ } } }, - "/v1/components/{id}/versions/{versionId}": { - "put": { - "operationId": "updateComponentVersion", - "summary": "Update a Component Version", - "description": "Update a Component Version.", + "/v1/components/{orgSlug}/{componentSlug}/versions": { + "post": { + "operationId": "createComponentVersionBySlug", + "summary": "Create a Component Version by Slug", + "description": "Create a Component Version by Slug.", "parameters": [ { - "name": "id", + "name": "orgSlug", "in": "path", "required": true, "schema": { @@ -1715,7 +1902,7 @@ } }, { - "name": "versionId", + "name": "componentSlug", "in": "path", "required": true, "schema": { @@ -1727,7 +1914,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ComponentVersionUpdateInput" + "$ref": "#/components/schemas/ComponentVersionCreateInput" } } }, @@ -1735,7 +1922,7 @@ }, "responses": { "200": { - "description": "The updated Component Version", + "description": "The created Component Version", "content": { "application/json": { "schema": { @@ -1757,14 +1944,30 @@ } } }, - "/v1/organizations/{id}/components": { - "get": { - "operationId": "listOrganizationComponents", - "summary": "List organization components", - "description": "Returns a list of organization components.", + "/v1/components/{orgSlug}/{componentSlug}/versions/{versionId}": { + "put": { + "operationId": "updateComponentVersionBySlug", + "summary": "Update a Component Version by Slug", + "description": "Update a Component Version by Slug.", "parameters": [ { - "name": "id", + "name": "orgSlug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "componentSlug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "versionId", "in": "path", "required": true, "schema": { @@ -1776,35 +1979,19 @@ "content": { "application/json": { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ComponentListParams" - } - ] + "$ref": "#/components/schemas/ComponentVersionUpdateInput" } } }, - "required": false + "required": true }, "responses": { "200": { - "description": "A list of Components", + "description": "The updated Component Version", "content": { "application/json": { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ListResponse" - } - ], - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Component" - } - } - } + "$ref": "#/components/schemas/ComponentVersion" } } } @@ -1820,11 +2007,13 @@ } } } - }, - "post": { - "operationId": "createComponent", - "summary": "Create a Component", - "description": "Create a Component.", + } + }, + "/v1/organizations/{id}/components": { + "get": { + "operationId": "listOrganizationComponents", + "summary": "List organization components", + "description": "Returns a list of organization components.", "parameters": [ { "name": "id", @@ -1839,19 +2028,35 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ComponentCreateInput" + "allOf": [ + { + "$ref": "#/components/schemas/ComponentListParams" + } + ] } } }, - "required": true + "required": false }, "responses": { "200": { - "description": "The created Component", + "description": "A list of Components", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Component" + "allOf": [ + { + "$ref": "#/components/schemas/ListResponse" + } + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Component" + } + } + } } } } @@ -2054,7 +2259,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/User" + "$ref": "#/components/schemas/UserWithRoles" } } } @@ -2093,7 +2298,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/User" + "$ref": "#/components/schemas/UserWithRoles" } } } @@ -2248,6 +2453,24 @@ } } } + }, + "/v1/upload/presign": { + "get": { + "operationId": "getUploadPresignedUrl", + "summary": "Get Upload Presigned URL", + "responses": { + "200": { + "description": "An Upload Presigned URL", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadPresign" + } + } + } + } + } + } } }, "components": { @@ -2374,7 +2597,16 @@ "description": "Time at which the object was last updated.", "readOnly": true } - } + }, + "required": [ + "object", + "id", + "name", + "slug", + "current_billing_plan", + "created_at", + "updated_at" + ] }, "OrganizationCreateInput": { "type": "object", @@ -2890,7 +3122,7 @@ "component_slug": { "type": "string", "description": "Slug of the component", - "example": "edgee/google_analytics" + "example": "edgee/google-analytics" }, "component_version": { "type": "string", @@ -2967,9 +3199,9 @@ "readOnly": true }, "versions": { - "type": "array", + "type": "object", "description": "List of versions of the component", - "items": { + "additionalProperties": { "$ref": "#/components/schemas/ComponentVersion" } }, @@ -3034,9 +3266,9 @@ }, "dynamic_fields": { "type": "array", - "description": "List of dynamic fields", + "description": "List of configuration fields", "items": { - "$ref": "#/components/schemas/DynamicFieldsItem" + "$ref": "#/components/schemas/ConfigurationField" } }, "changelog": { @@ -3052,30 +3284,37 @@ } } }, - "DynamicFieldsItem": { + "ConfigurationField": { "type": "object", "properties": { "name": { "type": "string", - "description": "The name of the dynamic field." + "description": "The name of the configuration field." }, "title": { "type": "string", - "description": "The title of the dynamic field." + "description": "The title of the configuration field." }, "type": { "type": "string", - "description": "The type of the dynamic field." + "description": "The type of the configuration field.", + "enum": ["string", "boolean", "number"] }, "required": { "type": "boolean", - "description": "Whether the dynamic field is required." + "description": "Whether the configuration field is required." }, "description": { "type": "string", - "description": "The description of the dynamic field." + "description": "The description of the configuration field." } - } + }, + "required": [ + "name", + "title", + "type", + "required" + ] }, "ComponentCreateInput": { "type": "object", @@ -3085,6 +3324,11 @@ "description": "Name of the component.", "example": "My Component" }, + "slug": { + "type": "string", + "description": "Slug of the component.", + "example": "my-component" + }, "category": { "type": "string", "description": "Category of the component.", @@ -3143,6 +3387,10 @@ "is_archived": { "type": "boolean", "description": "Whether the component is archived or not." + }, + "public": { + "type": "boolean", + "description": "Whether the component is public or not." } } }, @@ -3169,9 +3417,9 @@ }, "dynamic_fields": { "type": "array", - "description": "List of dynamic fields", + "description": "List of configuration fields", "items": { - "$ref": "#/components/schemas/DynamicFieldsItem" + "$ref": "#/components/schemas/ConfigurationField" } }, "changelog": { @@ -3269,7 +3517,7 @@ "role" ] }, - "User": { + "UserBase": { "type": "object", "description": "A User is a unique identifier that you can use to manage and organize your work.", "properties": { @@ -3299,10 +3547,6 @@ "description": "Slug of the user", "readOnly": true }, - "role": { - "type": "string", - "readOnly": true - }, "avatar_url": { "type": "string", "description": "Avatar of the user", @@ -3325,6 +3569,46 @@ }, "required": ["object", "id", "email", "name", "slug", "role", "created_at", "updated_at"] }, + "User": { + "type": "object", + "description": "A User is a unique identifier that you can use to manage and organize your work.", + "allOf": [ + { "$ref": "#/components/schemas/UserBase" } + ], + "properties": { + "role": { + "type": "string", + "description": "Role of the user", + "readOnly": true + } + }, + "required": [ + "object", + "id", + "email", + "name", + "slug", + "role", + "created_at", + "updated_at" + ] + }, + "UserWithRoles": { + "allOf": [ + { "$ref": "#/components/schemas/UserBase" } + ], + "properties": { + "roles": { + "type": "object", + "additionalProperties": { + "type": "string", + "enum": ["admin", "member"], + "description": "The role of the user in each organization." + }, + "description": "Roles of the user in multiple organizations. The keys are organization IDs, and values are roles ('admin', 'member')." + } + } + }, "OrganizationUser": { "type": "object", "allOf": [ @@ -3507,6 +3791,17 @@ } } }, + "UploadPresign": { + "type": "object", + "properties": { + "upload_url": { + "type": "string" + } + }, + "required": [ + "upload_url" + ] + }, "ErrorResponse": { "type": "object", "description": "An error response from the API. More info [here]('/docs/api-reference/errors')", @@ -3523,7 +3818,8 @@ "update_error", "deletion_error", "forbidden_error", - "authentication_error" + "authentication_error", + "conflict_error" ], "default": "invalid_request_error", "description": "The type of error returned." @@ -3552,7 +3848,103 @@ } } } - } + }, + "required": [ + "message" + ] + } + }, + "required": [ + "error" + ] + }, + "ProjectCounters": { + "type": "object", + "description": "A Project Counters object represents the statistics of a project.", + "example": { + "object": "project_counters", + "request_count": 32423432, + "event_count": 12390, + "month": "2023-12", + "project_id": "6d614bd5-4d81-4a9b-8ba4-6fe3ffd33748" + }, + "properties": { + "object": { + "type": "string", + "description": "String representing the object's type. Objects of the same type share the same value", + "example": "project_counters" + }, + "request_count": { + "type": "integer", + "description": "The number of requests made to the project." + }, + "event_count": { + "type": "integer", + "description": "The number of events made to the project." + }, + "month": { + "type": "string", + "format": "date", + "description": "The month of the statistics." + }, + "day": { + "type": "string", + "format": "date", + "description": "The day of the statistics." + }, + "project_id": { + "type": "string", + "description": "The Project ID." + } + } + }, + "ProjectComponentCounters": { + "type": "object", + "description": "A Project Counters object represents the counters of a project.", + "example": { + "object": "project_counters", + "user_count": 123, + "track_count": 456, + "page_count": 789, + "day": "2023-12-25", + "project_id": "6d614bd5-4d81-4a9b-8ba4-6fe3ffd33748", + "component_id": "abcdef" + }, + "properties": { + "object": { + "type": "string", + "description": "String representing the object's type. Objects of the same type share the same value", + "example": "project_counters" + }, + "user_count": { + "type": "integer", + "description": "The number of user events generated by the component." + }, + "track_count": { + "type": "integer", + "description": "The number of track events generated by the component." + }, + "page_count": { + "type": "integer", + "description": "The number of page events generated by the component." + }, + "month": { + "type": "string", + "format": "date", + "description": "The month of the statistics." + }, + "day": { + "type": "string", + "format": "date", + "description": "The day of the statistics." + }, + "project_id": { + "type": "string", + "description": "The Project ID." + }, + "component_id": { + "type": "string", + "description": "The Project component ID." } } } diff --git a/crates/api-client/src/auth.rs b/crates/api-client/src/auth.rs index f4b2556..1a7ebc0 100644 --- a/crates/api-client/src/auth.rs +++ b/crates/api-client/src/auth.rs @@ -65,6 +65,16 @@ impl Credentials { file.write_all(content.as_bytes()) .context("Could not write credentials data") } + + pub fn check_api_token(&self) -> Result<()> { + let Some(_api_token) = self.api_token.as_deref() else { + anyhow::bail!("Not logged in"); + }; + + // TODO: Check API token is valid using the API + + Ok(()) + } } impl<'a, S: State> ConnectBuilder<'a, S> { diff --git a/crates/api-client/src/lib.rs b/crates/api-client/src/lib.rs index e4d718b..9c13812 100644 --- a/crates/api-client/src/lib.rs +++ b/crates/api-client/src/lib.rs @@ -1,4 +1,5 @@ pub mod auth; +mod upload; pub const PROD_BASEURL: &str = "https://api.edgee.app"; @@ -38,3 +39,31 @@ pub fn connect(#[builder(default = PROD_BASEURL)] baseurl: &str, api_token: Stri Client::new_with_client(&baseurl, client) } + +#[easy_ext::ext(ErrorExt)] +impl Error { + pub fn into_message(self) -> String { + match self { + Error::ErrorResponse(err) => err.error.message.clone(), + _ => self.to_string(), + } + } +} + +#[easy_ext::ext(ResultExt)] +impl Result> { + pub fn api_context(self, ctx: impl std::fmt::Display) -> anyhow::Result { + self.map_err(|err| anyhow::anyhow!("{ctx}: {}", err.into_message())) + } + + pub fn api_with_context(self, f: F) -> anyhow::Result + where + F: FnOnce() -> C, + C: std::fmt::Display, + { + self.map_err(|err| { + let ctx = f(); + anyhow::anyhow!("{ctx}: {}", err.into_message()) + }) + } +} diff --git a/crates/api-client/src/upload.rs b/crates/api-client/src/upload.rs new file mode 100644 index 0000000..2490d2c --- /dev/null +++ b/crates/api-client/src/upload.rs @@ -0,0 +1,22 @@ +use std::path::Path; + +use anyhow::Result; + +use super::Client; + +impl Client { + pub async fn upload_file(&self, path: &Path) -> Result { + let presigned_url = self.get_upload_presigned_url().send().await?; + let upload_url = &presigned_url.upload_url; + + let content = std::fs::read(path)?; + + let client = reqwest::Client::new(); + let res = client.put(upload_url).body(content).send().await?; + if !res.status().is_success() { + anyhow::bail!("Could not upload file"); + } + + Ok(upload_url.clone()) + } +} diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index ae8d94d..bffc160 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -12,18 +12,23 @@ edition.workspace = true [dependencies] anyhow.workspace = true clap = { workspace = true, features = ["derive", "env"] } -inquire.workspace = true +inquire = { workspace = true, features = ["editor"] } +miette = { workspace = true, features = ["fancy"] } openssl.workspace = true +serde = { workspace = true, features = ["derive"] } serde_yml.workspace = true +serde_json.workspace = true tracing.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } toml.workspace = true tracing-subscriber = { workspace = true, features = ["env-filter", "json"] } +url = { workspace = true, features = ["serde"] } +reqwest = { workspace = true, features = ["blocking"] } +zip.workspace = true edgee-api-client.workspace = true edgee-server.workspace = true +edgee-components-runtime.workspace = true [features] -bundled = [ - "openssl/vendored", -] +bundled = ["openssl/vendored"] diff --git a/crates/cli/src/commands/auth/login.rs b/crates/cli/src/commands/auth/login.rs index a4272aa..b21f211 100644 --- a/crates/cli/src/commands/auth/login.rs +++ b/crates/cli/src/commands/auth/login.rs @@ -1,17 +1,19 @@ +use anyhow::Result; + setup_command! {} -pub async fn run(_opts: Options) { +pub async fn run(_opts: Options) -> Result<()> { use inquire::{Confirm, Password, PasswordDisplayMode}; use edgee_api_client::auth::Credentials; - let mut creds = Credentials::load().unwrap(); + let mut creds = Credentials::load()?; let confirm_overwrite = Confirm::new("An API token is already present, do you want to overwrite it?") .with_default(false); - if creds.api_token.is_some() && !confirm_overwrite.prompt().unwrap() { - return; + if creds.api_token.is_some() && !confirm_overwrite.prompt()? { + return Ok(()); } let api_token = Password::new("Enter Edgee API token (press Ctrl+R to toggle input display):") @@ -20,20 +22,13 @@ pub async fn run(_opts: Options) { .with_display_toggle_enabled() .without_confirmation() .with_validator(inquire::required!("API token cannot be empty")) - .prompt() - .unwrap(); + .prompt()?; creds.api_token.replace(api_token); let client = edgee_api_client::new().credentials(&creds).connect(); - let user = match client.get_me().send().await { - Ok(res) => res.into_inner(), - Err(err) => { - tracing::error!("{err:?}"); - return; - } - }; + let user = client.get_me().send().await?.into_inner(); println!("Logged as {} ({})", user.name, user.email); - creds.save().unwrap(); + creds.save() } diff --git a/crates/cli/src/commands/auth/mod.rs b/crates/cli/src/commands/auth/mod.rs index 272330e..beb691e 100644 --- a/crates/cli/src/commands/auth/mod.rs +++ b/crates/cli/src/commands/auth/mod.rs @@ -7,6 +7,7 @@ setup_commands! { pub type Options = Command; -pub async fn run(command: Command) { +pub async fn run(command: Command) -> anyhow::Result<()> { + crate::logger::init_cli(); command.run().await } diff --git a/crates/cli/src/commands/auth/whoami.rs b/crates/cli/src/commands/auth/whoami.rs index a0215f9..f558a7f 100644 --- a/crates/cli/src/commands/auth/whoami.rs +++ b/crates/cli/src/commands/auth/whoami.rs @@ -1,19 +1,18 @@ setup_command! {} -pub async fn run(_opts: Options) { +pub async fn run(_opts: Options) -> anyhow::Result<()> { use edgee_api_client::auth::Credentials; - let creds = Credentials::load().unwrap(); - if creds.api_token.is_none() { - eprintln!("Not logged in"); - return; - } + let creds = Credentials::load()?; + creds.check_api_token()?; let client = edgee_api_client::new().credentials(&creds).connect(); - let user = client.get_me().send().await.unwrap(); + let user = client.get_me().send().await?; println!("Logged in as:"); println!(" ID: {}", user.id); println!(" Name: {}", user.name); println!(" Email: {}", user.email); + + Ok(()) } diff --git a/crates/cli/src/commands/components/build.rs b/crates/cli/src/commands/components/build.rs new file mode 100644 index 0000000..ac5cf12 --- /dev/null +++ b/crates/cli/src/commands/components/build.rs @@ -0,0 +1,26 @@ +#[derive(Debug, clap::Parser)] +pub struct Options {} + +pub async fn run(_opts: Options) -> anyhow::Result<()> { + use std::process::Command; + + use crate::components::manifest::{self, Manifest}; + + let Some(manifest_path) = manifest::find_manifest_path() else { + anyhow::bail!("Edgee Manifest not found. Please run `edgee component new` and start from a template or `edgee component init` to create a new empty manifest in this folder."); + }; + let manifest = Manifest::load(&manifest_path).map_err(|err| anyhow::anyhow!(err))?; + + tracing::info!("Running: {}", manifest.package.build.command); + let mut cmd = Command::new("sh"); + cmd.arg("-c").arg(manifest.package.build.command); + let status = cmd.status()?; + + if status.success() { + tracing::info!("Build successful!"); + } else { + tracing::error!("Build errored!"); + } + + Ok(()) +} diff --git a/crates/cli/src/commands/components/check.rs b/crates/cli/src/commands/components/check.rs new file mode 100644 index 0000000..2d0246a --- /dev/null +++ b/crates/cli/src/commands/components/check.rs @@ -0,0 +1,88 @@ +#[derive(Debug, clap::Parser)] +pub struct Options {} + +enum ComponentType { + DataCollection, + #[allow(dead_code)] + ConsentMapping, +} + +async fn check_component( + component_type: ComponentType, + component_path: &str, +) -> anyhow::Result<()> { + use edgee_components_runtime::config::{ + ComponentsConfiguration, ConsentMappingComponents, DataCollectionComponents, + }; + use edgee_components_runtime::context::ComponentsContext; + + if !std::fs::exists(component_path)? { + anyhow::bail!( + "Component {} does not exist. Please run `edgee component build` first", + component_path + ); + } + + let config = match component_type { + ComponentType::DataCollection => ComponentsConfiguration { + data_collection: vec![DataCollectionComponents { + id: component_path.to_string(), + file: component_path.to_string(), + ..Default::default() + }], + ..Default::default() + }, + ComponentType::ConsentMapping => ComponentsConfiguration { + consent_mapping: vec![ConsentMappingComponents { + name: component_path.to_string(), + component: component_path.to_string(), + ..Default::default() + }], + ..Default::default() + }, + }; + + let context = ComponentsContext::new(&config) + .map_err(|e| anyhow::anyhow!("Invalid component {}: {}", component_path, e))?; + + let mut store = context.empty_store(); + + match component_type { + ComponentType::DataCollection => { + let _ = context + .get_data_collection_instance(component_path, &mut store) + .await?; + } + ComponentType::ConsentMapping => { + let _ = context + .get_consent_mapping_instance(component_path, &mut store) + .await?; + } + } + + println!("Component {} is valid", component_path); + Ok(()) +} + +pub async fn run(_opts: Options) -> anyhow::Result<()> { + use anyhow::Context; + + use crate::components::manifest::{self, Manifest}; + + let Some(manifest_path) = manifest::find_manifest_path() else { + anyhow::bail!("Edgee Manifest not found. Please run `edgee component new` and start from a template or `edgee component init` to create a new empty manifest in this folder."); + }; + + let manifest = Manifest::load(&manifest_path)?; + let component_path = manifest + .package + .build + .output_path + .to_str() + .context("Output path should be a valid UTF-8 string")?; + + // TODO: dont assume that it is a data collection component, add type in manifest + check_component(ComponentType::DataCollection, component_path).await?; + + Ok(()) +} diff --git a/crates/cli/src/commands/components/init.rs b/crates/cli/src/commands/components/init.rs new file mode 100644 index 0000000..4e2a69a --- /dev/null +++ b/crates/cli/src/commands/components/init.rs @@ -0,0 +1,70 @@ +use crate::components::{ + boilerplate::{CATEGORY_OPTIONS, LANGUAGE_OPTIONS, SUBCATEGORY_OPTIONS}, + manifest::{self, Build, Manifest, Package}, +}; + +#[derive(Debug, clap::Parser)] +pub struct Options {} + +pub async fn run(_opts: Options) -> anyhow::Result<()> { + use inquire::{Select, Text}; + if manifest::find_manifest_path().is_some() { + anyhow::bail!("Manifest already exists"); + } + + let component_name = Text::new("Enter the name of the component:") + .with_validator(inquire::required!("Component name cannot be empty")) + .with_validator(inquire::min_length!( + 3, + "Component name must be at least 3 characters" + )) + .prompt()?; + + let component_language = Select::new( + "Select the language of the component:", + LANGUAGE_OPTIONS.to_vec(), + ) + .prompt()?; + let component_category = if CATEGORY_OPTIONS.len() == 1 { + CATEGORY_OPTIONS[0].clone() // Accès direct car on sait qu'il y a un seul élément + } else { + Select::new( + "Select the category of the component:", + CATEGORY_OPTIONS.to_vec(), // Pas besoin de `.to_vec()`, on passe une slice + ) + .prompt()? + }; + + let component_subcategory = Select::new( + "Select the subcategory of the component:", + SUBCATEGORY_OPTIONS.to_vec(), + ) + .prompt()?; + + println!( + "Initiating component {} in {}", + component_name, component_language.name + ); + + Manifest { + manifest_version: manifest::MANIFEST_VERSION, + package: Package { + name: component_name, + version: "0.1.0".to_string(), + wit_world_version: "0.4.0".to_string(), + category: *component_category.value, + subcategory: *component_subcategory.value, + description: None, + documentation: None, + repository: None, + config_fields: Default::default(), + build: Build { + command: component_language.default_build_command.to_string(), + output_path: std::path::PathBuf::from(""), + }, + }, + } + .save(std::path::Path::new("./"))?; + + Ok(()) +} diff --git a/crates/cli/src/commands/components/list.rs b/crates/cli/src/commands/components/list.rs new file mode 100644 index 0000000..f758265 --- /dev/null +++ b/crates/cli/src/commands/components/list.rs @@ -0,0 +1,6 @@ +#[derive(Debug, clap::Parser)] +pub struct Options {} + +pub async fn run(_opts: Options) -> anyhow::Result<()> { + Ok(()) +} diff --git a/crates/cli/src/commands/components/mod.rs b/crates/cli/src/commands/components/mod.rs new file mode 100644 index 0000000..bae59d3 --- /dev/null +++ b/crates/cli/src/commands/components/mod.rs @@ -0,0 +1,27 @@ +setup_commands! { + /// Init a new component manifest in the current directory + Init(init), + /// Create component in a new directory with sample code + New(new), + + /// Compile the component in the current directory into WASM + Build(build), + + /// Pull a component from the Edgee Component Registry + Pull(pull), + /// Push a component to the Edgee Component Registry + Push(push), + /// List components you've previously pulled + List(list), + /// Check if the local WASM component file is valid + Check(check), + /// Run the component in the current folder with sample events + Test(test) +} + +pub type Options = Command; + +pub async fn run(command: Command) -> anyhow::Result<()> { + crate::logger::init_cli(); + command.run().await +} diff --git a/crates/cli/src/commands/components/new.rs b/crates/cli/src/commands/components/new.rs new file mode 100644 index 0000000..bf8aef9 --- /dev/null +++ b/crates/cli/src/commands/components/new.rs @@ -0,0 +1,66 @@ +use reqwest::Client; +use std::fs::{create_dir_all, File}; +use std::io::{Cursor, Read, Write}; +use std::path::Path; +use zip::read::ZipArchive; + +use crate::components::boilerplate::LANGUAGE_OPTIONS; + +#[derive(Debug, clap::Parser)] +pub struct Options {} + +pub async fn run(_opts: Options) -> anyhow::Result<()> { + use inquire::{Select, Text}; + + let component_name = Text::new("Enter the name of the component:") + .with_validator(inquire::required!("Component name cannot be empty")) + .prompt()?; + + let component_language = Select::new( + "Select the language of the component:", + LANGUAGE_OPTIONS.to_vec(), + ) + .prompt()?; + + let component_path = Path::new(&component_name); + if component_path.exists() { + anyhow::bail!("A component with this name already exists in this directory"); + } + + let url = format!( + "{}{}", + component_language.repo_url, "/archive/refs/heads/main.zip" + ); + + println!("Downloading sample code for {}...", component_language.name); + let response = Client::new().get(url).send().await?.bytes().await?; + let reader = Cursor::new(response); + let mut archive = ZipArchive::new(reader)?; + + println!("Extracting code..."); + for i in 0..archive.len() { + let mut file = archive.by_index(i)?; + let path = file.name(); + + // Skip the first-level folder and extract only its contents + let parts: Vec<&str> = path.splitn(2, '/').collect(); + if parts.len() < 2 { + continue; // Ignore root-level files or folders + } + + let output_path = component_path.join(parts[1]); + if file.is_dir() { + create_dir_all(&output_path)?; + } else { + if let Some(parent) = output_path.parent() { + create_dir_all(parent)?; + } + let mut outfile = File::create(&output_path)?; + let mut buffer = Vec::new(); + file.read_to_end(&mut buffer)?; + outfile.write_all(&buffer)?; + } + } + println!("New project {} setup, check README to install the correct dependencies", component_name); + Ok(()) +} diff --git a/crates/cli/src/commands/components/pull.rs b/crates/cli/src/commands/components/pull.rs new file mode 100644 index 0000000..f758265 --- /dev/null +++ b/crates/cli/src/commands/components/pull.rs @@ -0,0 +1,6 @@ +#[derive(Debug, clap::Parser)] +pub struct Options {} + +pub async fn run(_opts: Options) -> anyhow::Result<()> { + Ok(()) +} diff --git a/crates/cli/src/commands/components/push.rs b/crates/cli/src/commands/components/push.rs new file mode 100644 index 0000000..4f0ab8a --- /dev/null +++ b/crates/cli/src/commands/components/push.rs @@ -0,0 +1,156 @@ +use crate::components::manifest::Manifest; +use edgee_api_client::types as api_types; + +#[derive(Debug, clap::Parser)] +pub struct Options { + /// The organization name used to create or update your component + /// + /// Defaults to the user "self" org + pub organization: Option, +} + +pub async fn run(opts: Options) -> anyhow::Result<()> { + use inquire::{Confirm, Editor}; + + use edgee_api_client::{auth::Credentials, ResultExt}; + + use crate::components::manifest; + + let creds = Credentials::load()?; + creds.check_api_token()?; + + let Some(manifest_path) = manifest::find_manifest_path() else { + anyhow::bail!("Edgee Manifest not found. Please run `edgee component new` and start from a template or `edgee component init` to create a new empty manifest in this folder."); + }; + let manifest = Manifest::load(&manifest_path)?; + + let client = edgee_api_client::new().credentials(&creds).connect(); + + let organization = match opts.organization { + Some(ref organization) => client + .get_organization() + .id(organization) + .send() + .await + .api_with_context(|| format!("Could not get organization `{organization}`"))? + .into_inner(), + None => client + .get_my_organization() + .send() + .await + .api_context("Could not get user organization")? + .into_inner(), + }; + + match client + .get_component_by_slug() + .org_slug(&organization.slug) + .component_slug(&manifest.package.name) + .send() + .await + { + Err(edgee_api_client::Error::ErrorResponse(err)) + if err.error.type_ + == edgee_api_client::types::ErrorResponseErrorType::NotFoundError => + { + tracing::info!("Component does not exist, creating..."); + let confirm = Confirm::new(&format!( + "Component `{}/{}` does not exists, do you want to create it?", + organization.slug, manifest.package.name, + )) + .with_default(true) + .prompt()?; + if !confirm { + return Ok(()); + } + + client + .create_component() + .body( + api_types::ComponentCreateInput::builder() + .organization_id(organization.id.clone()) + .name(&manifest.package.name) + .description(manifest.package.description.clone()) + .category(manifest.package.category) + .subcategory(manifest.package.subcategory) + .documentation_link( + manifest + .package + .documentation + .as_ref() + .map(|url| url.to_string()), + ) + .repo_link( + manifest + .package + .repository + .as_ref() + .map(|url| url.to_string()), + ), + ) + .send() + .await + .api_context("Could not create component")?; + } + Ok(_) | Err(_) => {} + } + + let changelog = + Editor::new("Please describe the changes from the previous version").prompt_skippable()?; + + let confirm = Confirm::new(&format!( + "Please confirm to push the component `{}/{}`:", + organization.slug, manifest.package.name, + )) + .with_default(true) + .prompt()?; + if !confirm { + return Ok(()); + } + + tracing::info!("Uploading WASM file..."); + let asset_url = client + .upload_file(&manifest.package.build.output_path) + .await + .expect("Could not upload component"); + + tracing::info!("Creating component version..."); + client + .create_component_version_by_slug() + .org_slug(organization.slug) + .component_slug(&manifest.package.name) + .body( + api_types::ComponentVersionCreateInput::builder() + .version(&manifest.package.version) + .wit_world_version(&manifest.package.wit_world_version) + .wasm_url(asset_url) + .dynamic_fields(convert_manifest_config_fields(&manifest)) + .changelog(changelog), + ) + .send() + .await + .api_context("Could not create version")?; + + tracing::info!( + "{} {} pushed successfully!", + manifest.package.name, + manifest.package.version + ); + + Ok(()) +} + +fn convert_manifest_config_fields(manifest: &Manifest) -> Vec { + manifest + .package + .config_fields + .iter() + .map(|(name, field)| api_types::ConfigurationField { + name: name.clone(), + title: field.title.clone(), + type_: field.type_, + required: field.required, + description: field.description.clone(), + }) + .collect() +} diff --git a/crates/cli/src/commands/components/test.rs b/crates/cli/src/commands/components/test.rs new file mode 100644 index 0000000..1a9f54c --- /dev/null +++ b/crates/cli/src/commands/components/test.rs @@ -0,0 +1,173 @@ +use edgee_components_runtime::config::{ComponentsConfiguration, DataCollectionComponents}; +use edgee_components_runtime::context::ComponentsContext; +use std::collections::HashMap; + +use edgee_components_runtime::data_collection::payload::{Event, EventType}; + +#[derive(Debug, clap::Parser)] +pub struct Options { + /// Comma-separated key=value pairs for settings + #[arg(long="settings", value_parser = parse_settings)] + pub settings: Option>, + + /// The event type you want to test (valid values: page, track, or user) + #[arg(long = "event-type", value_parser = ["page", "track", "user"])] + pub event_type: Option, + + /// Whether to log the full input event in stdout or not (false by default) + #[arg(long = "display-input", default_value = "false")] + pub display_input: bool, +} + +fn parse_settings(settings_str: &str) -> Result, String> { + let mut settings_map = HashMap::new(); + + for setting in settings_str.split(',') { + let parts: Vec<&str> = setting.split('=').collect(); + if parts.len() == 2 { + settings_map.insert(parts[0].to_string(), parts[1].to_string()); + } else { + return Err(format!("Invalid setting: {}\nPlease use a comma-separated list of settings such as `-s 'key1=value,key2=value2'`", setting)); + } + } + + Ok(settings_map) +} + +async fn test_data_collection_component(component_path: &str, opts: Options) -> anyhow::Result<()> { + if !std::path::Path::new(component_path).exists() { + return Err(anyhow::anyhow!("Output path not found in manifest file.",)); + } + + let config = ComponentsConfiguration { + data_collection: vec![DataCollectionComponents { + id: component_path.to_string(), + file: component_path.to_string(), + ..Default::default() + }], + ..Default::default() + }; + + let context = ComponentsContext::new(&config) + .map_err(|_e| anyhow::anyhow!("Something went wrong when trying to load the Wasm file. Please re-build and try again."))?; + + let mut store = context.empty_store(); + + let instance = context + .get_data_collection_instance(component_path, &mut store) + .await?; + let component = instance.edgee_protocols_data_collection(); + + // events generated with demo.edgee.app + let page_event_json = r#"[{"uuid":"37009b9b-a572-4615-87c1-09e257331ecb","timestamp":"2025-02-03T15:46:39.283317613Z","type":"page","data":{"keywords":["demo","tag manager"],"title":"Page with Edgee components","url":"https://demo.edgee.app/analytics-with-edgee.html","path":"/analytics-with-edgee.html","referrer":"https://demo.edgee.dev/analytics-with-js.html"},"context":{"page":{"keywords":["demo","tag manager"],"title":"Page with Edgee components","url":"https://demo.edgee.app/analytics-with-edgee.html","path":"/analytics-with-edgee.html","referrer":"https://demo.edgee.dev/analytics-with-js.html"},"user":{"edgee_id":"6bb171d5-2284-41ee-9889-91af03b71dc5"},"client":{"ip":"127.0.0.1","locale":"en-us","accept_language":"en-US,en;q=0.9","timezone":"Europe/Paris","user_agent":"Mozilla/5.0 (X11; Linux x86_64)AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36","user_agent_version_list":"Not A(Brand;8|Chromium;132","user_agent_mobile":"0","os_name":"Linux","user_agent_architecture":"x86","user_agent_bitness":"64","user_agent_full_version_list":"Not A(Brand;8.0.0.0|Chromium;132.0.6834.159","user_agent_model":"","os_version":"6.12.11","screen_width":1920,"screen_height":1280,"screen_density":1.5},"session":{"session_id":"1738597536","session_count":1,"session_start":false,"first_seen":"2025-02-03T15:45:36.569004889Z","last_seen":"2025-02-03T15:46:39.278740029Z"}},"from":"edge"}]"#; + let track_event_json = r#" [{"uuid":"4cffe10b-b5fd-429e-96d2-471f0575005f","timestamp":"2025-02-03T16:06:32.809486270Z","type":"track","data":{"name":"button_click","properties":{"registered":false,"size":10,"color":"blue","category":"shoes","label":"Blue Sneakers"}},"context":{"page":{"keywords":["demo","tag manager"],"title":"Page with Edgee components","url":"https://demo.edgee.app/analytics-with-edgee.html","path":"/analytics-with-edgee.html","referrer":"https://demo.edgee.dev/"},"user":{"user_id":"123456","anonymous_id":"anon-123","edgee_id":"69659401-40cf-4ac8-8ffc-630a10fe06dc","properties":{"verified":true,"age":42,"email":"me@example.com","name":"John Doe"}},"client":{"ip":"127.0.0.1","locale":"en-us","accept_language":"en-US,en;q=0.5","timezone":"Europe/Paris","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:134.0) Gecko/20100101 Firefox/134.0","screen_width":1440,"screen_height":960,"screen_density":2.0},"session":{"session_id":"1738598699","session_count":7,"session_start":false,"first_seen":"2024-12-12T16:30:03.693248190Z","last_seen":"2025-02-03T16:06:32.808844970Z"}},"from":"client","consent":"granted"}]"#; + let user_event_json = r#"[{"uuid":"eb0f001a-cd2b-42c4-9c71-7b1c2bcda445","timestamp":"2025-02-03T16:07:04.878715197Z","type":"user","data":{"user_id":"123456","anonymous_id":"anon-123","edgee_id":"69659401-40cf-4ac8-8ffc-630a10fe06dc","properties":{"age":42,"verified":true,"name":"John Doe","email":"me@example.com"}},"context":{"page":{"keywords":["demo","tag manager"],"title":"Page with Edgee components","url":"https://demo.edgee.app/analytics-with-edgee.html","path":"/analytics-with-edgee.html","referrer":"https://demo.edgee.dev/"},"user":{"user_id":"123456","anonymous_id":"anon-123","edgee_id":"69659401-40cf-4ac8-8ffc-630a10fe06dc","properties":{"email":"me@example.com","age":42,"name":"John Doe","verified":true}},"client":{"ip":"127.0.0.1","locale":"en-us","accept_language":"en-US,en;q=0.5","timezone":"Europe/Paris","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:134.0) Gecko/20100101 Firefox/134.0","screen_width":1440,"screen_height":960,"screen_density":2.0},"session":{"session_id":"1738598699","session_count":7,"session_start":false,"first_seen":"2024-12-12T16:30:03.693248190Z","last_seen":"2025-02-03T16:07:04.878137016Z"}},"from":"client","consent":"granted"}]"#; + + // setting management + let mut settings_map = HashMap::new(); + + // insert user provided settings + if let Some(parsed_settings) = opts.settings { + for (key, value) in parsed_settings { + settings_map.insert(key, value); + } + } + // TODO generate settings from the missing settings in the component manifest + let settings = settings_map.clone().into_iter().collect(); + + // select events to run + let mut events = vec![]; + match opts.event_type { + None => { + events.push(serde_json::from_str::>(page_event_json).unwrap()[0].clone()); + events.push(serde_json::from_str::>(track_event_json).unwrap()[0].clone()); + events.push(serde_json::from_str::>(user_event_json).unwrap()[0].clone()); + } + Some(event_type) => match event_type.as_str() { + "page" => { + events + .push(serde_json::from_str::>(page_event_json).unwrap()[0].clone()); + } + "track" => { + events + .push(serde_json::from_str::>(track_event_json).unwrap()[0].clone()); + } + "user" => { + events + .push(serde_json::from_str::>(user_event_json).unwrap()[0].clone()); + } + _ => { + return Err(anyhow::anyhow!("Invalid event type")); + } + }, + } + + if opts.display_input { + println!("Settings: {:#?}", settings_map); + } + for event in events { + println!("---------------------------------------------------"); + let request = match event.event_type { + EventType::Page => { + println!("Calling page"); + component + .call_page(&mut store, &event.clone().into(), &settings) + .await + } + EventType::Track => { + println!("Calling track"); + component + .call_track(&mut store, &event.clone().into(), &settings) + .await + } + EventType::User => { + println!("Calling user"); + component + .call_user(&mut store, &event.clone().into(), &settings) + .await + } + }; + + let request = match request { + Ok(Ok(request)) => request, + Err(e) => return Err(anyhow::anyhow!("Failed to call component: {}", e)), + _ => unreachable!(), + }; + + if opts.display_input { + println!("Input Event: {}", serde_json::to_string_pretty(&event)?); + } + + println!("EdgeeRequest object:"); + println!("Method: {:#?}", request.method); + println!("URL: {:#?}", request.url); + println!("Headers: {:#?}", request.headers); + if let Ok(pretty_json) = serde_json::from_str::(&request.body) { + println!("Body: {}", serde_json::to_string_pretty(&pretty_json)?); + } else { + println!("Body: {:#?}", request.body); + } + } + + Ok(()) +} +pub async fn run(opts: Options) -> anyhow::Result<()> { + use crate::components::manifest::{self, Manifest}; + + let manifest_path = + manifest::find_manifest_path().ok_or_else(|| anyhow::anyhow!("Manifest not found"))?; + + let manifest = Manifest::load(&manifest_path)?; + let component_path = manifest + .package + .build + .output_path + .into_os_string() + .into_string() + .map_err(|_| anyhow::anyhow!("Invalid path"))?; + + // TODO: dont assume that it is a data collection component, add type in manifest + test_data_collection_component(&component_path, opts).await?; + + Ok(()) +} diff --git a/crates/cli/src/commands/macros.rs b/crates/cli/src/commands/macros.rs index 25b63b2..f19bc55 100644 --- a/crates/cli/src/commands/macros.rs +++ b/crates/cli/src/commands/macros.rs @@ -1,8 +1,11 @@ macro_rules! setup_commands { { + $( + #![run($($arg_name:ident: $arg_ty:ty),*$(,)?)] + )? $( $(#[$variant_meta:meta])* - $variant_name:ident($mod_name:ident) + $variant_name:ident($mod_name:ident $(, $($pass_arg_name:ident),*$(,)?)?) ),*$(,)? } => { $(mod $mod_name;)* @@ -16,9 +19,9 @@ macro_rules! setup_commands { } impl Command { - pub async fn run(self) { + pub async fn run(self, $($($arg_name: $arg_ty),*)?) -> anyhow::Result<()> { match self { - $(Self::$variant_name(opts) => $mod_name::run(opts).await),* + $(Self::$variant_name(opts) => $mod_name::run(opts, $($($pass_arg_name),*)?).await),* } } } diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index e7719d7..ab2eaa5 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -4,6 +4,9 @@ mod macros; setup_commands! { #[command(flatten)] Auth(auth), + /// Components management commands + #[command(subcommand, visible_alias = "component")] + Components(components), /// Run the Edgee server #[command(visible_alias = "server")] Serve(serve), diff --git a/crates/cli/src/commands/serve.rs b/crates/cli/src/commands/serve.rs index 4a717f8..f2805cf 100644 --- a/crates/cli/src/commands/serve.rs +++ b/crates/cli/src/commands/serve.rs @@ -1,10 +1,36 @@ -setup_command! {} +use std::path::PathBuf; -pub async fn run(_opts: Options) { - edgee_server::init().unwrap(); +use crate::logger; + +setup_command! { + #[arg(long, env = "EDGEE_LOG_FORMAT", value_enum, default_value_t)] + log_format: logger::LogFormat, + + #[arg(short, long = "config", env = "EDGEE_CONFIG_PATH")] + config_path: Option, + + /// Log only the specified component's requests and responses to debug. + #[arg(short, long, id = "COMPONENT_NAME")] + trace_component: Option, +} + +pub async fn run(opts: Options) -> anyhow::Result<()> { + use crate::config; + + config::init(opts.config_path.as_deref(), opts.trace_component.as_deref()); + // if trace_component is set, we only want to log the specified component. We change the options.log_format to do it. + let mut log_filter = None; + if opts.trace_component.is_some() { + // We disable all logs because component will print things to stdout directly + log_filter = Some("off".to_string()); + } + + logger::init(opts.log_format, log_filter); + + edgee_server::init()?; tokio::select! { - Err(err) = edgee_server::monitor::start() => tracing::error!(?err, "Monitor failed"), - Err(err) = edgee_server::start() => tracing::error!(?err, "Server failed to start"), + Err(err) = edgee_server::monitor::start() => Err(err), + Err(err) = edgee_server::start() => Err(err), } } diff --git a/crates/cli/src/components/boilerplate.rs b/crates/cli/src/components/boilerplate.rs new file mode 100644 index 0000000..085431c --- /dev/null +++ b/crates/cli/src/components/boilerplate.rs @@ -0,0 +1,94 @@ +#[derive(Clone)] +pub struct LanguageConfig { + pub name: &'static str, + pub repo_url: &'static str, + pub default_build_command: &'static str, +} + +impl std::fmt::Display for LanguageConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name) + } +} + +#[derive(Clone)] +pub struct CategoryConfig { + pub name: &'static str, + pub value: &'static edgee_api_client::types::ComponentCreateInputCategory, +} + +impl std::fmt::Display for CategoryConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name) + } +} + +#[derive(Clone)] +pub struct SubCategoryConfig { + pub name: &'static str, + pub value: &'static edgee_api_client::types::ComponentCreateInputSubcategory, +} + +impl std::fmt::Display for SubCategoryConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name) + } +} + +pub static LANGUAGE_OPTIONS: &[LanguageConfig] = &[ + LanguageConfig { + name: "C", + repo_url: "https://github.com/edgee-cloud/example-c-component", + default_build_command: "gcc main.c -o main", + }, + LanguageConfig { + name: "CSharp", + repo_url: "https://github.com/edgee-cloud/example-csharp-component", + default_build_command: "dotnet build", + }, + LanguageConfig { + name: "Go", + repo_url: "https://github.com/edgee-cloud/example-go-component", + default_build_command: "go build -o main .", + }, + LanguageConfig { + name: "JavaScript", + repo_url: "https://github.com/edgee-cloud/example-js-component", + default_build_command: "node main.js", + }, + LanguageConfig { + name: "Python", + repo_url: "https://github.com/edgee-cloud/example-py-component", + default_build_command: "python main.py", + }, + LanguageConfig { + name: "Rust", + repo_url: "https://github.com/edgee-cloud/example-rust-component", + default_build_command: "cargo build --release", + }, + LanguageConfig { + name: "TypeScript", + repo_url: "https://github.com/edgee-cloud/example-ts-component", + default_build_command: "npx tsc", + }, +]; + +pub static CATEGORY_OPTIONS: &[CategoryConfig] = &[CategoryConfig { + name: "Data Collection", + value: &edgee_api_client::types::ComponentCreateInputCategory::DataCollection, +}]; + +pub static SUBCATEGORY_OPTIONS: &[SubCategoryConfig] = &[ + SubCategoryConfig { + name: "Analytics", + value: &edgee_api_client::types::ComponentCreateInputSubcategory::Analytics, + }, + SubCategoryConfig { + name: "Attribution", + value: &edgee_api_client::types::ComponentCreateInputSubcategory::Attribution, + }, + SubCategoryConfig { + name: "Warehouse", + value: &edgee_api_client::types::ComponentCreateInputSubcategory::Warehouse, + }, +]; diff --git a/crates/cli/src/components/manifest.rs b/crates/cli/src/components/manifest.rs new file mode 100644 index 0000000..f7c7199 --- /dev/null +++ b/crates/cli/src/components/manifest.rs @@ -0,0 +1,136 @@ +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use edgee_api_client::types as api_types; + +pub const MANIFEST_VERSION: u8 = 1; +pub const MANIFEST_FILENAME: &str = "edgee-component.toml"; + +#[derive(Debug, Deserialize, Serialize)] +pub struct Manifest { + pub manifest_version: u8, + pub package: Package, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct Package { + pub name: String, + pub version: String, + #[serde(with = "Category")] + pub category: api_types::ComponentCreateInputCategory, + #[serde(with = "SubCategory")] + pub subcategory: api_types::ComponentCreateInputSubcategory, + pub description: Option, + + #[serde(default)] + pub documentation: Option, + #[serde(default)] + pub repository: Option, + + pub wit_world_version: String, + + #[serde(default)] + pub config_fields: HashMap, + + pub build: Build, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] +#[serde( + remote = "api_types::ComponentCreateInputCategory", + rename_all = "kebab-case" +)] +pub enum Category { + DataCollection, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] +#[serde( + remote = "api_types::ComponentCreateInputSubcategory", + rename_all = "kebab-case" +)] +pub enum SubCategory { + Analytics, + Warehouse, + Attribution, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ConfigField { + pub title: String, + #[serde(rename = "type", with = "ConfigFieldType")] + pub type_: api_types::ConfigurationFieldType, + #[serde(default)] + pub required: bool, + pub description: Option, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] +#[serde( + remote = "api_types::ConfigurationFieldType", + rename_all = "kebab-case" +)] +pub enum ConfigFieldType { + String, + Boolean, + Number, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Build { + pub command: String, + pub output_path: PathBuf, +} + +impl Manifest { + pub fn load(path: &Path) -> Result { + use std::fs; + + let content = fs::read_to_string(path) + .with_context(|| format!("Could not read manifest file at {}", path.display()))?; + + let manifest: Self = toml::from_str(&content) + .with_context(|| format!("Could not decode the manifest file at {}", path.display()))?; + + if manifest.manifest_version != MANIFEST_VERSION { + anyhow::bail!( + "Invalid manifest version {}, the supported one is {}", + manifest.manifest_version, + MANIFEST_VERSION + ); + } + + Ok(manifest) + } + + pub fn save(&self, path: &Path) -> Result<()> { + use std::fs; + + let content = toml::to_string(self)?; + + fs::write(path.join(MANIFEST_FILENAME), content) + .with_context(|| format!("Could not write manifest file at {}", path.display()))?; + Ok(()) + } +} + +pub fn find_manifest_path() -> Option { + let mut cwd = std::env::current_dir().ok(); + + while let Some(cur) = cwd { + let path = cur.join(MANIFEST_FILENAME); + if path.exists() { + return Some(path); + } + + cwd = cur.parent().map(ToOwned::to_owned); + } + + None +} diff --git a/crates/cli/src/components/mod.rs b/crates/cli/src/components/mod.rs new file mode 100644 index 0000000..411bd23 --- /dev/null +++ b/crates/cli/src/components/mod.rs @@ -0,0 +1,2 @@ +pub mod boilerplate; +pub mod manifest; diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 91caf59..98b5d5a 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -1,7 +1,5 @@ use std::path::Path; -use crate::Options; - use edgee_server::config::StaticConfiguration; fn read_config(path: Option<&Path>) -> Result { @@ -49,12 +47,11 @@ fn read_config(path: Option<&Path>) -> Result { } } -pub fn init(options: &Options) { - let path = options.config_path.as_deref(); - let mut config = read_config(path).expect("should read config file"); +pub fn init(config_path: Option<&Path>, trace_component: Option<&str>) { + let mut config = read_config(config_path).expect("should read config file"); config.validate().unwrap(); - if let Some(component) = options.trace_component.as_deref() { + if let Some(component) = trace_component { config.log.trace_component = Some(component.to_string()); } diff --git a/crates/cli/src/logger.rs b/crates/cli/src/logger.rs index 0b14088..1743805 100644 --- a/crates/cli/src/logger.rs +++ b/crates/cli/src/logger.rs @@ -59,3 +59,29 @@ pub fn init(log_format: LogFormat, log_filter: Option) { .with(fmt_layer) .init(); } + +pub fn init_cli() { + use std::env; + + use tracing::Level; + use tracing_subscriber::prelude::*; + use tracing_subscriber::{fmt, EnvFilter}; + + let fmt_layer = fmt::layer().with_target(false).without_time(); + + let filter_layer = { + let directives = env::var("EDGEE_LOG") + .ok() + .or_else(|| env::var("RUST_LOG").ok()) + .unwrap_or_default(); + + EnvFilter::builder() + .with_default_directive(Level::ERROR.into()) + .parse_lossy(directives) + }; + + tracing_subscriber::registry() + .with(filter_layer) + .with(fmt_layer) + .init(); +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 312f648..14cea66 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,41 +1,19 @@ -use std::path::PathBuf; - use clap::Parser; mod commands; +mod components; mod config; mod logger; #[derive(Debug, Parser)] #[command(about, author, version)] struct Options { - #[arg(long, env = "EDGEE_LOG_FORMAT", value_enum, default_value_t)] - log_format: logger::LogFormat, - - #[arg(short, long = "config", env = "EDGEE_CONFIG_PATH")] - config_path: Option, - - /// Log only the specified component's requests and responses to debug. - #[arg(short, long, id = "COMPONENT_NAME")] - trace_component: Option, - #[command(subcommand)] command: commands::Command, } #[tokio::main] -async fn main() { +async fn main() -> anyhow::Result<()> { let options = Options::parse(); - - config::init(&options); - // if trace_component is set, we only want to log the specified component. We change the options.log_format to do it. - let mut log_filter = None; - if options.trace_component.is_some() { - // We disable all logs because component will print things to stdout directly - log_filter = Some("off".to_string()); - } - - logger::init(options.log_format, log_filter); - options.command.run().await }