diff --git a/.github/workflows/sdk-build-validation.yml b/.github/workflows/sdk-build-validation.yml index ff7e3612c..eea7ce92a 100644 --- a/.github/workflows/sdk-build-validation.yml +++ b/.github/workflows/sdk-build-validation.yml @@ -58,6 +58,9 @@ jobs: - sdk: kotlin platform: server + - sdk: rust + platform: server + # Console SDKs - sdk: cli platform: console @@ -140,6 +143,10 @@ jobs: with: dotnet-version: '8.0.x' + - name: Setup Rust + if: matrix.sdk == 'rust' + uses: dtolnay/rust-toolchain@1.83.0 + - name: Build SDK working-directory: examples/${{ matrix.sdk }} run: | @@ -193,6 +200,10 @@ jobs: dotnet) dotnet build ;; + rust) + cargo build --release + cargo test --lib + ;; *) echo "Unknown SDK: ${{ matrix.sdk }}" exit 1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 47c2f894a..695070091 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -43,6 +43,7 @@ jobs: Ruby27, Ruby30, Ruby31, + Rust183, AppleSwift56, Swift56, WebChromium, diff --git a/.gitignore b/.gitignore index 6fa0abd25..cf9252bc7 100755 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ templates/swift/example/Example.xcodeproj/xcuserdata **/xcuserdata # exclude go checksum files go.sum + +demos \ No newline at end of file diff --git a/example.php b/example.php index 1950a3657..71b96c8f1 100644 --- a/example.php +++ b/example.php @@ -21,6 +21,7 @@ use Appwrite\SDK\Language\Android; use Appwrite\SDK\Language\Kotlin; use Appwrite\SDK\Language\ReactNative; +use Appwrite\SDK\Language\Rust; try { @@ -266,6 +267,13 @@ function configureSDK($sdk, $overrides = []) { configureSDK($sdk); $sdk->generate(__DIR__ . '/examples/graphql'); } + + // Rust + if (!$requestedSdk || $requestedSdk === 'rust') { + $sdk = new SDK(new Rust(), new Swagger2($spec)); + configureSDK($sdk); + $sdk->generate(__DIR__ . '/examples/rust'); + } } catch (Exception $exception) { echo 'Error: ' . $exception->getMessage() . ' on ' . $exception->getFile() . ':' . $exception->getLine() . "\n"; diff --git a/pyproject.toml b/pyproject.toml index 4bdd9ccea..b39e06e85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,8 +3,10 @@ profile="jinja" extension="twig" -ignore="H012,H025,H030,H031,T001,T002,T027,T032" +ignore="H009,H012,H023,H025,H030,H031,T001,T002,T027,T032" +# H009: lowercase tag names - false positive for Rust generics like > # H012: spaces around = - false positive for TypeScript generics like +# H023: entity references - false positive for Rust references like &Client, &str # H025: orphan tags - false positive for code generics like , , # H030/H031: meta description/keywords (not applicable to SDK code templates) # T001: whitespace in tags - {%~ is intentional Twig whitespace control diff --git a/src/SDK/Language/Rust.php b/src/SDK/Language/Rust.php new file mode 100644 index 000000000..b44b419b7 --- /dev/null +++ b/src/SDK/Language/Rust.php @@ -0,0 +1,723 @@ + "packageName", + ]; + + /** + * @param string $name + * @return $this + */ + public function setCratePackage(string $name): self + { + $this->setParam("cratePackage", $name); + + return $this; + } + + /** + * @return string + */ + public function getName(): string + { + return "Rust"; + } + + /** + * Get Language Keywords List + * + * @return array + */ + public function getKeywords(): array + { + return [ + "abstract", + "alignof", + "as", + "become", + "box", + "break", + "const", + "continue", + "crate", + "do", + "else", + "enum", + "extern", + "false", + "final", + "fn", + "for", + "if", + "impl", + "in", + "let", + "loop", + "macro", + "match", + "mod", + "move", + "mut", + "offsetof", + "override", + "priv", + "proc", + "pub", + "pure", + "ref", + "return", + "self", + "sizeof", + "static", + "struct", + "super", + "trait", + "true", + "type", + "typeof", + "unsafe", + "unsized", + "use", + "virtual", + "where", + "while", + "yield", + "async", + "await", + "dyn", + "union", + "gen", + "try", + "Self", + ]; + } + + /** + * @return array + */ + public function getIdentifierOverrides(): array + { + return [ + "type" => "r#type", + "ref" => "r#ref", + "move" => "r#move", + "static" => "r#static", + "const" => "r#const", + "struct" => "r#struct", + "enum" => "r#enum", + "trait" => "r#trait", + "impl" => "r#impl", + "fn" => "r#fn", + "let" => "r#let", + "mut" => "r#mut", + "use" => "r#use", + "pub" => "r#pub", + "crate" => "r#crate", + "mod" => "r#mod", + "super" => "r#super", + "self" => "r#self", + "where" => "r#where", + "async" => "r#async", + "gen" => "r#gen", + "try" => "r#try", + "Self" => "r#Self", + "await" => "r#await", + "loop" => "r#loop", + "while" => "r#while", + "for" => "r#for", + "if" => "r#if", + "else" => "r#else", + "match" => "r#match", + "return" => "r#return", + "break" => "r#break", + "continue" => "r#continue", + ]; + } + + /** + * @return array + */ + public function getFiles(): array + { + return [ + [ + "scope" => "default", + "destination" => "Cargo.toml", + "template" => "rust/Cargo.toml.twig", + ], + [ + "scope" => "default", + "destination" => "README.md", + "template" => "rust/README.md.twig", + ], + [ + "scope" => "default", + "destination" => "CHANGELOG.md", + "template" => "rust/CHANGELOG.md.twig", + ], + [ + "scope" => "default", + "destination" => "LICENSE", + "template" => "rust/LICENSE.twig", + ], + [ + "scope" => "copy", + "destination" => ".gitignore", + "template" => "rust/.gitignore", + ], + [ + "scope" => "default", + "destination" => "src/lib.rs", + "template" => "rust/src/lib.rs.twig", + ], + [ + "scope" => "default", + "destination" => "src/client.rs", + "template" => "rust/src/client.rs.twig", + ], + [ + "scope" => "default", + "destination" => "src/error.rs", + "template" => "rust/src/error.rs.twig", + ], + [ + "scope" => "default", + "destination" => "src/input_file.rs", + "template" => "rust/src/input_file.rs.twig", + ], + [ + "scope" => "default", + "destination" => "src/query.rs", + "template" => "rust/src/query.rs.twig", + ], + [ + "scope" => "default", + "destination" => "src/permission.rs", + "template" => "rust/src/permission.rs.twig", + ], + [ + "scope" => "default", + "destination" => "src/role.rs", + "template" => "rust/src/role.rs.twig", + ], + [ + "scope" => "default", + "destination" => "src/id.rs", + "template" => "rust/src/id.rs.twig", + ], + [ + "scope" => "default", + "destination" => "src/operator.rs", + "template" => "rust/src/operator.rs.twig", + ], + [ + "scope" => "default", + "destination" => "src/models/mod.rs", + "template" => "rust/src/models/mod.rs.twig", + ], + [ + "scope" => "default", + "destination" => "src/services/mod.rs", + "template" => "rust/src/services/mod.rs.twig", + ], + [ + "scope" => "service", + "destination" => "src/services/{{ service.name | caseSnake }}.rs", + "template" => "rust/src/services/service.rs.twig", + ], + + [ + "scope" => "definition", + "destination" => "src/models/{{ definition.name | caseSnake }}.rs", + "template" => "rust/src/models/model.rs.twig", + ], + [ + "scope" => "requestModel", + "destination" => "src/models/{{ requestModel.name | caseSnake }}.rs", + "template" => "rust/src/models/request_model.rs.twig", + ], + [ + "scope" => "default", + "destination" => "src/enums/mod.rs", + "template" => "rust/src/enums/mod.rs.twig", + ], + [ + "scope" => "enum", + "destination" => "src/enums/{{ enum.name | caseSnake }}.rs", + "template" => "rust/src/enums/enum.rs.twig", + ], + [ + "scope" => "copy", + "destination" => "tests/tests.rs", + "template" => "rust/tests/tests.rs", + ], + [ + "scope" => "default", + "destination" => ".github/workflows/publish.yml", + "template" => "rust/.github/workflows/publish.yml.twig", + ], + [ + "scope" => "copy", + "destination" => ".github/workflows/stale.yml", + "template" => "rust/.github/workflows/stale.yml", + ], + [ + "scope" => "copy", + "destination" => ".github/workflows/autoclose.yml", + "template" => "rust/.github/workflows/autoclose.yml", + ], + ]; + } + + /** + * @param array $parameter + * @param array $spec + * @return string + */ + public function getTypeName(array $parameter, array $spec = []): string + { + // Handle enum types + if (isset($parameter["enumName"])) { + return "crate::enums::" . ucfirst($parameter["enumName"]); + } + if (!empty($parameter["enumValues"])) { + return "crate::enums::" . ucfirst($parameter["name"]); + } + + if ( + isset($parameter["type"]) && $parameter["type"] === "array" && + isset($parameter["items"]["type"]) && $parameter["items"]["type"] === "object" && + !isset($parameter["items"]["model"]) && + !isset($parameter["items"]['$ref']) + ) { + return "Vec"; + } + if (isset($parameter["items"])) { + // Map definition nested type to parameter nested type + $parameter["array"] = $parameter["items"]; + } + + return match ($parameter["type"]) { + self::TYPE_INTEGER => "i64", + self::TYPE_NUMBER => "f64", + self::TYPE_FILE => "InputFile", + self::TYPE_STRING => "String", + self::TYPE_BOOLEAN => "bool", + self::TYPE_OBJECT => "serde_json::Value", + self::TYPE_ARRAY => isset($parameter["array"]["model"]) + ? "Vec" + : (!empty(($parameter["array"] ?? [])["type"]) && !\is_array($parameter["array"]["type"]) + ? "Vec<" . $this->getTypeName($parameter["array"]) . ">" + : "Vec"), + default => isset($parameter["model"]) + ? "crate::models::" . ucfirst($parameter["model"]) + : $parameter["type"], + }; + } + + /** + * @return string + */ + public function getStaticAccessOperator(): string + { + return '::'; + } + + /** + * @return string + */ + public function getStringQuote(): string + { + return '"'; + } + + /** + * @param string $elements + * @return string + */ + public function getArrayOf(string $elements): string + { + return 'vec![' . $elements . ']'; + } + + /** + * @param array $param + * @return string + */ + public function getParamDefault(array $param): string + { + $type = $param["type"] ?? ""; + $default = $param["default"] ?? ""; + $required = $param["required"] ?? ""; + + if ($required) { + return ""; + } + + $output = " = "; + + if (empty($default) && $default !== 0 && $default !== false) { + switch ($type) { + case self::TYPE_NUMBER: + case self::TYPE_INTEGER: + $output .= "0"; + break; + case self::TYPE_BOOLEAN: + $output .= "false"; + break; + case self::TYPE_STRING: + $output .= "String::new()"; + break; + case self::TYPE_OBJECT: + $output .= "serde_json::Value::Null"; + break; + case self::TYPE_ARRAY: + $output .= "Vec::new()"; + break; + case self::TYPE_FILE: + $output .= "InputFile::default()"; + break; + } + } else { + switch ($type) { + case self::TYPE_NUMBER: + case self::TYPE_INTEGER: + $output .= $default; + break; + case self::TYPE_BOOLEAN: + $output .= $default ? "true" : "false"; + break; + case self::TYPE_STRING: + $output .= "String::from(\"$default\")"; + break; + case self::TYPE_OBJECT: + $output .= "serde_json::Value::Null"; + break; + case self::TYPE_ARRAY: + $output .= "Vec::new()"; + break; + case self::TYPE_FILE: + $output .= "InputFile::default()"; + break; + } + } + + return $output; + } + + /** + * @param array $param + * @param string $lang + * @return string + */ + public function getParamExample(array $param, string $lang = ''): string + { + $type = $param["type"] ?? ""; + $example = $param["example"] ?? ""; + + $output = ""; + + if (empty($example) && $example !== 0 && $example !== false) { + switch ($type) { + case self::TYPE_NUMBER: + case self::TYPE_INTEGER: + $output .= "0"; + break; + case self::TYPE_BOOLEAN: + $output .= "false"; + break; + case self::TYPE_STRING: + $output .= '""'; + break; + case self::TYPE_OBJECT: + $output .= "serde_json::json!({})"; + break; + case self::TYPE_ARRAY: + $output .= "vec![]"; + break; + case self::TYPE_FILE: + $output .= 'InputFile::from_path("file.png")'; + break; + } + } else { + switch ($type) { + case self::TYPE_NUMBER: + case self::TYPE_INTEGER: + $output .= $example; + break; + case self::TYPE_BOOLEAN: + $output .= $example ? "true" : "false"; + break; + case self::TYPE_STRING: + $output .= "\"{$example}\""; + break; + case self::TYPE_OBJECT: + $output .= "serde_json::json!({})"; + break; + case self::TYPE_ARRAY: + if (\str_starts_with($example, "[")) { + $example = \substr($example, 1); + } + if (\str_ends_with($example, "]")) { + $example = \substr($example, 0, -1); + } + $output .= "vec![" . $example . "]"; + break; + case self::TYPE_FILE: + $output .= 'InputFile::from_path("file.png")'; + break; + } + } + + return $output; + } + + /** + * @return array + */ + public function getFilters(): array + { + return [ + new TwigFilter( + "rustdocComment", + function ($value, $indent = 0) { + $value = trim($value); + $value = explode("\n", $value); + $indent = \str_repeat(" ", $indent); + foreach ($value as $key => $line) { + $value[$key] = "/// " . wordwrap(trim($line), 75, "\n" . $indent . "/// "); + } + return implode("\n" . $indent, $value); + }, + ["is_safe" => ["html"]], + ), + new TwigFilter("propertyType", function ( + array $property, + array $spec = [], + string $generic = "serde_json::Value", + ) { + return $this->getPropertyType($property, $spec, $generic); + }), + new TwigFilter("returnType", function ( + array $method, + array $spec, + string $namespace, + string $generic = "serde_json::Value", + ) { + return $this->getReturnType($method, $spec, $namespace, $generic); + }), + new TwigFilter("caseEnumKey", function (string $value) { + return $this->toPascalCase($value); + }), + new TwigFilter("inputType", function ( + array $property, + array $spec = [], + string $generic = "serde_json::Value", + ) { + return $this->getInputType($property, $spec, $generic); + }), + new TwigFilter("paramValue", function ( + array $property, + string $paramName, + array $spec = [], + ) { + return $this->getParamValue($property, $paramName, $spec); + }, ["is_safe" => ["html"]]), + new TwigFilter("rustType", function ($value) { + return str_replace(['<', '>'], ['<', '>'], $value); + }, ["is_safe" => ["html"]]), + new TwigFilter("rustCrateName", function ($value) { + return str_replace('-', '_', $value); + }), + new TwigFilter("stripProtocol", function ($value) { + return str_replace(['https://', 'http://'], '', $value); + }), + ]; + } + + /** + * @param array $property + * @param array $spec + * @param string $generic + * @return string + */ + protected function getPropertyType(array $property, array $spec, string $generic = "serde_json::Value"): string + { + if (\array_key_exists("sub_schema", $property)) { + $type = "crate::models::" . ucfirst($property["sub_schema"]); + + if ($property["type"] === "array") { + $type = "Vec<" . $type . ">"; + } + } else { + $type = $this->getTypeName($property, $spec); + } + + return $type; + } + + /** + * Get input type for method parameters (uses impl Into for better DX) + * + * @param array $property + * @param array $spec + * @param string $generic + * @return string + */ + protected function getInputType(array $property, array $spec, string $generic = "serde_json::Value"): string + { + $baseType = $this->getPropertyType($property, $spec, $generic); + + // For String types, accept impl Into for better DX + if ($baseType === "String") { + return "impl Into"; + } + + // For Vec, accept impl IntoIterator for better DX (accepts slices, vecs, arrays) + if ($baseType === "Vec") { + return "impl IntoIterator>"; + } + + // For Vec, keep as-is (enums don't benefit from Into) + if (preg_match('/^Vec, keep as-is (models don't benefit from Into) + if (preg_match('/^Vec$/', $baseType)) { + return $baseType; + } + + // Default: return base type as-is + return $baseType; + } + + /** + * Get parameter value conversion expression for method body + * + * @param array $property + * @param string $paramName + * @param array $spec + * @return string + */ + protected function getParamValue(array $property, string $paramName, array $spec): string + { + $baseType = $this->getPropertyType($property, $spec); + + // For String types with impl Into, call .into() + if ($baseType === "String") { + return $paramName . ".into()"; + } + + // For Vec with impl IntoIterator, map and collect + if ($baseType === "Vec") { + return $paramName . ".into_iter().map(|s| s.into()).collect::>()"; + } + + // For other types, use as-is + return $paramName; + } + + /** + * @param array $method + * @param array $spec + * @param string $namespace + * @param string $generic + * @return string + */ + protected function getReturnType( + array $method, + array $spec, + string $namespace, + string $generic = "serde_json::Value", + ): string { + if ($method["type"] === "webAuth") { + return "crate::error::Result"; + } + if ($method["type"] === "location") { + return "crate::error::Result>"; + } + + $isEmpty = empty($method["produces"]) || (isset($method["responses"]) && $this->isEmptyResponse($method["responses"])); + + if ( + !\array_key_exists("responseModel", $method) || + empty($method["responseModel"]) || + $method["responseModel"] === "any" + ) { + if ($isEmpty) { + return "crate::error::Result<()>"; + } + return "crate::error::Result"; + } + + $ret = ucfirst($method["responseModel"]); + + return "crate::error::Result"; + } + + protected function isEmptyResponse(array $responses): bool + { + foreach ($responses as $code => $response) { + if (!in_array((int)$code, [204, 205])) { + return false; + } + } + return !empty($responses); + } + + /** + * @param string|null $model + * @param array $spec + * @return bool + */ + protected function hasGenericType(?string $model, array $spec): bool + { + if (empty($model) || $model === "any") { + return false; + } + + $model = $spec["definitions"][$model]; + + if ($model["additionalProperties"]) { + return true; + } + + foreach ($model["properties"] as $property) { + if (!\array_key_exists("sub_schema", $property) || !$property["sub_schema"]) { + continue; + } + + return $this->hasGenericType($property["sub_schema"], $spec); + } + + return false; + } +} diff --git a/templates/rust/.github/workflows/autoclose.yml b/templates/rust/.github/workflows/autoclose.yml new file mode 100644 index 000000000..d9629e166 --- /dev/null +++ b/templates/rust/.github/workflows/autoclose.yml @@ -0,0 +1,12 @@ +name: Auto-close External Pull Requests + +on: + pull_request_target: + types: [opened, reopened] + +jobs: + auto_close: + if: github.head_ref != 'dev' + uses: appwrite/.github/.github/workflows/autoclose.yml@main + secrets: + GH_AUTO_CLOSE_PR_TOKEN: ${{ secrets.GH_AUTO_CLOSE_PR_TOKEN }} diff --git a/templates/rust/.github/workflows/publish.yml.twig b/templates/rust/.github/workflows/publish.yml.twig new file mode 100644 index 000000000..259ae4951 --- /dev/null +++ b/templates/rust/.github/workflows/publish.yml.twig @@ -0,0 +1,23 @@ +name: Publish to Crates.io + +on: + release: + types: [published] + workflow_dispatch: + +jobs: + publish: + name: Build and publish to crates.io + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Run tests + run: cargo test --all-features + + - name: Publish to crates.io + run: cargo publish --token {{ '${{ secrets.CARGO_REGISTRY_TOKEN }}' }} diff --git a/templates/rust/.github/workflows/stale.yml b/templates/rust/.github/workflows/stale.yml new file mode 100644 index 000000000..5888b6156 --- /dev/null +++ b/templates/rust/.github/workflows/stale.yml @@ -0,0 +1,9 @@ +name: Mark stale issues + +on: + schedule: + - cron: "0 0 * * *" # Midnight Runtime + +jobs: + stale: + uses: appwrite/.github/.github/workflows/stale.yml@main diff --git a/templates/rust/.gitignore b/templates/rust/.gitignore new file mode 100644 index 000000000..7b0cbbe3e --- /dev/null +++ b/templates/rust/.gitignore @@ -0,0 +1,24 @@ +# Rust build artifacts +/target + +# Cargo.lock is included for applications, excluded for libraries +# Since this is a library SDK, we exclude it +Cargo.lock + +# Backup files from rustfmt +**/*.rs.bk + +# IDE files +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Test coverage +*.profraw +*.profdata diff --git a/templates/rust/CHANGELOG.md.twig b/templates/rust/CHANGELOG.md.twig new file mode 100644 index 000000000..b658e5e56 --- /dev/null +++ b/templates/rust/CHANGELOG.md.twig @@ -0,0 +1,58 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [{{ sdk.version }}] - TBD + +### Added +- Initial release of {{ spec.title }} Rust SDK +- Full support for {{ spec.title }} API {{ spec.version }} +- Async/await support with tokio runtime +- Built-in error handling with custom error types +- File upload support with automatic chunking +- Query builder for database operations +- Permission and role management utilities +- ID generation utilities +- Comprehensive documentation and examples +- Support for self-signed certificates +- Custom header support +- Automatic JSON serialization/deserialization + +### Features +{% for service in spec.services %} +- {{ service.name | caseUcfirst }} service with {{ service.methods | length }} methods +{% endfor %} + +### Services +{% for service in spec.services %} +#### {{ service.name | caseUcfirst }} +{{ service.description }} +{% for method in service.methods %} +- `{{ method.name | caseSnake }}()` - {{ method.description }} +{% endfor %} + +{% endfor %} + +### Models +{% for definition in spec.definitions %} +- `{{ definition.name | caseUcfirst }}` - {{ definition.description }} +{% endfor %} + +### Dependencies +- reqwest 0.12+ for HTTP client +- serde 1.0+ for JSON serialization +- tokio 1.0+ for async runtime +- fastrand 2.0+ for ID generation +- thiserror 1.0+ for error handling + +### Documentation +- Complete API documentation +- Usage examples for all methods +- Error handling guide +- File upload examples +- Query builder documentation + +[{{ sdk.version }}]: https://github.com/{{ sdk.gitUserName }}/sdk-for-rust/releases/tag/{{ sdk.version }} diff --git a/templates/rust/Cargo.toml.twig b/templates/rust/Cargo.toml.twig new file mode 100644 index 000000000..79556deed --- /dev/null +++ b/templates/rust/Cargo.toml.twig @@ -0,0 +1,48 @@ +[package] +name = "{{ sdk.cratePackage | default('appwrite') }}" +version = "{{ sdk.version }}" +edition = "2021" +rust-version = "1.83" +{% if sdk.gitUserEmail %}authors = ["{{ sdk.gitUserName }} <{{ sdk.gitUserEmail }}>"] +{% else %}authors = ["{{ sdk.gitUserName }}"] +{% endif %}description = "{{ spec.title }} SDK for Rust" +documentation = "https://docs.rs/{{ sdk.cratePackage | default('appwrite') }}" +repository = "https://github.com/{{ sdk.gitUserName }}/sdk-for-rust" +license = "BSD-3-Clause" +keywords = ["{{ spec.title | caseLower }}", "sdk", "api", "backend", "baas"] +categories = ["api-bindings", "web-programming"] +readme = "README.md" +autobins = false +autoexamples = false +autotests = false +autobenches = false + +[dependencies] +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0" +reqwest = { version = "0.12.28", features = ["json", "multipart", "stream"] } +tokio = { version = "1.48.0", features = ["full"] } +url = "2.4" +mime = "0.3" +fastrand = "2.0" +thiserror = "1.0" +bytes = "1.0" +arc-swap = "1.8.0" + +[dev-dependencies] +tokio-test = "0.4" +wiremock = "0.5" + +[lib] +name = "{{ sdk.cratePackage | default('appwrite') | rustCrateName }}" +path = "src/lib.rs" +{% if sdk.test == "true" %} + +[[bin]] +name = "tests" +path = "tests/tests.rs" +{% endif %} + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] diff --git a/templates/rust/LICENSE.twig b/templates/rust/LICENSE.twig new file mode 100644 index 000000000..ce6435c38 --- /dev/null +++ b/templates/rust/LICENSE.twig @@ -0,0 +1 @@ +{{sdk.licenseContent | raw}} diff --git a/templates/rust/README.md.twig b/templates/rust/README.md.twig new file mode 100644 index 000000000..bdc6f9e24 --- /dev/null +++ b/templates/rust/README.md.twig @@ -0,0 +1,45 @@ +# {{ spec.title }} Rust SDK + +![License](https://img.shields.io/github/license/{{ sdk.gitUserName }}/sdk-for-rust.svg) +![Version](https://img.shields.io/badge/api%20version-{{ spec.version }}-blue.svg) +[![Crates.io](https://img.shields.io/crates/v/{{ sdk.cratePackage | default('appwrite') }}.svg)](https://crates.io/crates/{{ sdk.cratePackage | default('appwrite') }}) +[![Documentation](https://docs.rs/{{ sdk.cratePackage | default('appwrite') }}/badge.svg)](https://docs.rs/{{ sdk.cratePackage | default('appwrite') }}) +{% if sdk.warning %} + +{{ sdk.warning|raw }} +{% endif %} + +{{ sdk.description }} + +{% if sdk.logo %} +![{{ spec.title }}]({{ sdk.logo }}) +{% endif %} + +## Installation + +Add this to your `Cargo.toml`: + +```toml +[dependencies] +{{ sdk.cratePackage | default('appwrite') }} = "{{ sdk.version }}" +tokio = { version = "1.48", features = ["full"] } +``` + +Or using `cargo add`: + +```bash +cargo add {{ sdk.cratePackage | default('appwrite') }} +``` + +{% if sdk.gettingStarted %} + +{{ sdk.gettingStarted|raw }} +{% endif %} + +## Contribution + +This library is auto-generated by Appwrite custom [SDK Generator](https://github.com/appwrite/sdk-generator). To learn more about how you can help us improve this SDK, please check the [contribution guide](https://github.com/appwrite/sdk-generator/blob/master/CONTRIBUTING.md) before sending a pull-request. + +## License + +Please see the [{{ spec.licenseName }} license]({{ spec.licenseURL }}) file for more information. diff --git a/templates/rust/examples/basic_usage.rs.twig b/templates/rust/examples/basic_usage.rs.twig new file mode 100644 index 000000000..16b6cff62 --- /dev/null +++ b/templates/rust/examples/basic_usage.rs.twig @@ -0,0 +1,150 @@ +//! Basic usage example for {{ spec.title }} Rust SDK + +use {{ sdk.cratePackage | default('appwrite') | rustCrateName }}::{ + Client, + services::Users, + id::ID, + {{ spec.title | caseUcfirst }}Error, +}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize the client + let client = Client::new() + .set_endpoint("{{ spec.endpoint }}") // Your API Endpoint + .set_project("5df5acd0d48c2") // Your project ID + .set_key("919c2d18fb5d4...a2ae413da83346ad2"); // Your secret API key + + println!("šŸš€ {{ spec.title }} Rust SDK Example"); + println!("Connected to: {}", client.endpoint()); + + // Initialize services + let users = Users::new(&client); + + println!("\nšŸ“ Creating a new user..."); + match users.create( + ID::unique(), + Some("walter.obrien@example.com".to_string()), + Some("+1234567890".to_string()), + Some("password123".to_string()), + Some("Walter O'Brien".to_string()), + ).await { + Ok(user) => { + println!("āœ… User created successfully!"); + println!("User ID: {}", user.id); + println!("Email: {}", user.email.unwrap_or_default()); + println!("Name: {}", user.name.unwrap_or_default()); + } + Err(e) => { + println!("āŒ Error {}: {}", e.code, e.message); + } + } + + println!("\nšŸ“‹ Listing users..."); + match users.list(None, None).await { + Ok(users_list) => { + println!("āœ… Found {} users", users_list.total); + for user in users_list.users.iter().take(3) { + println!(" - {} ({})", + user.name.as_ref().unwrap_or(&"No name".to_string()), + user.email.as_ref().unwrap_or(&"No email".to_string()) + ); + } + } + Err(e) => { + println!("āŒ Error {}: {}", e.code, e.message); + } + } + + // Example 3: Using Database service (if available) + {% for service in spec.services %} + {% if service.name == 'databases' %} + use {{ sdk.cratePackage | default('appwrite') | rustCrateName }}::services::Databases; + use {{ sdk.cratePackage | default('appwrite') | rustCrateName }}::query::Query; + + let databases = Databases::new(&client); + + println!("\nšŸ—„ļø Working with databases..."); + + // List documents with query + let queries = vec![ + Query::limit(10), + Query::order_desc("$createdAt"), + ]; + + match databases.list_documents( + "your-database-id".to_string(), + "your-collection-id".to_string(), + Some(queries.into_iter().map(|q| q.to_string()).collect()), + ).await { + Ok(documents) => { + println!("āœ… Found {} documents", documents.total); + } + Err(e) => { + println!("āŒ Database Error {}: {}", e.code, e.message); + } + } + {% endif %} + {% endfor %} + + // Example 4: Using Storage service for file operations (if available) + {% for service in spec.services %} + {% if service.name == 'storage' %} + use {{ sdk.cratePackage | default('appwrite') | rustCrateName }}::services::Storage; + use {{ sdk.cratePackage | default('appwrite') | rustCrateName }}::input_file::InputFile; + + let storage = Storage::new(&client); + + println!("\nšŸ“ Working with storage..."); + + // Create a sample file + let sample_data = b"Hello, {{ spec.title }}! This is a test file."; + let file = InputFile::from_bytes( + sample_data.to_vec(), + "test.txt", + Some("text/plain"), + ); + + match storage.create_file( + "your-bucket-id".to_string(), + ID::unique(), + file, + None, + ).await { + Ok(file_result) => { + println!("āœ… File uploaded successfully!"); + println!("File ID: {}", file_result.id); + println!("File name: {}", file_result.name); + println!("File size: {} bytes", file_result.size_original); + } + Err(e) => { + println!("āŒ Storage Error {}: {}", e.code, e.message); + } + } + {% endif %} + {% endfor %} + + // Example 5: Error handling patterns + println!("\nšŸ› ļø Demonstrating error handling..."); + + // This will likely fail with invalid credentials + let invalid_client = Client::new() + .set_endpoint("{{ spec.endpoint }}") + .set_project("invalid-project") + .set_key("invalid-key"); + + let invalid_users = Users::new(&invalid_client); + + match invalid_users.list(None, None).await { + Ok(_) => println!("Unexpected success with invalid credentials"), + Err(e) => { + println!("āœ… Expected error with invalid credentials: {} - {}", e.code, e.message); + } + } + + println!("\nšŸŽ‰ Example completed!"); + println!("For more examples, check the documentation at:"); + println!("https://docs.rs/{{ sdk.cratePackage | default('appwrite') }}"); + + Ok(()) +} diff --git a/templates/rust/src/client.rs.twig b/templates/rust/src/client.rs.twig new file mode 100644 index 000000000..c2bb227cf --- /dev/null +++ b/templates/rust/src/client.rs.twig @@ -0,0 +1,882 @@ +//! HTTP client for {{ spec.title }} API + +use crate::error::{{ spec.title | caseUcfirst }}Error; +use crate::error::Result; +use crate::input_file::InputFile; +use arc_swap::ArcSwap; +use reqwest::{header::HeaderMap, multipart, Client as HttpClient, Method, Response}; +use serde::de::DeserializeOwned; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use url::Url; + +/// Default request timeout in seconds +const DEFAULT_TIMEOUT: u64 = 10; + +/// Default chunk size for file uploads (5MB) +const DEFAULT_CHUNK_SIZE: usize = 5 * 1024 * 1024; + +/// Upload progress information +#[derive(Debug, Clone, Copy)] +pub struct UploadProgress { + /// Number of bytes uploaded so far + pub bytes_uploaded: u64, + /// Total number of bytes to upload + pub total_bytes: u64, + /// Number of chunks uploaded so far + pub chunks_uploaded: u64, + /// Total number of chunks + pub total_chunks: u64, +} + +impl UploadProgress { + /// Get the upload progress as a percentage (0.0 to 100.0) + pub fn percentage(&self) -> f64 { + if self.total_bytes == 0 { + return 0.0; + } + (self.bytes_uploaded as f64 / self.total_bytes as f64) * 100.0 + } +} + +/// Options for file upload operations +pub struct UploadOptions { + pub upload_id: Option, + pub on_progress: Option, +} + +impl Default for UploadOptions { + fn default() -> Self { + Self { + upload_id: None, + on_progress: None, + } + } +} + +/// {{ spec.title }} client for making API requests +#[derive(Debug, Clone)] +pub struct Client { + state: Arc>, +} + +#[derive(Debug, Clone)] +struct ClientState { + config: Config, + http: HttpClient, +} + +#[derive(Debug, Clone)] +struct Config { + endpoint: String, + headers: HeaderMap, + self_signed: bool, + chunk_size: usize, +} + +impl Default for Client { + fn default() -> Self { + Self::new() + } +} + +impl Client { + /// Create a new {{ spec.title }} client + pub fn new() -> Self { + let mut headers = HeaderMap::new(); +{% for key, header in spec.global.defaultHeaders %} + headers.insert("{{ key }}", "{{ header }}".parse().unwrap()); +{% endfor %} + headers.insert("user-agent", format!("{{ spec.title }}RustSDK/{{ sdk.version }} ({}; {})", std::env::consts::OS, std::env::consts::ARCH).parse().unwrap()); + headers.insert("x-sdk-name", "{{ sdk.name }}".parse().unwrap()); + headers.insert("x-sdk-platform", "{{ sdk.platform }}".parse().unwrap()); + headers.insert("x-sdk-language", "{{ language.name | caseLower }}".parse().unwrap()); + headers.insert("x-sdk-version", "{{ sdk.version }}".parse().unwrap()); + + let config = Config { + endpoint: "{{ spec.endpoint }}".to_string(), + headers, + self_signed: false, + chunk_size: DEFAULT_CHUNK_SIZE, + }; + + let http = Self::build_http_client(&config); + + let state = ClientState { config, http }; + + Self { + state: Arc::new(ArcSwap::from_pointee(state)), + } + } + + fn build_http_client(config: &Config) -> HttpClient { + let mut builder = HttpClient::builder().timeout(Duration::from_secs(DEFAULT_TIMEOUT)); + + if config.self_signed { + builder = builder.danger_accept_invalid_certs(true); + } + + builder.build().expect("Failed to create HTTP client") + } + + /// Set the API endpoint + pub fn set_endpoint>(&self, endpoint: S) -> Self { + let endpoint = endpoint.into(); + if !endpoint.starts_with("http://") && !endpoint.starts_with("https://") { + panic!("Invalid endpoint URL: {}. Endpoint must start with http:// or https://", endpoint); + } + self.state.rcu(|state| { + let mut next = (**state).clone(); + next.config.endpoint = endpoint.clone(); + Arc::new(next) + }); + self.clone() + } + + /// Set the project ID + pub fn set_project>(&self, project: S) -> Self { + let project = project.into(); + self.state.rcu(|state| { + let mut next = (**state).clone(); + next.config.headers.insert("x-appwrite-project", project.clone().parse().unwrap()); + Arc::new(next) + }); + self.clone() + } + + /// Set the API key + pub fn set_key>(&self, key: S) -> Self { + let key = key.into(); + self.state.rcu(|state| { + let mut next = (**state).clone(); + next.config.headers.insert("x-appwrite-key", key.clone().parse().unwrap()); + Arc::new(next) + }); + self.clone() + } + + /// Set the JWT token + pub fn set_jwt>(&self, jwt: S) -> Self { + let jwt = jwt.into(); + self.state.rcu(|state| { + let mut next = (**state).clone(); + next.config.headers.insert("x-appwrite-jwt", jwt.clone().parse().unwrap()); + Arc::new(next) + }); + self.clone() + } + + /// Set the locale + pub fn set_locale>(&self, locale: S) -> Self { + let locale = locale.into(); + self.state.rcu(|state| { + let mut next = (**state).clone(); + next.config.headers.insert("x-appwrite-locale", locale.clone().parse().unwrap()); + Arc::new(next) + }); + self.clone() + } + + /// Set the session + pub fn set_session>(&self, session: S) -> Self { + let session = session.into(); + self.state.rcu(|state| { + let mut next = (**state).clone(); + next.config.headers.insert("x-appwrite-session", session.clone().parse().unwrap()); + Arc::new(next) + }); + self.clone() + } + + /// Enable or disable self-signed certificates + pub fn set_self_signed(&self, self_signed: bool) -> Self { + self.state.rcu(|state| { + let mut next = (**state).clone(); + if next.config.self_signed != self_signed { + next.config.self_signed = self_signed; + next.http = Self::build_http_client(&next.config); + } + Arc::new(next) + }); + self.clone() + } + + /// Set chunk size for file uploads (minimum 1 byte) + pub fn set_chunk_size(&self, chunk_size: usize) -> Self { + self.state.rcu(|state| { + let mut next = (**state).clone(); + next.config.chunk_size = chunk_size.max(1); + Arc::new(next) + }); + self.clone() + } + + /// Add a custom header + pub fn add_header, V: AsRef>(&self, key: K, value: V) -> Self { + use reqwest::header::{HeaderName, HeaderValue}; + + let key = key.as_ref().to_string(); + let value = value.as_ref().to_string(); + + self.state.rcu(|state| { + let mut next = (**state).clone(); + if let (Ok(header_name), Ok(header_value)) = ( + key.parse::(), + value.parse::(), + ) { + next.config.headers.insert(header_name, header_value); + } + Arc::new(next) + }); + self.clone() + } + + /// Get the current endpoint + pub fn endpoint(&self) -> String { + let state = self.state.load(); + state.config.endpoint.clone() + } + + /// Serialize primitive parameter value to string + fn serialize_param_value(value: &Value) -> String { + match value { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => String::new(), + _ => value.to_string(), + } + } + + /// Flatten parameters for GET query strings + /// Converts nested arrays/objects to bracket notation: key[0], key[subkey], etc. + fn flatten_query_params(params: &HashMap) -> Vec<(String, String)> { + let mut result = Vec::new(); + + for (key, value) in params { + match value { + Value::Array(arr) => { + for item in arr { + match item { + Value::String(s) => result.push((format!("{}[]", key), s.clone())), + Value::Number(n) => result.push((format!("{}[]", key), n.to_string())), + Value::Bool(b) => result.push((format!("{}[]", key), b.to_string())), + Value::Null => result.push((format!("{}[]", key), String::new())), + _ => { + result.push((format!("{}[]", key), serde_json::to_string(item).unwrap_or_default())); + } + } + } + } + Value::Object(_) => { + result.push((key.clone(), serde_json::to_string(value).unwrap_or_default())); + } + _ => { + result.push((key.clone(), Self::serialize_param_value(value))); + } + } + } + + result + } + + /// Flatten nested parameters for multipart form data + /// Converts nested arrays/objects to bracket notation: key[0], key[subkey], etc. + fn flatten_multipart_params(params: &HashMap, prefix: &str) -> Vec<(String, String)> { + let mut result = Vec::new(); + + for (key, value) in params { + let final_key = if prefix.is_empty() { + key.clone() + } else { + format!("{}[{}]", prefix, key) + }; + + match value { + Value::Array(arr) => { + for (i, item) in arr.iter().enumerate() { + let array_key = format!("{}[{}]", final_key, i); + if item.is_object() || item.is_array() { + let mut nested = HashMap::new(); + nested.insert(String::new(), item.clone()); + let flattened = Self::flatten_multipart_params(&nested, &array_key); + result.extend(flattened); + } else { + result.push((array_key, Self::serialize_param_value(item))); + } + } + } + Value::Object(obj) => { + let mut nested_map = HashMap::new(); + for (nested_key, nested_value) in obj { + nested_map.insert(nested_key.clone(), nested_value.clone()); + } + let flattened = Self::flatten_multipart_params(&nested_map, &final_key); + result.extend(flattened); + } + _ => { + result.push((final_key, Self::serialize_param_value(value))); + } + } + } + + result + } + + pub async fn call( + &self, + method: Method, + path: &str, + headers: Option>, + params: Option>, + ) -> Result { + let state = self.state.load_full(); + let url = format!("{}{}", state.config.endpoint, path); + let mut request_builder; + + if let Some(params) = params { + if method == Method::GET { + let mut url_with_params = Url::parse(&url).map_err(|e| {{ spec.title | caseUcfirst }}Error::new(0, format!("Invalid URL: {}", e), None, String::new()))?; + { + let mut query_pairs = url_with_params.query_pairs_mut(); + for (key, value) in Self::flatten_query_params(¶ms) { + query_pairs.append_pair(&key, &value); + } + } + request_builder = state.http.request(method.clone(), url_with_params); + } else { + request_builder = state.http.request(method.clone(), &url); + request_builder = request_builder.json(¶ms); + } + } else { + request_builder = state.http.request(method.clone(), &url); + } + + request_builder = request_builder.headers(state.config.headers.clone()); + + if let Some(custom_headers) = headers { + for (key, value) in custom_headers { + request_builder = request_builder.header(key, value); + } + } + + let response = request_builder.send().await?; + self.handle_response(response).await + } + + /// Make an API call that returns a location/redirect URL (for webAuth endpoints) + pub async fn call_location( + &self, + method: Method, + path: &str, + headers: Option>, + params: Option>, + ) -> Result { + let state = self.state.load_full(); + let url = format!("{}{}", state.config.endpoint, path); + + let client_with_no_redirect = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(DEFAULT_TIMEOUT)) + .danger_accept_invalid_certs(state.config.self_signed) + .build() + ?; + + let mut request_builder = client_with_no_redirect.request(method.clone(), &url); + + request_builder = request_builder.headers(state.config.headers.clone()); + + if let Some(ref custom_headers) = headers { + for (key, value) in custom_headers { + request_builder = request_builder.header(key, value); + } + } + + if let Some(params) = params { + if method == Method::GET { + let mut url_with_params = Url::parse(&url).map_err(|e| {{ spec.title | caseUcfirst }}Error::new(0, format!("Invalid URL: {}", e), None, String::new()))?; + { + let mut query_pairs = url_with_params.query_pairs_mut(); + for (key, value) in Self::flatten_query_params(¶ms) { + query_pairs.append_pair(&key, &value); + } + } + request_builder = client_with_no_redirect.request(method, url_with_params); + request_builder = request_builder.headers(state.config.headers.clone()); + if let Some(ref custom_headers) = headers { + for (key, value) in custom_headers { + request_builder = request_builder.header(key, value); + } + } + } else { + request_builder = request_builder.json(¶ms); + } + } + + let response = request_builder.send().await?; + + let status = response.status(); + + if status.is_redirection() { + response + .headers() + .get("location") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + .ok_or_else(|| {{ spec.title | caseUcfirst }}Error::new( + status.as_u16(), + "Location header not found in redirect response", + None, + String::new(), + )) + } else { + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + let error_text = response.text().await?; + + if content_type.starts_with("application/json") { + if let Ok(error_json) = serde_json::from_str::(&error_text) { + let message = error_json + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error"); + let error_type = error_json + .get("type") + .and_then(|v| v.as_str()) + .map(|v| v.to_string()); + return Err({{ spec.title | caseUcfirst }}Error::new( + status.as_u16(), + message, + error_type, + error_text, + )); + } + } + + Err({{ spec.title | caseUcfirst }}Error::new( + status.as_u16(), + error_text.clone(), + None, + error_text, + )) + } + } + + /// Make an API call that returns raw bytes (for downloads/location endpoints) + pub async fn call_bytes( + &self, + method: Method, + path: &str, + headers: Option>, + params: Option>, + ) -> Result> { + let state = self.state.load_full(); + let url = format!("{}{}", state.config.endpoint, path); + let mut request_builder; + + if let Some(params) = params { + if method == Method::GET { + let mut url_with_params = Url::parse(&url).map_err(|e| {{ spec.title | caseUcfirst }}Error::new(0, format!("Invalid URL: {}", e), None, String::new()))?; + { + let mut query_pairs = url_with_params.query_pairs_mut(); + for (key, value) in Self::flatten_query_params(¶ms) { + query_pairs.append_pair(&key, &value); + } + } + request_builder = state.http.request(method.clone(), url_with_params); + } else { + request_builder = state.http.request(method.clone(), &url); + request_builder = request_builder.json(¶ms); + } + } else { + request_builder = state.http.request(method.clone(), &url); + } + + request_builder = request_builder.headers(state.config.headers.clone()); + + if let Some(custom_headers) = headers { + for (key, value) in custom_headers { + request_builder = request_builder.header(key, value); + } + } + + let response = request_builder.send().await?; + + let status = response.status(); + if status.is_success() { + let bytes = response.bytes().await?; + Ok(bytes.to_vec()) + } else { + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + let error_text = response.text().await?; + + if content_type.starts_with("application/json") { + if let Ok(error_json) = serde_json::from_str::(&error_text) { + let message = error_json + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error"); + let error_type = error_json + .get("type") + .and_then(|v| v.as_str()) + .map(|v| v.to_string()); + return Err({{ spec.title | caseUcfirst }}Error::new( + status.as_u16(), + message, + error_type, + error_text, + )); + } + } + + Err({{ spec.title | caseUcfirst }}Error::new( + status.as_u16(), + error_text.clone(), + None, + error_text, + )) + } + } + + /// Upload a file + pub async fn file_upload( + &self, + path: &str, + headers: Option>, + params: HashMap, + param_name: &str, + input_file: InputFile, + upload_id: Option, + ) -> Result { + self.file_upload_with_progress( + path, + headers, + params, + param_name, + input_file, + UploadOptions { + upload_id, + on_progress: None::, + }, + ) + .await + } + + /// Upload a file with progress callback + pub async fn file_upload_with_progress( + &self, + path: &str, + headers: Option>, + params: HashMap, + param_name: &str, + input_file: InputFile, + options: UploadOptions, + ) -> Result + where + F: Fn(UploadProgress), + { + let file_size = input_file.size().await?; + let chunk_size = { + let state = self.state.load(); + state.config.chunk_size.max(1) + }; + + if file_size <= chunk_size as u64 { + let state = self.state.load_full(); + let url = if let Some(id) = &options.upload_id { + format!("{}{}/{}", state.config.endpoint, path, id) + } else { + format!("{}{}", state.config.endpoint, path) + }; + + let result = self.single_file_upload(&url, headers, params, param_name, &input_file).await?; + + if let Some(callback) = &options.on_progress { + callback(UploadProgress { + bytes_uploaded: file_size, + total_bytes: file_size, + chunks_uploaded: 1, + total_chunks: 1, + }); + } + + return Ok(result); + } + + self.chunked_file_upload_with_progress(path, headers, params, param_name, &input_file, options).await + } + + async fn single_file_upload( + &self, + url: &str, + headers: Option>, + params: HashMap, + param_name: &str, + input_file: &InputFile, + ) -> Result { + let state = self.state.load_full(); + let mut form = multipart::Form::new(); + + let file_data = input_file.read_all().await?; + let file_size = file_data.len() as u64; + let mut file_part = multipart::Part::stream_with_length(file_data, file_size) + .file_name(input_file.filename().to_string()); + + if let Some(mime_type) = input_file.mime_type() { + file_part = file_part.mime_str(mime_type) + .map_err(|e| {{ spec.title | caseUcfirst }}Error::new(0, format!("Invalid MIME type: {}", e), None, String::new()))?; + } + + form = form.part(param_name.to_string(), file_part); + + let mut params_to_flatten = HashMap::new(); + for (key, value) in params { + if key != param_name { + params_to_flatten.insert(key, value); + } + } + + for (key, value_str) in Self::flatten_multipart_params(¶ms_to_flatten, "") { + form = form.text(key, value_str); + } + + let mut request_builder = state.http.post(url).headers(state.config.headers.clone()); + + if let Some(custom_headers) = headers { + for (key, value) in custom_headers { + // Skip content-type for multipart - reqwest sets it automatically with boundary + if key.to_lowercase() != "content-type" { + request_builder = request_builder.header(key, value); + } + } + } + + let response = request_builder + .multipart(form) + .send() + .await + ?; + + self.handle_response(response).await + } + + async fn chunked_file_upload_with_progress( + &self, + path: &str, + headers: Option>, + params: HashMap, + param_name: &str, + input_file: &InputFile, + options: UploadOptions, + ) -> Result + where + F: Fn(UploadProgress), + { + let file_size = input_file.size().await?; + let chunk_size = { + let state = self.state.load(); + state.config.chunk_size.max(1) + }; + let total_chunks = file_size.div_ceil(chunk_size as u64); + let mut current_upload_id = options.upload_id; + let mut start_chunk = 0u64; + if let Some(ref id) = current_upload_id { + if let Ok(response) = self.call::( + Method::GET, + &format!("{}/{}", path, id), + None, + None, + ).await { + if let Some(chunks_uploaded) = response.get("chunksUploaded").and_then(|v| v.as_u64()) { + start_chunk = chunks_uploaded; + } + } + } + + let mut reader = input_file.chunked_reader().await?; + + if start_chunk > 0 { + let resume_offset = start_chunk * chunk_size as u64; + reader.seek(resume_offset).await?; + } + + let mut last_response = None; + + for chunk_index in start_chunk..total_chunks { + let chunk_data = match reader.read_next(chunk_size).await? { + Some(data) => data, + None => break, + }; + let actual_chunk_size = chunk_data.len(); + let start = reader.position() - actual_chunk_size as u64; + + if actual_chunk_size == 0 { + break; + } + + let state = self.state.load_full(); + let mut form = multipart::Form::new(); + let mut file_part = multipart::Part::stream_with_length(chunk_data, actual_chunk_size as u64) + .file_name(input_file.filename().to_string()); + + if let Some(mime_type) = input_file.mime_type() { + file_part = file_part.mime_str(mime_type) + .map_err(|e| {{ spec.title | caseUcfirst }}Error::new(0, format!("Invalid MIME type: {}", e), None, String::new()))?; + } + + form = form.part(param_name.to_string(), file_part); + + let mut params_to_flatten = HashMap::new(); + for (key, value) in ¶ms { + if key != param_name { + params_to_flatten.insert(key.clone(), value.clone()); + } + } + + for (key, value_str) in Self::flatten_multipart_params(¶ms_to_flatten, "") { + form = form.text(key, value_str); + } + + let url = format!("{}{}", state.config.endpoint, path); + let mut request_builder = state.http.post(url).headers(state.config.headers.clone()); + + if let Some(ref custom_headers) = headers { + for (key, value) in custom_headers { + // Skip content-type for multipart - reqwest sets it automatically with boundary + if key.to_lowercase() != "content-type" { + request_builder = request_builder.header(key, value); + } + } + } + + if let Some(ref id) = current_upload_id { + request_builder = request_builder.header("x-appwrite-id", id); + } + + let chunk_end = start + actual_chunk_size as u64 - 1; + let content_range = format!("bytes {}-{}/{}", start, chunk_end, file_size); + request_builder = request_builder.header("content-range", content_range); + + let response = request_builder + .multipart(form) + .send() + .await + ?; + + let result: Value = self.handle_response(response).await?; + + if current_upload_id.is_none() { + if let Some(id) = result.get("$id").and_then(|v| v.as_str()) { + current_upload_id = Some(id.to_string()); + } + } + + last_response = Some(result); + + if let Some(ref callback) = options.on_progress { + callback(UploadProgress { + bytes_uploaded: start + actual_chunk_size as u64, + total_bytes: file_size, + chunks_uploaded: chunk_index + 1, + total_chunks, + }); + } + } + + last_response + .ok_or_else(|| {{ spec.title | caseUcfirst }}Error::new(0, "No chunks uploaded", None, String::new())) + .and_then(|v| serde_json::from_value(v).map_err(Into::into)) + } + + async fn handle_response(&self, response: Response) -> Result { + let status = response.status(); + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + .unwrap_or_default(); + let content_type = content_type.as_str(); + + if status.is_success() { + let bytes = response.bytes().await?; + + if !content_type.is_empty() && !content_type.starts_with("application/json") && !bytes.is_empty() { + return Err({{ spec.title | caseUcfirst }}Error::new( + status.as_u16(), + format!("Expected JSON response but received content-type: {}", content_type), + None, + String::from_utf8_lossy(&bytes).to_string(), + )); + } + + Ok(if bytes.is_empty() { + serde_json::from_slice(b"null")? + } else { + serde_json::from_slice(&bytes)? + }) + } else { + let error_text = response.text().await?; + + if content_type.starts_with("application/json") { + if let Ok(error_json) = serde_json::from_str::(&error_text) { + let message = error_json + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error"); + let error_type = error_json + .get("type") + .and_then(|v| v.as_str()) + .map(|v| v.to_string()); + return Err({{ spec.title | caseUcfirst }}Error::new( + status.as_u16(), + message, + error_type, + error_text, + )); + } + } + + Err({{ spec.title | caseUcfirst }}Error::new( + status.as_u16(), + error_text.clone(), + None, + error_text, + )) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_client_creation() { + let client = Client::new(); + assert_eq!(client.endpoint(), "{{ spec.endpoint }}"); + } + + #[test] + fn test_client_builder_pattern() { + let client = Client::new() + .set_endpoint("https://custom.example.com/v1") + .set_project("test-project") + .set_key("test-key"); + + assert_eq!(client.endpoint(), "https://custom.example.com/v1"); + } +} diff --git a/templates/rust/src/enums/enum.rs.twig b/templates/rust/src/enums/enum.rs.twig new file mode 100644 index 000000000..044fb1a2b --- /dev/null +++ b/templates/rust/src/enums/enum.rs.twig @@ -0,0 +1,30 @@ +use serde::{Deserialize, Serialize}; + +{% if enum.description %}/// {{ enum.description }} +{% endif %}#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +pub enum {{ enum.name | caseUcfirst | overrideIdentifier }} { +{% for value in enum.enum %} +{% set key = enum.keys is empty ? value : enum.keys[loop.index0] %} + #[serde(rename = "{{ value }}")] + {% if loop.first %}#[default] + {% endif %}{{ key | caseEnumKey | overrideIdentifier }}, +{% endfor %} +} + +impl {{ enum.name | caseUcfirst | overrideIdentifier }} { + /// Get the string value of the enum + pub fn as_str(&self) -> &str { + match self { +{% for value in enum.enum %} +{% set key = enum.keys is empty ? value : enum.keys[loop.index0] %} + {{ enum.name | caseUcfirst | overrideIdentifier }}::{{ key | caseEnumKey | overrideIdentifier }} => "{{ value }}", +{% endfor %} + } + } +} + +impl std::fmt::Display for {{ enum.name | caseUcfirst | overrideIdentifier }} { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} diff --git a/templates/rust/src/enums/mod.rs.twig b/templates/rust/src/enums/mod.rs.twig new file mode 100644 index 000000000..1560f0953 --- /dev/null +++ b/templates/rust/src/enums/mod.rs.twig @@ -0,0 +1,6 @@ +//! Enums for {{ spec.title }} SDK + +{% for enum in spec.allEnums %} +pub mod {{ enum.name | caseSnake | overrideIdentifier }}; +pub use {{ enum.name | caseSnake | overrideIdentifier }}::{{ enum.name | caseUcfirst | overrideIdentifier }}; +{% endfor %} diff --git a/templates/rust/src/error.rs.twig b/templates/rust/src/error.rs.twig new file mode 100644 index 000000000..81cb6b206 --- /dev/null +++ b/templates/rust/src/error.rs.twig @@ -0,0 +1,111 @@ +//! Error types for {{ spec.title }} SDK + +use serde_json; + +/// Result type alias for SDK operations +pub type Result = std::result::Result; + +/// Main error type for {{ spec.title }} SDK +#[derive(Debug, Clone, thiserror::Error)] +#[error("{message}")] +pub struct {{ spec.title | caseUcfirst }}Error { + pub code: u16, + pub message: String, + pub error_type: Option, + pub response: String, +} + +impl {{ spec.title | caseUcfirst }}Error { + pub fn new(code: u16, message: impl Into, error_type: Option, response: impl Into) -> Self { + Self { + code, + message: message.into(), + error_type, + response: response.into(), + } + } + + pub fn status_code(&self) -> u16 { + self.code + } + + pub fn get_message(&self) -> &str { + &self.message + } + + pub fn get_type(&self) -> Option<&str> { + self.error_type.as_deref() + } + + pub fn get_response(&self) -> &str { + &self.response + } + + pub fn is_client_error(&self) -> bool { + (400..500).contains(&self.code) + } + + pub fn is_server_error(&self) -> bool { + (500..600).contains(&self.code) + } +} + +impl From for {{ spec.title | caseUcfirst }}Error { + fn from(err: reqwest::Error) -> Self { + Self::new(0, format!("HTTP error: {}", err), None, String::new()) + } +} + +impl From for {{ spec.title | caseUcfirst }}Error { + fn from(err: serde_json::Error) -> Self { + Self::new(0, format!("Serialization error: {}", err), None, String::new()) + } +} + +impl From for {{ spec.title | caseUcfirst }}Error { + fn from(err: std::io::Error) -> Self { + Self::new(0, format!("File error: {}", err), None, String::new()) + } +} + +/// {{ spec.title }} specific error response structure +#[derive(Debug, serde::Deserialize)] +pub struct ErrorResponse { + pub message: String, + pub code: Option, + pub r#type: Option, + pub version: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_client_error() { + let error = {{ spec.title | caseUcfirst }}Error::new( + 404, + "Not found", + None, + "{}", + ); + + assert_eq!(error.status_code(), 404); + assert_eq!(error.get_message(), "Not found"); + assert!(error.is_client_error()); + assert!(!error.is_server_error()); + } + + #[test] + fn test_server_error() { + let error = {{ spec.title | caseUcfirst }}Error::new( + 500, + "Internal server error", + None, + "{}", + ); + + assert!(error.is_server_error()); + assert!(!error.is_client_error()); + } +} diff --git a/templates/rust/src/id.rs.twig b/templates/rust/src/id.rs.twig new file mode 100644 index 000000000..fb3c65b50 --- /dev/null +++ b/templates/rust/src/id.rs.twig @@ -0,0 +1,118 @@ +//! ID generation utilities for {{ spec.title }} SDK + +use std::time::{SystemTime, UNIX_EPOCH}; + +/// ID generator for {{ spec.title }} resources +pub struct ID; + +impl ID { + /// Generate a unique ID (13 hex timestamp + 7 random hex) + pub fn unique() -> String { + Self::unique_with_padding(7) + } + + /// Generate a unique ID with custom padding + pub fn unique_with_padding(padding: usize) -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards"); + + let sec = now.as_secs(); + let usec = now.subsec_micros(); + let timestamp_hex = format!("{:08x}{:05x}", sec, usec); + + if padding == 0 { + return timestamp_hex; + } + + let mut id = String::with_capacity(13 + padding); + id.push_str(×tamp_hex); + + const HEX: &[u8; 16] = b"0123456789abcdef"; + for _ in 0..padding { + let idx = fastrand::u8(..16) as usize; + id.push(HEX[idx] as char); + } + + id + } + + /// Create a custom ID + pub fn custom>(id: S) -> String { + id.into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_unique_id_length() { + let id = ID::unique(); + assert_eq!(id.len(), 20); + } + + #[test] + fn test_unique_id_uniqueness() { + let id1 = ID::unique(); + let id2 = ID::unique(); + assert_ne!(id1, id2); + } + + #[test] + fn test_unique_id_hex_format() { + let id = ID::unique(); + assert!(id.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_custom_id_passthrough() { + let id = ID::custom("my_custom_id_123"); + assert_eq!(id, "my_custom_id_123"); + } + + #[test] + fn test_custom_id_with_special_chars() { + let id = ID::custom("test@id-with_special.chars"); + assert_eq!(id, "test@id-with_special.chars"); + } + + #[test] + fn test_custom_id_empty() { + let id = ID::custom(""); + assert_eq!(id, ""); + } + + #[test] + fn test_multiple_unique_ids() { + let ids: Vec = (0..100).map(|_| ID::unique()).collect(); + assert!(ids.iter().all(|id| id.len() == 20)); + let unique_count = ids.iter().collect::>().len(); + assert_eq!(unique_count, 100); + } + + #[test] + fn test_unique_with_padding() { + let id_no_padding = ID::unique_with_padding(0); + assert_eq!(id_no_padding.len(), 13); + + let id_small_padding = ID::unique_with_padding(3); + assert_eq!(id_small_padding.len(), 16); + + let id_large_padding = ID::unique_with_padding(15); + assert_eq!(id_large_padding.len(), 28); + } + + #[test] + fn test_format_matches_sdk_length() { + let id = ID::unique(); + assert_eq!(id.len(), 20); + assert!(id.chars().all(|c| c.is_ascii_hexdigit())); + + let timestamp_part = &id[..13]; + let random_part = &id[13..]; + assert_eq!(timestamp_part.len(), 13); + assert_eq!(random_part.len(), 7); + } +} diff --git a/templates/rust/src/input_file.rs.twig b/templates/rust/src/input_file.rs.twig new file mode 100644 index 000000000..78fb23a33 --- /dev/null +++ b/templates/rust/src/input_file.rs.twig @@ -0,0 +1,294 @@ +//! Input file handling for {{ spec.title }} SDK + +use crate::error::Result; +use bytes::Bytes; +use std::path::{Path, PathBuf}; +use tokio::fs; + +/// Source of the input file data +#[derive(Debug, Clone)] +pub enum InputFileSource { + Path { path: PathBuf }, + Bytes { data: Bytes }, +} + +/// Chunked reader for streaming file data without reopening +pub struct ChunkedReader { + file: Option, + data: Option, + position: u64, + total_size: u64, +} + +/// Represents a file to be uploaded to {{ spec.title }} +#[derive(Debug, Clone)] +pub struct InputFile { + source: InputFileSource, + filename: String, + mime_type: Option, +} + +impl Default for InputFile { + fn default() -> Self { + Self { + source: InputFileSource::Bytes { data: Bytes::new() }, + filename: String::new(), + mime_type: None, + } + } +} + +impl InputFile { + /// Create a new InputFile from raw bytes + pub fn from_bytes, B: Into>( + data: B, + filename: S, + mime_type: Option<&str>, + ) -> Self { + Self { + source: InputFileSource::Bytes { data: data.into() }, + filename: filename.into(), + mime_type: mime_type.map(|s| s.to_string()), + } + } + + pub async fn from_path>(path: P, mime_type: Option<&str>) -> Result { + let path = path.as_ref(); + fs::metadata(path).await?; + + let filename = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("unknown") + .to_string(); + + Ok(Self { + source: InputFileSource::Path { path: path.to_path_buf() }, + filename, + mime_type: mime_type.map(|s| s.to_string()), + }) + } + + pub fn from_path_sync>(path: P, mime_type: Option<&str>) -> Result { + let path = path.as_ref(); + std::fs::metadata(path)?; + + let filename = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("unknown") + .to_string(); + + Ok(Self { + source: InputFileSource::Path { path: path.to_path_buf() }, + filename, + mime_type: mime_type.map(|s| s.to_string()), + }) + } + + pub fn source(&self) -> &InputFileSource { + &self.source + } + + pub fn filename(&self) -> &str { + &self.filename + } + + pub fn mime_type(&self) -> Option<&str> { + self.mime_type.as_deref() + } + + pub async fn size(&self) -> Result { + match &self.source { + InputFileSource::Bytes { data } => Ok(data.len() as u64), + InputFileSource::Path { path } => { + Ok(fs::metadata(path).await?.len()) + } + } + } + + pub fn size_sync(&self) -> Result { + match &self.source { + InputFileSource::Bytes { data } => Ok(data.len() as u64), + InputFileSource::Path { path } => { + Ok(std::fs::metadata(path)?.len()) + } + } + } + + pub async fn read_all(&self) -> Result { + match &self.source { + InputFileSource::Bytes { data } => Ok(data.clone()), + InputFileSource::Path { path } => { + Ok(Bytes::from(fs::read(path).await?)) + } + } + } + + pub async fn chunked_reader(&self) -> Result { + match &self.source { + InputFileSource::Path { path } => { + let file = fs::File::open(path).await?; + let total_size = file.metadata().await?.len(); + Ok(ChunkedReader { + file: Some(file), + data: None, + position: 0, + total_size, + }) + } + InputFileSource::Bytes { data } => { + Ok(ChunkedReader { + file: None, + data: Some(data.clone()), + position: 0, + total_size: data.len() as u64, + }) + } + } + } + + /// Set the filename + pub fn set_filename>(mut self, filename: S) -> Self { + self.filename = filename.into(); + self + } + + /// Set the MIME type + pub fn set_mime_type>(mut self, mime_type: S) -> Self { + self.mime_type = Some(mime_type.into()); + self + } + + pub fn is_empty(&self) -> bool { + match &self.source { + InputFileSource::Bytes { data } => data.is_empty(), + InputFileSource::Path { .. } => false, + } + } +} + +impl ChunkedReader { + /// Read the next chunk of specified size + pub async fn read_next(&mut self, chunk_size: usize) -> Result> { + if self.position >= self.total_size { + return Ok(None); + } + + match (&mut self.file, &self.data) { + (Some(file), None) => { + use tokio::io::AsyncReadExt; + + let remaining = (self.total_size - self.position) as usize; + let to_read = remaining.min(chunk_size); + let mut buffer = vec![0u8; to_read]; + let mut total_read = 0; + + while total_read < to_read { + match file.read(&mut buffer[total_read..]).await? { + 0 => break, + n => total_read += n, + } + } + + if total_read == 0 { + return Ok(None); + } + + buffer.truncate(total_read); + self.position += total_read as u64; + Ok(Some(Bytes::from(buffer))) + } + (None, Some(data)) => { + let start = self.position as usize; + let end = ((self.position + chunk_size as u64).min(self.total_size)) as usize; + + if start >= end { + return Ok(None); + } + + self.position = end as u64; + Ok(Some(data.slice(start..end))) + } + _ => Ok(None), + } + } + + /// Get the current read position + pub fn position(&self) -> u64 { + self.position + } + + /// Get the total size + pub fn total_size(&self) -> u64 { + self.total_size + } + + pub async fn seek(&mut self, position: u64) -> Result<()> { + if position > self.total_size { + return Err(crate::error::{{ spec.title | caseUcfirst }}Error::new( + 0, + format!("Seek position {} exceeds file size {}", position, self.total_size), + None, + String::new(), + )); + } + + if let Some(file) = &mut self.file { + use tokio::io::AsyncSeekExt; + file.seek(std::io::SeekFrom::Start(position)).await?; + } + + self.position = position; + Ok(()) + } +} + +impl From> for InputFile { + fn from(data: Vec) -> Self { + Self::from_bytes(data, "unknown", None) + } +} + +impl From<&[u8]> for InputFile { + fn from(data: &[u8]) -> Self { + Self::from_bytes(data.to_vec(), "unknown", None) + } +} + +impl From for InputFile { + fn from(data: Bytes) -> Self { + Self::from_bytes(data, "unknown", None) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_from_bytes() { + let data = b"Hello, world!".to_vec(); + let file = InputFile::from_bytes(data.clone(), "test.txt", Some("text/plain")); + + assert_eq!(file.read_all().await.unwrap(), data); + assert_eq!(file.filename(), "test.txt"); + assert_eq!(file.mime_type(), Some("text/plain")); + assert_eq!(file.size().await.unwrap(), 13); + } + + #[test] + fn test_empty_file() { + let file = InputFile::default(); + assert!(file.is_empty()); + assert_eq!(file.size_sync().unwrap(), 0); + } + + #[tokio::test] + async fn test_from_vec() { + let data = vec![1, 2, 3, 4, 5]; + let file = InputFile::from(data.clone()); + assert_eq!(file.read_all().await.unwrap(), data); + assert_eq!(file.filename(), "unknown"); + } +} diff --git a/templates/rust/src/lib.rs.twig b/templates/rust/src/lib.rs.twig new file mode 100644 index 000000000..3616be4a5 --- /dev/null +++ b/templates/rust/src/lib.rs.twig @@ -0,0 +1,63 @@ +//! # {{ spec.title }} SDK for Rust +//! +//! {{ spec.description }} +//! +//! ## Installation +//! +//! Add this to your `Cargo.toml`: +//! +//! ```toml +//! [dependencies] +//! {{ sdk.cratePackage | default('appwrite') }} = "{{ sdk.version }}" +//! ``` +//! +//! ## Usage +//! +//! ```rust +//! use {{ sdk.cratePackage | default('appwrite') | rustCrateName }}::Client; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! let client = Client::new() +//! .set_endpoint("{{ spec.endpoint }}") +//! .set_project("your-project-id") +//! .set_key("your-api-key"); +//! +//! // Use the client to make API calls +//! Ok(()) +//! } +//! ``` + +pub mod client; +pub mod enums; +pub mod error; +pub mod input_file; +pub mod models; +pub mod services; + +// Utility modules +pub mod id; +pub mod operator; +pub mod permission; +pub mod query; +pub mod role; + +// Re-export commonly used types +pub use client::Client; +pub use error::{{ spec.title | caseUcfirst }}Error; +pub use input_file::InputFile; + +/// Result type alias for SDK operations +pub type Result = std::result::Result; + +/// SDK version +pub const VERSION: &str = "{{ sdk.version }}"; + +/// SDK name +pub const SDK_NAME: &str = "{{ sdk.name }}"; + +/// SDK platform +pub const SDK_PLATFORM: &str = "{{ sdk.platform }}"; + +/// SDK language +pub const SDK_LANGUAGE: &str = "{{ language.name | caseLower }}"; diff --git a/templates/rust/src/models/mod.rs.twig b/templates/rust/src/models/mod.rs.twig new file mode 100644 index 000000000..c340f23c6 --- /dev/null +++ b/templates/rust/src/models/mod.rs.twig @@ -0,0 +1,24 @@ +//! Data models for {{ spec.title }} SDK + +{% for definition in spec.definitions %} +pub mod {{ definition.name | caseSnake | overrideIdentifier }}; +pub use {{ definition.name | caseSnake | overrideIdentifier }}::{{ definition.name | caseUcfirst | overrideIdentifier }}; +{% endfor %} +{% for definition in spec.requestModels %} +pub mod {{ definition.name | caseSnake | overrideIdentifier }}; +pub use {{ definition.name | caseSnake | overrideIdentifier }}::{{ definition.name | caseUcfirst | overrideIdentifier }}; +{% endfor %} + +// Re-export commonly used types +use serde::{Deserialize, Serialize}; + +/// Base trait for all {{ spec.title }} models +pub trait Model: Serialize + for<'de> Deserialize<'de> + Clone + std::fmt::Debug {} + +// Implement the trait for all generated models +{% for definition in spec.definitions %} +impl Model for {{ definition.name | caseUcfirst | overrideIdentifier }} {} +{% endfor %} +{% for definition in spec.requestModels %} +impl Model for {{ definition.name | caseUcfirst | overrideIdentifier }} {} +{% endfor %} diff --git a/templates/rust/src/models/model.rs.twig b/templates/rust/src/models/model.rs.twig new file mode 100644 index 000000000..c973cdbd2 --- /dev/null +++ b/templates/rust/src/models/model.rs.twig @@ -0,0 +1,96 @@ +//! {{ definition.name | caseUcfirst }} model for {{ spec.title }} SDK + +use serde::{Deserialize, Serialize}; +{% if definition.additionalProperties %} +use std::collections::HashMap; +{% endif %} +{% set fieldNames = {} %} + +{{ definition.description | rustdocComment }} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(test, derive(Default))] +pub struct {{ definition.name | caseUcfirst | overrideIdentifier }} { +{% for property in definition.properties %} +{% set propertyName = property.name %} +{% set fieldName = propertyName matches '/^_?[A-Z_]+$/' ? propertyName | lower | trim('_') : propertyName | caseSnake %} +{% set fieldName = fieldName | overrideIdentifier %} + {{ property.description | rustdocComment(4) }} + #[serde(rename = "{{ property.name }}")] +{% if property.required %} + pub {{ fieldName }}: {{ property | propertyType(spec) | rustType }}, +{% else %} + #[serde(skip_serializing_if = "Option::is_none")] + pub {{ fieldName }}: Option<{{ property | propertyType(spec) | rustType }}>, +{% endif %} +{% endfor %} +{% if definition.additionalProperties %} + + #[serde(flatten)] + pub data: HashMap, +{% endif %} +} + +impl {{ definition.name | caseUcfirst | overrideIdentifier }} { +{% for property in definition.properties %} +{% set propertyName = property.name %} +{% set fieldName = propertyName matches '/^_?[A-Z_]+$/' ? propertyName | lower | trim('_') : propertyName | caseSnake %} +{% set fieldName = fieldName | overrideIdentifier %} +{% if not property.required %} + /// Set {{ fieldName }} + pub fn set_{{ fieldName }}(mut self, {{ fieldName }}: {{ property | propertyType(spec) | rustType }}) -> Self { + self.{{ fieldName }} = Some({{ fieldName }}); + self + } + + /// Get {{ fieldName }} + pub fn {{ fieldName }}(&self) -> Option<&{{ property | propertyType(spec) | rustType }}> { + self.{{ fieldName }}.as_ref() + } +{% else %} + /// Get {{ fieldName }} + pub fn {{ fieldName }}(&self) -> &{{ property | propertyType(spec) | rustType }} { + &self.{{ fieldName }} + } +{% endif %} + +{% endfor %} +{% if definition.additionalProperties %} + + pub fn get(&self, key: &str) -> Option { + self.data.get(key) + .and_then(|v| serde_json::from_value(v.clone()).ok()) + } + + pub fn data(&self) -> &HashMap { + &self.data + } +{% endif %} +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_{{ definition.name | caseSnake }}_creation() { + let _model = <{{ definition.name | caseUcfirst | overrideIdentifier }} as Default>::default(); +{% for property in definition.properties %} +{% if property.required %} +{% set propertyName = property.name %} +{% set fieldName = propertyName matches '/^_?[A-Z_]+$/' ? propertyName | lower | trim('_') : propertyName | caseSnake %} +{% set fieldName = fieldName | overrideIdentifier %} + let _ = _model.{{ fieldName }}(); +{% endif %} +{% endfor %} + } + + #[test] + fn test_{{ definition.name | caseSnake }}_serialization() { + let model = <{{ definition.name | caseUcfirst | overrideIdentifier }} as Default>::default(); + let json = serde_json::to_string(&model); + assert!(json.is_ok()); + + let deserialized: Result<{{ definition.name | caseUcfirst | overrideIdentifier }}, _> = serde_json::from_str(&json.unwrap()); + assert!(deserialized.is_ok()); + } +} diff --git a/templates/rust/src/models/request_model.rs.twig b/templates/rust/src/models/request_model.rs.twig new file mode 100644 index 000000000..63fd5bad1 --- /dev/null +++ b/templates/rust/src/models/request_model.rs.twig @@ -0,0 +1,77 @@ +//! {{ requestModel.name | caseUcfirst }} request model for {{ spec.title }} SDK + +use serde::{Deserialize, Serialize}; +{% set fieldNames = {} %} + +{{ requestModel.description | rustdocComment }} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(test, derive(Default))] +pub struct {{ requestModel.name | caseUcfirst | overrideIdentifier }} { +{% for property in requestModel.properties %} +{% set propertyName = property.name %} +{% set fieldName = propertyName matches '/^_?[A-Z_]+$/' ? propertyName | lower | trim('_') : propertyName | caseSnake %} +{% set fieldName = fieldName | overrideIdentifier %} + {{ property.description | rustdocComment(4) }} + #[serde(rename = "{{ property.name }}")] +{% if property.required %} + pub {{ fieldName }}: {{ property | propertyType(spec) | rustType }}, +{% else %} + #[serde(skip_serializing_if = "Option::is_none")] + pub {{ fieldName }}: Option<{{ property | propertyType(spec) | rustType }}>, +{% endif %} +{% endfor %} +} + +impl {{ requestModel.name | caseUcfirst | overrideIdentifier }} { +{% for property in requestModel.properties %} +{% set propertyName = property.name %} +{% set fieldName = propertyName matches '/^_?[A-Z_]+$/' ? propertyName | lower | trim('_') : propertyName | caseSnake %} +{% set fieldName = fieldName | overrideIdentifier %} +{% if not property.required %} + /// Set {{ fieldName }} + pub fn set_{{ fieldName }}(mut self, {{ fieldName }}: {{ property | propertyType(spec) | rustType }}) -> Self { + self.{{ fieldName }} = Some({{ fieldName }}); + self + } + + /// Get {{ fieldName }} + pub fn {{ fieldName }}(&self) -> Option<&{{ property | propertyType(spec) | rustType }}> { + self.{{ fieldName }}.as_ref() + } +{% else %} + /// Get {{ fieldName }} + pub fn {{ fieldName }}(&self) -> &{{ property | propertyType(spec) | rustType }} { + &self.{{ fieldName }} + } +{% endif %} + +{% endfor %} +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_{{ requestModel.name | caseSnake }}_creation() { + let _model = <{{ requestModel.name | caseUcfirst | overrideIdentifier }} as Default>::default(); +{% for property in requestModel.properties %} +{% if property.required %} +{% set propertyName = property.name %} +{% set fieldName = propertyName matches '/^_?[A-Z_]+$/' ? propertyName | lower | trim('_') : propertyName | caseSnake %} +{% set fieldName = fieldName | overrideIdentifier %} + let _ = _model.{{ fieldName }}(); +{% endif %} +{% endfor %} + } + + #[test] + fn test_{{ requestModel.name | caseSnake }}_serialization() { + let model = <{{ requestModel.name | caseUcfirst | overrideIdentifier }} as Default>::default(); + let json = serde_json::to_string(&model); + assert!(json.is_ok()); + + let deserialized: Result<{{ requestModel.name | caseUcfirst | overrideIdentifier }}, _> = serde_json::from_str(&json.unwrap()); + assert!(deserialized.is_ok()); + } +} diff --git a/templates/rust/src/operator.rs.twig b/templates/rust/src/operator.rs.twig new file mode 100644 index 000000000..573ca0e39 --- /dev/null +++ b/templates/rust/src/operator.rs.twig @@ -0,0 +1,412 @@ +use serde::Serialize; +use serde_json::Value; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Condition { + Equal, + NotEqual, + GreaterThan, + GreaterThanEqual, + LessThan, + LessThanEqual, + Contains, + IsNull, + IsNotNull, +} + +impl Condition { + pub fn as_str(&self) -> &'static str { + match self { + Condition::Equal => "equal", + Condition::NotEqual => "notEqual", + Condition::GreaterThan => "greaterThan", + Condition::GreaterThanEqual => "greaterThanEqual", + Condition::LessThan => "lessThan", + Condition::LessThanEqual => "lessThanEqual", + Condition::Contains => "contains", + Condition::IsNull => "isNull", + Condition::IsNotNull => "isNotNull", + } + } +} + +#[derive(Debug, Serialize)] +struct OperatorData { + method: String, + values: Vec, +} + +fn parse_operator(method: &str, values: Vec) -> String { + let data = OperatorData { + method: method.to_string(), + values, + }; + serde_json::to_string(&data).expect("Failed to serialize operator data") +} + +fn validate_numeric(value: &Value, param_name: &str) { + if let Some(num) = value.as_f64() { + if num.is_nan() || num.is_infinite() { + panic!("{} cannot be NaN or Infinity", param_name); + } + } +} + +pub fn increment() -> String { + parse_operator("increment", vec![Value::from(1)]) +} + +pub fn increment_by>(value: T) -> String { + let val = value.into(); + validate_numeric(&val, "value"); + parse_operator("increment", vec![val]) +} + +pub fn increment_with_max, M: Into>(value: T, max: M) -> String { + let val = value.into(); + let max_val = max.into(); + validate_numeric(&val, "value"); + validate_numeric(&max_val, "max"); + parse_operator("increment", vec![val, max_val]) +} + +pub fn decrement() -> String { + parse_operator("decrement", vec![Value::from(1)]) +} + +pub fn decrement_by>(value: T) -> String { + let val = value.into(); + validate_numeric(&val, "value"); + parse_operator("decrement", vec![val]) +} + +pub fn decrement_with_min, M: Into>(value: T, min: M) -> String { + let val = value.into(); + let min_val = min.into(); + validate_numeric(&val, "value"); + validate_numeric(&min_val, "min"); + parse_operator("decrement", vec![val, min_val]) +} + +pub fn multiply>(factor: T) -> String { + let val = factor.into(); + validate_numeric(&val, "factor"); + parse_operator("multiply", vec![val]) +} + +pub fn multiply_with_max, M: Into>(factor: T, max: M) -> String { + let val = factor.into(); + let max_val = max.into(); + validate_numeric(&val, "factor"); + validate_numeric(&max_val, "max"); + parse_operator("multiply", vec![val, max_val]) +} + +pub fn divide>(divisor: T) -> String { + let val = divisor.into(); + validate_numeric(&val, "divisor"); + + if let Some(num) = val.as_f64() { + if num == 0.0 { + panic!("divisor cannot be zero"); + } + } else if let Some(num) = val.as_i64() { + if num == 0 { + panic!("divisor cannot be zero"); + } + } + + parse_operator("divide", vec![val]) +} + +pub fn divide_with_min, M: Into>(divisor: T, min: M) -> String { + let val = divisor.into(); + let min_val = min.into(); + validate_numeric(&val, "divisor"); + validate_numeric(&min_val, "min"); + + if let Some(num) = val.as_f64() { + if num == 0.0 { + panic!("divisor cannot be zero"); + } + } else if let Some(num) = val.as_i64() { + if num == 0 { + panic!("divisor cannot be zero"); + } + } + + parse_operator("divide", vec![val, min_val]) +} + +pub fn modulo>(divisor: T) -> String { + let val = divisor.into(); + validate_numeric(&val, "divisor"); + + if let Some(num) = val.as_f64() { + if num == 0.0 { + panic!("divisor cannot be zero"); + } + } else if let Some(num) = val.as_i64() { + if num == 0 { + panic!("divisor cannot be zero"); + } + } + + parse_operator("modulo", vec![val]) +} + +pub fn power>(exponent: T) -> String { + let val = exponent.into(); + validate_numeric(&val, "exponent"); + parse_operator("power", vec![val]) +} + +pub fn power_with_max, M: Into>(exponent: T, max: M) -> String { + let val = exponent.into(); + let max_val = max.into(); + validate_numeric(&val, "exponent"); + validate_numeric(&max_val, "max"); + parse_operator("power", vec![val, max_val]) +} + +pub fn array_append(values: &[T]) -> String { + let vals: Vec = values + .iter() + .map(|v| serde_json::to_value(v).expect("Failed to serialize value")) + .collect(); + parse_operator("arrayAppend", vals) +} + +pub fn array_prepend(values: &[T]) -> String { + let vals: Vec = values + .iter() + .map(|v| serde_json::to_value(v).expect("Failed to serialize value")) + .collect(); + parse_operator("arrayPrepend", vals) +} + +pub fn array_insert>(index: i64, value: T) -> String { + parse_operator("arrayInsert", vec![Value::from(index), value.into()]) +} + +pub fn array_remove>(value: T) -> String { + parse_operator("arrayRemove", vec![value.into()]) +} + +pub fn array_unique() -> String { + parse_operator("arrayUnique", vec![]) +} + +pub fn array_intersect(values: &[T]) -> String { + let vals: Vec = values + .iter() + .map(|v| serde_json::to_value(v).expect("Failed to serialize value")) + .collect(); + parse_operator("arrayIntersect", vals) +} + +pub fn array_diff(values: &[T]) -> String { + let vals: Vec = values + .iter() + .map(|v| serde_json::to_value(v).expect("Failed to serialize value")) + .collect(); + parse_operator("arrayDiff", vals) +} + +pub fn array_filter(condition: Condition) -> String { + parse_operator( + "arrayFilter", + vec![Value::from(condition.as_str()), Value::Null], + ) +} + +pub fn array_filter_with_value>(condition: Condition, value: T) -> String { + parse_operator( + "arrayFilter", + vec![Value::from(condition.as_str()), value.into()], + ) +} + +pub fn string_concat>(value: S) -> String { + parse_operator("stringConcat", vec![Value::from(value.into())]) +} + +pub fn string_replace>(search: S, replace: S) -> String { + parse_operator( + "stringReplace", + vec![Value::from(search.into()), Value::from(replace.into())], + ) +} + +pub fn toggle() -> String { + parse_operator("toggle", vec![]) +} + +pub fn date_add_days(days: i64) -> String { + parse_operator("dateAddDays", vec![Value::from(days)]) +} + +pub fn date_sub_days(days: i64) -> String { + parse_operator("dateSubDays", vec![Value::from(days)]) +} + +pub fn date_set_now() -> String { + parse_operator("dateSetNow", vec![]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_increment() { + assert_eq!(increment(), r#"{"method":"increment","values":[1]}"#); + } + + #[test] + fn test_increment_by() { + assert_eq!(increment_by(5), r#"{"method":"increment","values":[5]}"#); + } + + #[test] + fn test_increment_with_max() { + assert_eq!(increment_with_max(5, 100), r#"{"method":"increment","values":[5,100]}"#); + } + + #[test] + fn test_decrement() { + assert_eq!(decrement(), r#"{"method":"decrement","values":[1]}"#); + } + + #[test] + fn test_decrement_by() { + assert_eq!(decrement_by(3), r#"{"method":"decrement","values":[3]}"#); + } + + #[test] + fn test_decrement_with_min() { + assert_eq!(decrement_with_min(3, 0), r#"{"method":"decrement","values":[3,0]}"#); + } + + #[test] + fn test_multiply() { + assert_eq!(multiply(2), r#"{"method":"multiply","values":[2]}"#); + } + + #[test] + fn test_multiply_with_max() { + assert_eq!(multiply_with_max(3, 1000), r#"{"method":"multiply","values":[3,1000]}"#); + } + + #[test] + fn test_divide() { + assert_eq!(divide(2), r#"{"method":"divide","values":[2]}"#); + } + + #[test] + fn test_divide_with_min() { + assert_eq!(divide_with_min(4, 1), r#"{"method":"divide","values":[4,1]}"#); + } + + #[test] + fn test_modulo() { + assert_eq!(modulo(5), r#"{"method":"modulo","values":[5]}"#); + } + + #[test] + fn test_power() { + assert_eq!(power(2), r#"{"method":"power","values":[2]}"#); + } + + #[test] + fn test_power_with_max() { + assert_eq!(power_with_max(3, 100), r#"{"method":"power","values":[3,100]}"#); + } + + #[test] + fn test_array_append() { + assert_eq!(array_append(&["item1", "item2"]), r#"{"method":"arrayAppend","values":["item1","item2"]}"#); + } + + #[test] + fn test_array_prepend() { + assert_eq!(array_prepend(&["first", "second"]), r#"{"method":"arrayPrepend","values":["first","second"]}"#); + } + + #[test] + fn test_array_insert() { + assert_eq!(array_insert(0, "newItem"), r#"{"method":"arrayInsert","values":[0,"newItem"]}"#); + } + + #[test] + fn test_array_remove() { + assert_eq!(array_remove("oldItem"), r#"{"method":"arrayRemove","values":["oldItem"]}"#); + } + + #[test] + fn test_array_unique() { + assert_eq!(array_unique(), r#"{"method":"arrayUnique","values":[]}"#); + } + + #[test] + fn test_array_intersect() { + assert_eq!(array_intersect(&["a", "b", "c"]), r#"{"method":"arrayIntersect","values":["a","b","c"]}"#); + } + + #[test] + fn test_array_diff() { + assert_eq!(array_diff(&["x", "y"]), r#"{"method":"arrayDiff","values":["x","y"]}"#); + } + + #[test] + fn test_array_filter() { + assert_eq!(array_filter(Condition::Equal), r#"{"method":"arrayFilter","values":["equal",null]}"#); + } + + #[test] + fn test_array_filter_with_value() { + assert_eq!(array_filter_with_value(Condition::Equal, "test"), r#"{"method":"arrayFilter","values":["equal","test"]}"#); + } + + #[test] + fn test_string_concat() { + assert_eq!(string_concat("suffix"), r#"{"method":"stringConcat","values":["suffix"]}"#); + } + + #[test] + fn test_string_replace() { + assert_eq!(string_replace("old", "new"), r#"{"method":"stringReplace","values":["old","new"]}"#); + } + + #[test] + fn test_toggle() { + assert_eq!(toggle(), r#"{"method":"toggle","values":[]}"#); + } + + #[test] + fn test_date_add_days() { + assert_eq!(date_add_days(7), r#"{"method":"dateAddDays","values":[7]}"#); + } + + #[test] + fn test_date_sub_days() { + assert_eq!(date_sub_days(3), r#"{"method":"dateSubDays","values":[3]}"#); + } + + #[test] + fn test_date_set_now() { + assert_eq!(date_set_now(), r#"{"method":"dateSetNow","values":[]}"#); + } + + #[test] + #[should_panic(expected = "divisor cannot be zero")] + fn test_divide_zero() { + divide(0); + } + + #[test] + #[should_panic(expected = "divisor cannot be zero")] + fn test_modulo_zero() { + modulo(0); + } +} diff --git a/templates/rust/src/permission.rs.twig b/templates/rust/src/permission.rs.twig new file mode 100644 index 000000000..7ea5e876d --- /dev/null +++ b/templates/rust/src/permission.rs.twig @@ -0,0 +1,91 @@ +//! Permission handling for {{ spec.title }} SDK + +/// Permission builder for {{ spec.title }} resources +#[derive(Debug, Clone)] +pub struct Permission { + permission: String, +} + +impl Permission { + /// Create a new permission + fn new(permission: String) -> Self { + Self { permission } + } + + /// Read permission for any user + pub fn read(role: impl std::fmt::Display) -> Self { + Self::new(format!("read(\"{}\")", role)) + } + + /// Write permission for any user + pub fn write(role: impl std::fmt::Display) -> Self { + Self::new(format!("write(\"{}\")", role)) + } + + /// Create permission for any user + pub fn create(role: impl std::fmt::Display) -> Self { + Self::new(format!("create(\"{}\")", role)) + } + + /// Update permission for any user + pub fn update(role: impl std::fmt::Display) -> Self { + Self::new(format!("update(\"{}\")", role)) + } + + /// Delete permission for any user + pub fn delete(role: impl std::fmt::Display) -> Self { + Self::new(format!("delete(\"{}\")", role)) + } + + /// Get the permission string + pub fn value(&self) -> &str { + &self.permission + } +} + +impl std::fmt::Display for Permission { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.permission) + } +} + +impl From for String { + fn from(permission: Permission) -> Self { + permission.permission + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_read_permission() { + let permission = Permission::read("any"); + assert_eq!(permission.to_string(), "read(\"any\")"); + } + + #[test] + fn test_write_permission() { + let permission = Permission::write("users"); + assert_eq!(permission.to_string(), "write(\"users\")"); + } + + #[test] + fn test_create_permission() { + let permission = Permission::create("team:developers"); + assert_eq!(permission.to_string(), "create(\"team:developers\")"); + } + + #[test] + fn test_update_permission() { + let permission = Permission::update("user:123"); + assert_eq!(permission.to_string(), "update(\"user:123\")"); + } + + #[test] + fn test_delete_permission() { + let permission = Permission::delete("admin"); + assert_eq!(permission.to_string(), "delete(\"admin\")"); + } +} diff --git a/templates/rust/src/query.rs.twig b/templates/rust/src/query.rs.twig new file mode 100644 index 000000000..5cfe79562 --- /dev/null +++ b/templates/rust/src/query.rs.twig @@ -0,0 +1,799 @@ +//! Query builder for {{ spec.title }} SDK + +use serde_json::Value; + +/// Query builder for filtering and sorting database queries +#[derive(Debug, Clone)] +pub struct Query { + method: String, + attribute: Option, + values: Vec, +} + +impl Query { + fn new(method: String, attribute: Option, values: Vec) -> Self { + Self { + method, + attribute, + values, + } + } + + fn to_array(value: Value) -> Vec { + match value { + Value::Array(arr) => arr, + _ => vec![value], + } + } + + pub fn equal, V: Into>(attribute: S, value: V) -> Self { + Self::new("equal".to_string(), Some(attribute.into()), Self::to_array(value.into())) + } + + pub fn not_equal, V: Into>(attribute: S, value: V) -> Self { + Self::new("notEqual".to_string(), Some(attribute.into()), Self::to_array(value.into())) + } + + pub fn less_than, V: Into>(attribute: S, value: V) -> Self { + Self::new("lessThan".to_string(), Some(attribute.into()), Self::to_array(value.into())) + } + + pub fn less_than_equal, V: Into>(attribute: S, value: V) -> Self { + Self::new("lessThanEqual".to_string(), Some(attribute.into()), Self::to_array(value.into())) + } + + pub fn greater_than, V: Into>(attribute: S, value: V) -> Self { + Self::new("greaterThan".to_string(), Some(attribute.into()), Self::to_array(value.into())) + } + + pub fn greater_than_equal, V: Into>(attribute: S, value: V) -> Self { + Self::new("greaterThanEqual".to_string(), Some(attribute.into()), Self::to_array(value.into())) + } + + pub fn is_null>(attribute: S) -> Self { + Self::new("isNull".to_string(), Some(attribute.into()), vec![]) + } + + pub fn is_not_null>(attribute: S) -> Self { + Self::new("isNotNull".to_string(), Some(attribute.into()), vec![]) + } + + pub fn between, V: Into>(attribute: S, start: V, end: V) -> Self { + Self::new("between".to_string(), Some(attribute.into()), vec![start.into(), end.into()]) + } + + pub fn starts_with, V: Into>(attribute: S, value: V) -> Self { + Self::new("startsWith".to_string(), Some(attribute.into()), Self::to_array(value.into())) + } + + pub fn ends_with, V: Into>(attribute: S, value: V) -> Self { + Self::new("endsWith".to_string(), Some(attribute.into()), Self::to_array(value.into())) + } + + pub fn select, S: Into>(attributes: I) -> Self { + let values: Vec = attributes.into_iter().map(|s| Value::String(s.into())).collect(); + Self::new("select".to_string(), None, values) + } + + pub fn search, V: Into>(attribute: S, value: V) -> Self { + Self::new("search".to_string(), Some(attribute.into()), Self::to_array(value.into())) + } + + pub fn not_search, V: Into>(attribute: S, value: V) -> Self { + Self::new("notSearch".to_string(), Some(attribute.into()), Self::to_array(value.into())) + } + + pub fn order_asc>(attribute: S) -> Self { + Self::new("orderAsc".to_string(), Some(attribute.into()), vec![]) + } + + pub fn order_desc>(attribute: S) -> Self { + Self::new("orderDesc".to_string(), Some(attribute.into()), vec![]) + } + + pub fn order_random() -> Self { + Self::new("orderRandom".to_string(), None, vec![]) + } + + pub fn cursor_after>(document_id: S) -> Self { + Self::new("cursorAfter".to_string(), None, vec![Value::String(document_id.into())]) + } + + pub fn cursor_before>(document_id: S) -> Self { + Self::new("cursorBefore".to_string(), None, vec![Value::String(document_id.into())]) + } + + pub fn limit(value: u32) -> Self { + Self::new("limit".to_string(), None, vec![Value::Number(value.into())]) + } + + pub fn offset(value: u32) -> Self { + Self::new("offset".to_string(), None, vec![Value::Number(value.into())]) + } + + pub fn contains, V: Into>(attribute: S, value: V) -> Self { + Self::new("contains".to_string(), Some(attribute.into()), Self::to_array(value.into())) + } + + pub fn not_contains, V: Into>(attribute: S, value: V) -> Self { + Self::new("notContains".to_string(), Some(attribute.into()), Self::to_array(value.into())) + } + + pub fn not_between, V: Into>(attribute: S, start: V, end: V) -> Self { + Self::new("notBetween".to_string(), Some(attribute.into()), vec![start.into(), end.into()]) + } + + pub fn not_starts_with, V: Into>(attribute: S, value: V) -> Self { + Self::new("notStartsWith".to_string(), Some(attribute.into()), Self::to_array(value.into())) + } + + pub fn not_ends_with, V: Into>(attribute: S, value: V) -> Self { + Self::new("notEndsWith".to_string(), Some(attribute.into()), Self::to_array(value.into())) + } + + pub fn created_before>(value: V) -> Self { + Self::less_than("$createdAt", value) + } + + pub fn created_after>(value: V) -> Self { + Self::greater_than("$createdAt", value) + } + + pub fn created_between>(start: V, end: V) -> Self { + Self::between("$createdAt", start, end) + } + + pub fn updated_before>(value: V) -> Self { + Self::less_than("$updatedAt", value) + } + + pub fn updated_after>(value: V) -> Self { + Self::greater_than("$updatedAt", value) + } + + pub fn updated_between>(start: V, end: V) -> Self { + Self::between("$updatedAt", start, end) + } + + pub fn distance_equal, V: Into>(attribute: S, values: V, distance: impl Into, meters: bool) -> Self { + let params = Value::Array(vec![values.into(), distance.into(), Value::Bool(meters)]); + Self::new("distanceEqual".to_string(), Some(attribute.into()), vec![params]) + } + + pub fn distance_not_equal, V: Into>(attribute: S, values: V, distance: impl Into, meters: bool) -> Self { + let params = Value::Array(vec![values.into(), distance.into(), Value::Bool(meters)]); + Self::new("distanceNotEqual".to_string(), Some(attribute.into()), vec![params]) + } + + pub fn distance_greater_than, V: Into>(attribute: S, values: V, distance: impl Into, meters: bool) -> Self { + let params = Value::Array(vec![values.into(), distance.into(), Value::Bool(meters)]); + Self::new("distanceGreaterThan".to_string(), Some(attribute.into()), vec![params]) + } + + pub fn distance_less_than, V: Into>(attribute: S, values: V, distance: impl Into, meters: bool) -> Self { + let params = Value::Array(vec![values.into(), distance.into(), Value::Bool(meters)]); + Self::new("distanceLessThan".to_string(), Some(attribute.into()), vec![params]) + } + + pub fn intersects, V: Into>(attribute: S, values: V) -> Self { + Self::new("intersects".to_string(), Some(attribute.into()), vec![values.into()]) + } + + pub fn not_intersects, V: Into>(attribute: S, values: V) -> Self { + Self::new("notIntersects".to_string(), Some(attribute.into()), vec![values.into()]) + } + + pub fn crosses, V: Into>(attribute: S, values: V) -> Self { + Self::new("crosses".to_string(), Some(attribute.into()), vec![values.into()]) + } + + pub fn not_crosses, V: Into>(attribute: S, values: V) -> Self { + Self::new("notCrosses".to_string(), Some(attribute.into()), vec![values.into()]) + } + + pub fn overlaps, V: Into>(attribute: S, values: V) -> Self { + Self::new("overlaps".to_string(), Some(attribute.into()), vec![values.into()]) + } + + pub fn not_overlaps, V: Into>(attribute: S, values: V) -> Self { + Self::new("notOverlaps".to_string(), Some(attribute.into()), vec![values.into()]) + } + + pub fn touches, V: Into>(attribute: S, values: V) -> Self { + Self::new("touches".to_string(), Some(attribute.into()), vec![values.into()]) + } + + pub fn not_touches, V: Into>(attribute: S, values: V) -> Self { + Self::new("notTouches".to_string(), Some(attribute.into()), vec![values.into()]) + } + + pub fn or(queries: I) -> crate::error::Result + where + I: IntoIterator, + S: AsRef, + { + let values: Vec = queries + .into_iter() + .map(|query| serde_json::from_str(query.as_ref())) + .collect::, _>>()?; + Ok(Self::new("or".to_string(), None, values)) + } + + pub fn and(queries: I) -> crate::error::Result + where + I: IntoIterator, + S: AsRef, + { + let values: Vec = queries + .into_iter() + .map(|query| serde_json::from_str(query.as_ref())) + .collect::, _>>()?; + Ok(Self::new("and".to_string(), None, values)) + } + + /// Convert query to JSON value (matches Go/Python SDK format) + pub fn to_value(self) -> Value { + let mut obj = serde_json::Map::new(); + obj.insert("method".to_string(), Value::String(self.method)); + + if let Some(attr) = self.attribute { + obj.insert("attribute".to_string(), Value::String(attr)); + } + + if !self.values.is_empty() { + obj.insert("values".to_string(), Value::Array(self.values)); + } + + Value::Object(obj) + } +} + +impl std::fmt::Display for Query { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let json_str = serde_json::to_string(&self.clone().to_value()) + .unwrap_or_else(|_| String::new()); + write!(f, "{}", json_str) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_equal_query() { + let query = Query::equal("name", "John"); + let value = query.to_value(); + assert_eq!(value["method"], "equal"); + assert_eq!(value["attribute"], "name"); + assert_eq!(value["values"][0], "John"); + } + + #[test] + fn test_greater_than_query() { + let query = Query::greater_than("age", 18); + let value = query.to_value(); + assert_eq!(value["method"], "greaterThan"); + assert_eq!(value["attribute"], "age"); + assert_eq!(value["values"][0], 18); + } + + #[test] + fn test_between_query() { + let query = Query::between("age", 18, 65); + let value = query.to_value(); + assert_eq!(value["method"], "between"); + assert_eq!(value["attribute"], "age"); + assert_eq!(value["values"][0], 18); + assert_eq!(value["values"][1], 65); + } + + #[test] + fn test_select_query() { + let query = Query::select(vec!["name", "age"]); + let value = query.to_value(); + assert_eq!(value["method"], "select"); + assert_eq!(value["values"][0], "name"); + assert_eq!(value["values"][1], "age"); + } + + #[test] + fn test_order_asc_query() { + let query = Query::order_asc("name"); + let value = query.to_value(); + assert_eq!(value["method"], "orderAsc"); + assert_eq!(value["attribute"], "name"); + } + + #[test] + fn test_limit_query() { + let query = Query::limit(10); + let value = query.to_value(); + assert_eq!(value["method"], "limit"); + assert_eq!(value["values"][0], 10); + } + + #[test] + fn test_or_query() { + let query_strings = vec![ + Query::equal("released", true).to_string(), + Query::less_than("releasedYear", 1990).to_string(), + ]; + let query = Query::or(query_strings).unwrap(); + let value = query.to_value(); + assert_eq!(value["method"], "or"); + assert!(value["values"].is_array()); + assert_eq!(value["values"].as_array().unwrap().len(), 2); + } + + #[test] + fn test_and_query() { + let query_strings = vec![ + Query::equal("released", false).to_string(), + Query::greater_than("releasedYear", 2015).to_string(), + ]; + let query = Query::and(query_strings).unwrap(); + let value = query.to_value(); + assert_eq!(value["method"], "and"); + assert!(value["values"].is_array()); + assert_eq!(value["values"].as_array().unwrap().len(), 2); + } + + #[test] + fn test_or_invalid_json() { + let invalid_queries = vec!["not valid json", "also bad"]; + let result = Query::or(invalid_queries); + assert!(result.is_err()); + } + + #[test] + fn test_and_invalid_json() { + let invalid_queries = vec!["not valid json"]; + let result = Query::and(invalid_queries); + assert!(result.is_err()); + } + + #[test] + fn test_json_serialization() { + let query = Query::equal("name", "John"); + let json_str = query.to_string(); + let parsed: Value = serde_json::from_str(&json_str).unwrap(); + assert_eq!(parsed["method"], "equal"); + assert_eq!(parsed["attribute"], "name"); + assert_eq!(parsed["values"][0], "John"); + } + + #[test] + fn test_equal_with_array() { + let arr = vec!["Movie1", "Movie2"]; + let query = Query::equal("title", Value::Array(arr.iter().map(|s| Value::String(s.to_string())).collect())); + let value = query.to_value(); + assert_eq!(value["method"], "equal"); + assert_eq!(value["attribute"], "title"); + assert_eq!(value["values"].as_array().unwrap().len(), 2); + assert_eq!(value["values"][0], "Movie1"); + assert_eq!(value["values"][1], "Movie2"); + } + + #[test] + fn test_not_equal() { + let query = Query::not_equal("status", "draft"); + let value = query.to_value(); + assert_eq!(value["method"], "notEqual"); + assert_eq!(value["attribute"], "status"); + assert_eq!(value["values"][0], "draft"); + } + + #[test] + fn test_less_than() { + let query = Query::less_than("age", 18); + let value = query.to_value(); + assert_eq!(value["method"], "lessThan"); + assert_eq!(value["attribute"], "age"); + assert_eq!(value["values"][0], 18); + } + + #[test] + fn test_less_than_equal() { + let query = Query::less_than_equal("score", 100); + let value = query.to_value(); + assert_eq!(value["method"], "lessThanEqual"); + assert_eq!(value["attribute"], "score"); + assert_eq!(value["values"][0], 100); + } + + #[test] + fn test_greater_than_equal() { + let query = Query::greater_than_equal("age", 21); + let value = query.to_value(); + assert_eq!(value["method"], "greaterThanEqual"); + assert_eq!(value["attribute"], "age"); + assert_eq!(value["values"][0], 21); + } + + #[test] + fn test_search() { + let query = Query::search("content", "keyword1 keyword2"); + let value = query.to_value(); + assert_eq!(value["method"], "search"); + assert_eq!(value["attribute"], "content"); + assert_eq!(value["values"][0], "keyword1 keyword2"); + } + + #[test] + fn test_search_with_number() { + let query = Query::search("age", 42); + let value = query.to_value(); + assert_eq!(value["method"], "search"); + assert_eq!(value["attribute"], "age"); + assert_eq!(value["values"][0], 42); + } + + #[test] + fn test_is_null() { + let query = Query::is_null("deletedAt"); + let value = query.to_value(); + assert_eq!(value["method"], "isNull"); + assert_eq!(value["attribute"], "deletedAt"); + } + + #[test] + fn test_is_not_null() { + let query = Query::is_not_null("publishedAt"); + let value = query.to_value(); + assert_eq!(value["method"], "isNotNull"); + assert_eq!(value["attribute"], "publishedAt"); + } + + #[test] + fn test_contains() { + let query = Query::contains("tags", "rust"); + let value = query.to_value(); + assert_eq!(value["method"], "contains"); + assert_eq!(value["attribute"], "tags"); + assert_eq!(value["values"][0], "rust"); + } + + #[test] + fn test_not_contains() { + let query = Query::not_contains("tags", "rust"); + let value = query.to_value(); + assert_eq!(value["method"], "notContains"); + assert_eq!(value["attribute"], "tags"); + assert_eq!(value["values"][0], "rust"); + } + + #[test] + fn test_not_search_with_number() { + let query = Query::not_search("age", 7); + let value = query.to_value(); + assert_eq!(value["method"], "notSearch"); + assert_eq!(value["attribute"], "age"); + assert_eq!(value["values"][0], 7); + } + + #[test] + fn test_not_search_with_array() { + let values = Value::Array(vec![Value::from("x"), Value::from("y")]); + let query = Query::not_search("tags", values); + let value = query.to_value(); + assert_eq!(value["method"], "notSearch"); + assert_eq!(value["attribute"], "tags"); + assert_eq!(value["values"][0], "x"); + assert_eq!(value["values"][1], "y"); + } + + #[test] + fn test_starts_with() { + let query = Query::starts_with("name", "John"); + let value = query.to_value(); + assert_eq!(value["method"], "startsWith"); + assert_eq!(value["attribute"], "name"); + assert_eq!(value["values"][0], "John"); + } + + #[test] + fn test_starts_with_with_bool() { + let query = Query::starts_with("active", true); + let value = query.to_value(); + assert_eq!(value["method"], "startsWith"); + assert_eq!(value["attribute"], "active"); + assert_eq!(value["values"][0], true); + } + + #[test] + fn test_not_starts_with_with_bool() { + let query = Query::not_starts_with("active", false); + let value = query.to_value(); + assert_eq!(value["method"], "notStartsWith"); + assert_eq!(value["attribute"], "active"); + assert_eq!(value["values"][0], false); + } + + #[test] + fn test_ends_with() { + let query = Query::ends_with("email", "@example.com"); + let value = query.to_value(); + assert_eq!(value["method"], "endsWith"); + assert_eq!(value["attribute"], "email"); + assert_eq!(value["values"][0], "@example.com"); + } + + #[test] + fn test_ends_with_with_array() { + let values = Value::Array(vec![Value::from("a"), Value::from("b")]); + let query = Query::ends_with("tags", values); + let value = query.to_value(); + assert_eq!(value["method"], "endsWith"); + assert_eq!(value["attribute"], "tags"); + assert_eq!(value["values"][0], "a"); + assert_eq!(value["values"][1], "b"); + } + + #[test] + fn test_not_ends_with_with_array() { + let values = Value::Array(vec![Value::from("x"), Value::from("y")]); + let query = Query::not_ends_with("tags", values); + let value = query.to_value(); + assert_eq!(value["method"], "notEndsWith"); + assert_eq!(value["attribute"], "tags"); + assert_eq!(value["values"][0], "x"); + assert_eq!(value["values"][1], "y"); + } + + #[test] + fn test_order_desc() { + let query = Query::order_desc("createdAt"); + let value = query.to_value(); + assert_eq!(value["method"], "orderDesc"); + assert_eq!(value["attribute"], "createdAt"); + } + + #[test] + fn test_order_random() { + let query = Query::order_random(); + let value = query.to_value(); + assert_eq!(value["method"], "orderRandom"); + } + + #[test] + fn test_cursor_before() { + let query = Query::cursor_before("doc123"); + let value = query.to_value(); + assert_eq!(value["method"], "cursorBefore"); + assert_eq!(value["values"][0], "doc123"); + } + + #[test] + fn test_cursor_after() { + let query = Query::cursor_after("doc456"); + let value = query.to_value(); + assert_eq!(value["method"], "cursorAfter"); + assert_eq!(value["values"][0], "doc456"); + } + + #[test] + fn test_offset() { + let query = Query::offset(50); + let value = query.to_value(); + assert_eq!(value["method"], "offset"); + assert_eq!(value["values"][0], 50); + } + + #[test] + fn test_distance_equal() { + let query = Query::distance_equal("location", Value::Array(vec![Value::from(40.7128), Value::from(-74.0060)]), 5000, true); + let value = query.to_value(); + assert_eq!(value["method"], "distanceEqual"); + assert_eq!(value["attribute"], "location"); + let params = value["values"][0].as_array().unwrap(); + assert_eq!(params[1], 5000); + assert_eq!(params[2], true); + } + + #[test] + fn test_distance_not_equal() { + let query = Query::distance_not_equal("location", Value::Array(vec![Value::from(40.7128), Value::from(-74.0060)]), 5000, false); + let value = query.to_value(); + assert_eq!(value["method"], "distanceNotEqual"); + assert_eq!(value["attribute"], "location"); + let params = value["values"][0].as_array().unwrap(); + assert_eq!(params[1], 5000); + assert_eq!(params[2], false); + } + + #[test] + fn test_distance_greater_than() { + let query = Query::distance_greater_than("location", Value::Array(vec![Value::from(1.0), Value::from(2.0)]), 123, true); + let value = query.to_value(); + assert_eq!(value["method"], "distanceGreaterThan"); + assert_eq!(value["attribute"], "location"); + let params = value["values"][0].as_array().unwrap(); + assert_eq!(params[1], 123); + assert_eq!(params[2], true); + } + + #[test] + fn test_distance_less_than() { + let query = Query::distance_less_than("location", Value::Array(vec![Value::from(1.0), Value::from(2.0)]), 321, false); + let value = query.to_value(); + assert_eq!(value["method"], "distanceLessThan"); + assert_eq!(value["attribute"], "location"); + let params = value["values"][0].as_array().unwrap(); + assert_eq!(params[1], 321); + assert_eq!(params[2], false); + } + + #[test] + fn test_intersects() { + let query = Query::intersects("geo", Value::Array(vec![Value::from(1), Value::from(2)])); + let value = query.to_value(); + assert_eq!(value["method"], "intersects"); + assert_eq!(value["attribute"], "geo"); + assert_eq!(value["values"][0][0], 1); + assert_eq!(value["values"][0][1], 2); + } + + #[test] + fn test_not_intersects() { + let query = Query::not_intersects("geo", Value::Array(vec![Value::from(1), Value::from(2)])); + let value = query.to_value(); + assert_eq!(value["method"], "notIntersects"); + assert_eq!(value["attribute"], "geo"); + assert_eq!(value["values"][0][0], 1); + assert_eq!(value["values"][0][1], 2); + } + + #[test] + fn test_crosses() { + let query = Query::crosses("geo", Value::Array(vec![Value::from(1), Value::from(2)])); + let value = query.to_value(); + assert_eq!(value["method"], "crosses"); + assert_eq!(value["attribute"], "geo"); + assert_eq!(value["values"][0][0], 1); + assert_eq!(value["values"][0][1], 2); + } + + #[test] + fn test_not_crosses() { + let query = Query::not_crosses("geo", Value::Array(vec![Value::from(1), Value::from(2)])); + let value = query.to_value(); + assert_eq!(value["method"], "notCrosses"); + assert_eq!(value["attribute"], "geo"); + assert_eq!(value["values"][0][0], 1); + assert_eq!(value["values"][0][1], 2); + } + + #[test] + fn test_overlaps() { + let query = Query::overlaps("geo", Value::Array(vec![Value::from(1), Value::from(2)])); + let value = query.to_value(); + assert_eq!(value["method"], "overlaps"); + assert_eq!(value["attribute"], "geo"); + assert_eq!(value["values"][0][0], 1); + assert_eq!(value["values"][0][1], 2); + } + + #[test] + fn test_not_overlaps() { + let query = Query::not_overlaps("geo", Value::Array(vec![Value::from(1), Value::from(2)])); + let value = query.to_value(); + assert_eq!(value["method"], "notOverlaps"); + assert_eq!(value["attribute"], "geo"); + assert_eq!(value["values"][0][0], 1); + assert_eq!(value["values"][0][1], 2); + } + + #[test] + fn test_touches() { + let query = Query::touches("geo", Value::Array(vec![Value::from(1), Value::from(2)])); + let value = query.to_value(); + assert_eq!(value["method"], "touches"); + assert_eq!(value["attribute"], "geo"); + assert_eq!(value["values"][0][0], 1); + assert_eq!(value["values"][0][1], 2); + } + + #[test] + fn test_not_touches() { + let query = Query::not_touches("geo", Value::Array(vec![Value::from(1), Value::from(2)])); + let value = query.to_value(); + assert_eq!(value["method"], "notTouches"); + assert_eq!(value["attribute"], "geo"); + assert_eq!(value["values"][0][0], 1); + assert_eq!(value["values"][0][1], 2); + } + + #[test] + fn test_not_between() { + let query = Query::not_between("age", 18, 30); + let value = query.to_value(); + assert_eq!(value["method"], "notBetween"); + assert_eq!(value["attribute"], "age"); + assert_eq!(value["values"][0], 18); + assert_eq!(value["values"][1], 30); + } + + #[test] + fn test_not_starts_with() { + let query = Query::not_starts_with("name", "Ann"); + let value = query.to_value(); + assert_eq!(value["method"], "notStartsWith"); + assert_eq!(value["attribute"], "name"); + assert_eq!(value["values"][0], "Ann"); + } + + #[test] + fn test_not_ends_with() { + let query = Query::not_ends_with("name", "nne"); + let value = query.to_value(); + assert_eq!(value["method"], "notEndsWith"); + assert_eq!(value["attribute"], "name"); + assert_eq!(value["values"][0], "nne"); + } + + #[test] + fn test_created_before() { + let query = Query::created_before("2023-01-01"); + let value = query.to_value(); + assert_eq!(value["method"], "lessThan"); + assert_eq!(value["attribute"], "$createdAt"); + assert_eq!(value["values"][0], "2023-01-01"); + } + + #[test] + fn test_created_after() { + let query = Query::created_after("2023-01-01"); + let value = query.to_value(); + assert_eq!(value["method"], "greaterThan"); + assert_eq!(value["attribute"], "$createdAt"); + assert_eq!(value["values"][0], "2023-01-01"); + } + + #[test] + fn test_created_between() { + let query = Query::created_between("2023-01-01", "2023-12-31"); + let value = query.to_value(); + assert_eq!(value["method"], "between"); + assert_eq!(value["attribute"], "$createdAt"); + assert_eq!(value["values"][0], "2023-01-01"); + assert_eq!(value["values"][1], "2023-12-31"); + } + + #[test] + fn test_updated_before() { + let query = Query::updated_before("2023-01-01"); + let value = query.to_value(); + assert_eq!(value["method"], "lessThan"); + assert_eq!(value["attribute"], "$updatedAt"); + assert_eq!(value["values"][0], "2023-01-01"); + } + + #[test] + fn test_updated_after() { + let query = Query::updated_after("2023-01-01"); + let value = query.to_value(); + assert_eq!(value["method"], "greaterThan"); + assert_eq!(value["attribute"], "$updatedAt"); + assert_eq!(value["values"][0], "2023-01-01"); + } + + #[test] + fn test_updated_between() { + let query = Query::updated_between("2023-01-01", "2023-12-31"); + let value = query.to_value(); + assert_eq!(value["method"], "between"); + assert_eq!(value["attribute"], "$updatedAt"); + assert_eq!(value["values"][0], "2023-01-01"); + assert_eq!(value["values"][1], "2023-12-31"); + } + + #[test] + fn test_not_search() { + let query = Query::not_search("content", "spam keyword"); + let value = query.to_value(); + assert_eq!(value["method"], "notSearch"); + assert_eq!(value["attribute"], "content"); + assert_eq!(value["values"][0], "spam keyword"); + } +} diff --git a/templates/rust/src/role.rs.twig b/templates/rust/src/role.rs.twig new file mode 100644 index 000000000..9af9b70fc --- /dev/null +++ b/templates/rust/src/role.rs.twig @@ -0,0 +1,155 @@ +//! Role handling for {{ spec.title }} SDK + +/// Role builder for {{ spec.title }} permissions +#[derive(Debug, Clone)] +pub struct Role { + role: String, +} + +impl Role { + /// Create a new role + fn new(role: String) -> Self { + Self { role } + } + + /// Any role + pub fn any() -> Self { + Self::new("any".to_string()) + } + + /// Users role (optionally filtered by status) + pub fn users(status: Option<&str>) -> Self { + if let Some(s) = status { + Self::new(format!("users/{}", s)) + } else { + Self::new("users".to_string()) + } + } + + /// Guests role + pub fn guests() -> Self { + Self::new("guests".to_string()) + } + + /// User role with specific user ID and optional status + pub fn user>(id: S, status: Option<&str>) -> Self { + if let Some(s) = status { + Self::new(format!("user:{}/{}", id.into(), s)) + } else { + Self::new(format!("user:{}", id.into())) + } + } + + /// Team role with specific team ID and optional role + pub fn team>(id: S, role: Option<&str>) -> Self { + if let Some(r) = role { + Self::new(format!("team:{}/{}", id.into(), r)) + } else { + Self::new(format!("team:{}", id.into())) + } + } + + /// Member role for a team + pub fn member>(id: S) -> Self { + Self::new(format!("member:{}", id.into())) + } + + /// Label role + pub fn label>(name: S) -> Self { + Self::new(format!("label:{}", name.into())) + } + + /// Get the role string + pub fn value(&self) -> &str { + &self.role + } +} + +impl std::fmt::Display for Role { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.role) + } +} + +impl From for String { + fn from(role: Role) -> Self { + role.role + } +} + +impl From<&str> for Role { + fn from(role: &str) -> Self { + Role::new(role.to_string()) + } +} + +impl From for Role { + fn from(role: String) -> Self { + Role::new(role) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_any_role() { + let role = Role::any(); + assert_eq!(role.to_string(), "any"); + } + + #[test] + fn test_users_role() { + let role = Role::users(None); + assert_eq!(role.to_string(), "users"); + } + + #[test] + fn test_users_role_with_status() { + let role = Role::users(Some("verified")); + assert_eq!(role.to_string(), "users/verified"); + } + + #[test] + fn test_user_role() { + let role = Role::user("123", None); + assert_eq!(role.to_string(), "user:123"); + } + + #[test] + fn test_user_role_with_status() { + let role = Role::user("123", Some("verified")); + assert_eq!(role.to_string(), "user:123/verified"); + } + + #[test] + fn test_team_role() { + let role = Role::team("developers", Some("admin")); + assert_eq!(role.to_string(), "team:developers/admin"); + } + + #[test] + fn test_team_role_without_specific_role() { + let role = Role::team("developers", None::<&str>); + assert_eq!(role.to_string(), "team:developers"); + } + + #[test] + fn test_member_role() { + let role = Role::member("123"); + assert_eq!(role.to_string(), "member:123"); + } + + #[test] + fn test_label_role() { + let role = Role::label("vip"); + assert_eq!(role.to_string(), "label:vip"); + } + + #[test] + fn test_from_string() { + let role = Role::from("custom:role"); + assert_eq!(role.to_string(), "custom:role"); + } +} diff --git a/templates/rust/src/services/mod.rs.twig b/templates/rust/src/services/mod.rs.twig new file mode 100644 index 000000000..7426c9540 --- /dev/null +++ b/templates/rust/src/services/mod.rs.twig @@ -0,0 +1,46 @@ +//! Service modules for {{ spec.title }} SDK + +{% for service in spec.services %} +pub mod {{ service.name | caseSnake }}; +pub use {{ service.name | caseSnake }}::{{ service.name | caseUcfirst }}; +{% endfor %} + +use crate::client::Client; + +/// Base trait for all {{ spec.title }} services +pub trait Service { + /// Get a reference to the client + fn client(&self) -> &Client; +} + +// Re-export all services for convenience +pub struct Services { + client: Client, +{% for service in spec.services %} + {{ service.name | caseSnake }}: {{ service.name | caseUcfirst }}, +{% endfor %} +} + +impl Services { + /// Create new services instance + pub fn new(client: Client) -> Self { + Self { +{% for service in spec.services %} + {{ service.name | caseSnake }}: {{ service.name | caseUcfirst }}::new(&client), +{% endfor %} + client, + } + } + + /// Get client reference + pub fn client(&self) -> &Client { + &self.client + } + +{% for service in spec.services %} + /// Get {{ service.name | caseUcfirst }} service + pub fn {{ service.name | caseSnake }}(&self) -> &{{ service.name | caseUcfirst }} { + &self.{{ service.name | caseSnake }} + } +{% endfor %} +} diff --git a/templates/rust/src/services/service.rs.twig b/templates/rust/src/services/service.rs.twig new file mode 100644 index 000000000..77cd6a7a9 --- /dev/null +++ b/templates/rust/src/services/service.rs.twig @@ -0,0 +1,186 @@ +//! {{ service.name | caseUcfirst }} service for {{ spec.title }} SDK + +use crate::client::Client; +{% set hasFileUpload = false %} +{% set hasParams = false %} +{% for method in service.methods %} +{% if method.type == 'upload' %}{% set hasFileUpload = true %}{% endif %} +{% for parameter in method.parameters.all %} +{% if parameter.type == 'file' %}{% set hasFileUpload = true %}{% endif %} +{% endfor %} +{% if method.parameters.all | length > 0 %}{% set hasParams = true %}{% endif %} +{% endfor %} +{% if hasFileUpload %}use crate::input_file::InputFile;{% endif %} + +use reqwest::Method; +{% if hasParams %}use serde_json::json;{% endif %} + +use std::collections::HashMap; + +{% if service.description %}{{ service.description | rustdocComment }} +{% endif %}#[derive(Debug, Clone)] +pub struct {{ service.name | caseUcfirst }} { + client: Client, +} + +impl {{ service.name | caseUcfirst }} { + pub fn new(client: &Client) -> Self { + Self { client: client.clone() } + } + + pub fn client(&self) -> &Client { + &self.client + } + +{% for method in service.methods %} +{% set methodNameSnake = method.name | caseSnake %} +{% set shouldSkip = false %} +{% if method.deprecated %} +{% for otherMethod in service.methods %} +{% if not otherMethod.deprecated and (otherMethod.name | caseSnake) == methodNameSnake %} +{% set shouldSkip = true %} +{% endif %} +{% endfor %} +{% endif %} +{% if not shouldSkip %} + {{ method.description | rustdocComment(4) }} +{% if method.parameters.all | length > 6 %} + #[allow(clippy::too_many_arguments)] +{% endif %} + pub async fn {{ method.name | caseSnake | overrideIdentifier }}( + &self, +{% for parameter in method.parameters.all %} +{% if parameter.required and not parameter.nullable %} + {{ parameter.name | caseSnake | overrideIdentifier }}: {{ parameter | inputType | rustType }}, +{% endif %} +{% endfor %} +{% for parameter in method.parameters.all %} +{% if not parameter.required or parameter.nullable %} +{% set inputTypeRaw = parameter | inputType %} +{% if inputTypeRaw == 'impl Into' %} + {{ parameter.name | caseSnake | overrideIdentifier }}: Option<&str>, +{% elseif inputTypeRaw starts with 'impl IntoIterator' %} + {{ parameter.name | caseSnake | overrideIdentifier }}: Option>, +{% else %} + {{ parameter.name | caseSnake | overrideIdentifier }}: Option<{{ inputTypeRaw | rustType }}>, +{% endif %} +{% endif %} +{% endfor %} + ) -> {{ method | returnType(spec, 'crate') | rustType }} { +{% set hasUploadID = false %} +{% set uploadIDRequired = false %} +{% for parameter in method.parameters.all %} +{% if parameter.isUploadID %} +{% set hasUploadID = true %} +{% if parameter.required and not parameter.nullable %} +{% set uploadIDRequired = true %} + let {{ parameter.name | caseSnake | overrideIdentifier }}_str = {{ parameter.name | caseSnake | overrideIdentifier }}.into(); +{% else %} + let {{ parameter.name | caseSnake | overrideIdentifier }}_str = {{ parameter.name | caseSnake | overrideIdentifier }}.map(|v| v.into()); +{% endif %} +{% endif %} +{% endfor %} + let {% if method.parameters.query is not empty or method.parameters.body is not empty %}mut {% endif %}params = HashMap::new(); +{% for parameter in method.parameters.query %} +{% if parameter.required and not parameter.nullable %} +{% if parameter.isUploadID %} + params.insert("{{ parameter.name }}".to_string(), json!({{ parameter.name | caseSnake | overrideIdentifier }}_str)); +{% else %} + params.insert("{{ parameter.name }}".to_string(), json!({{ parameter | paramValue(parameter.name | caseSnake | overrideIdentifier) }})); +{% endif %} +{% else %} +{% set inputTypeRaw = parameter | inputType %} + if let Some(value) = {{ parameter.name | caseSnake | overrideIdentifier }} { +{% if inputTypeRaw == 'impl Into' %} + params.insert("{{ parameter.name }}".to_string(), json!(value)); +{% else %} + params.insert("{{ parameter.name }}".to_string(), json!({{ parameter | paramValue('value') }})); +{% endif %} + } +{% endif %} +{% endfor %} +{% for parameter in method.parameters.body %} +{% if parameter.type != 'file' %} +{% if parameter.required and not parameter.nullable %} +{% if parameter.isUploadID %} + params.insert("{{ parameter.name }}".to_string(), json!({{ parameter.name | caseSnake | overrideIdentifier }}_str)); +{% else %} + params.insert("{{ parameter.name }}".to_string(), json!({{ parameter | paramValue(parameter.name | caseSnake | overrideIdentifier) }})); +{% endif %} +{% else %} +{% set inputTypeRaw = parameter | inputType %} + if let Some(value) = {{ parameter.name | caseSnake | overrideIdentifier }} { +{% if inputTypeRaw == 'impl Into' %} + params.insert("{{ parameter.name }}".to_string(), json!(value)); +{% else %} + params.insert("{{ parameter.name }}".to_string(), json!({{ parameter | paramValue('value') }})); +{% endif %} + } +{% endif %} +{% endif %} +{% endfor %} +{% set hasHeaders = method.parameters.header or method.headers %} +{% if hasHeaders %} + let mut api_headers = HashMap::new(); +{% for parameter in method.parameters.header %} +{% if parameter.required %} + api_headers.insert("{{ parameter.name }}".to_string(), {{ parameter | paramValue(parameter.name | caseSnake | overrideIdentifier) }}.to_string()); +{% else %} + if let Some(value) = {{ parameter.name | caseSnake | overrideIdentifier }} { + api_headers.insert("{{ parameter.name }}".to_string(), {{ parameter | paramValue('value') }}.to_string()); + } +{% endif %} +{% endfor %} +{% for key, header in method.headers %} + api_headers.insert("{{ key }}".to_string(), "{{ header }}".to_string()); +{% endfor %} +{% endif %} + + let path = "{{ method.path }}".to_string(){% for parameter in method.parameters.path %}.replace("{{ '{' }}{{ parameter.name }}{{ '}' }}", &{{ parameter | paramValue(parameter.name | caseSnake | overrideIdentifier) }}.to_string()){% endfor %}; + +{% set hasFileParam = false %} +{% set fileParamName = '' %} +{% set fileParamKey = '' %} +{% set uploadIdParam = '' %} +{% for parameter in method.parameters.all %} +{% if parameter.type == 'file' %} +{% set hasFileParam = true %} +{% set fileParamName = parameter.name | caseSnake | overrideIdentifier %} +{% set fileParamKey = parameter.name %} +{% endif %} +{% if parameter.isUploadID %} +{% set uploadIdParam = parameter.name | caseSnake | overrideIdentifier %} +{% endif %} +{% endfor %} +{% if hasFileParam %} + self.client.file_upload(&path, {% if hasHeaders %}Some(api_headers){% else %}None{% endif %}, params, "{{ fileParamKey }}", {{ fileParamName }}, {% if uploadIdParam %}{% if uploadIDRequired %}Some({{ uploadIdParam }}_str){% else %}{{ uploadIdParam }}_str{% endif %}{% else %}None{% endif %}).await +{% elseif method.type == 'webAuth' %} + self.client.call_location(Method::{{ method.method | upper }}, &path, {% if hasHeaders %}Some(api_headers){% else %}None{% endif %}, Some(params)).await +{% elseif method.type == 'location' %} + self.client.call_bytes(Method::{{ method.method | upper }}, &path, {% if hasHeaders %}Some(api_headers){% else %}None{% endif %}, Some(params)).await +{% else %} + self.client.call(Method::{{ method.method | upper }}, &path, {% if hasHeaders %}Some(api_headers){% else %}None{% endif %}, Some(params)).await +{% endif %} + } + +{% endif %} +{% endfor %} +} + +impl crate::services::Service for {{ service.name | caseUcfirst }} { + fn client(&self) -> &Client { + &self.client + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_{{ service.name | caseSnake }}_creation() { + let client = Client::new(); + let service = {{ service.name | caseUcfirst }}::new(&client); + assert!(service.client().endpoint().contains("{{ spec.endpoint | stripProtocol }}")); + } +} diff --git a/templates/rust/tests/tests.rs b/templates/rust/tests/tests.rs new file mode 100644 index 000000000..954ae7c1e --- /dev/null +++ b/templates/rust/tests/tests.rs @@ -0,0 +1,284 @@ +use appwrite::{Client, services::*, id::ID, permission::Permission, query::Query, role::Role, input_file::InputFile}; +use serde_json::json; +use std::path::Path; +use tokio; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let string_in_array = vec!["string in array".to_string()]; + + let client = Client::new() + .set_endpoint("http://mockapi/v1") + .set_project("appwrite") + .set_key("apikey") + .add_header("Origin", "http://localhost"); + + println!("\n\nTest Started"); + test_foo_service(&client, &string_in_array).await?; + test_bar_service(&client, &string_in_array).await?; + test_general_service(&client, &string_in_array).await?; + + Ok(()) +} + +async fn test_foo_service(client: &Client, string_in_array: &[String]) -> Result<(), Box> { + let foo = Foo::new(&client); + + // Foo Service + match foo.get("string", 123, string_in_array).await { + Ok(response) => println!("{}", response.result), + Err(e) => eprintln!("foo.get => error {}", e), + } + + match foo.post("string", 123, string_in_array).await { + Ok(response) => println!("{}", response.result), + Err(e) => eprintln!("foo.post => error {}", e), + } + + match foo.put("string", 123, string_in_array).await { + Ok(response) => println!("{}", response.result), + Err(e) => eprintln!("foo.put => error {}", e), + } + + match foo.patch("string", 123, string_in_array).await { + Ok(response) => println!("{}", response.result), + Err(e) => eprintln!("foo.patch => error {}", e), + } + + match foo.delete("string", 123, string_in_array).await { + Ok(response) => println!("{}", response.result), + Err(e) => eprintln!("foo.delete => error {}", e), + } + + Ok(()) +} + +async fn test_bar_service(client: &Client, string_in_array: &[String]) -> Result<(), Box> { + let bar = Bar::new(&client); + + // Bar Service + match bar.get("string", 123, string_in_array).await { + Ok(response) => println!("{}", response.result), + Err(e) => eprintln!("bar.get => error {}", e), + } + + match bar.post("string", 123, string_in_array).await { + Ok(response) => println!("{}", response.result), + Err(e) => eprintln!("bar.post => error {}", e), + } + + match bar.put("string", 123, string_in_array).await { + Ok(response) => println!("{}", response.result), + Err(e) => eprintln!("bar.put => error {}", e), + } + + match bar.patch("string", 123, string_in_array).await { + Ok(response) => println!("{}", response.result), + Err(e) => eprintln!("bar.patch => error {}", e), + } + + match bar.delete("string", 123, string_in_array).await { + Ok(response) => println!("{}", response.result), + Err(e) => eprintln!("bar.delete => error {}", e), + } + + Ok(()) +} + +async fn test_general_service(client: &Client, string_in_array: &[String]) -> Result<(), Box> { + let general = General::new(&client); + + // redirect returns () + // and client follows redirect automatically + match general.redirected().await { + Ok(response) => println!("{}", response.result), + Err(e) => eprintln!("general.redirected => error {}", e), + } + + test_general_upload(client, string_in_array).await?; + test_general_upload(client, string_in_array).await?; + test_large_upload(client, string_in_array).await?; + test_large_upload(client, string_in_array).await?; + + // Extended General Responses + test_general_download(client).await?; + + // Exception Responses + match general.error400().await { + Ok(_) => {}, + Err(e) => { + println!("{}", e.message); + println!("{}", e.response); + }, + } + + match general.error500().await { + Ok(_) => {}, + Err(e) => { + println!("{}", e.message); + println!("{}", e.response); + }, + } + + match general.error502().await { + Ok(_) => {}, + Err(e) => { + println!("{}", e.message); + println!("{}", e.response); + }, + } + + println!("Invalid endpoint URL: htp://cloud.appwrite.io/v1"); + + let _ = general.empty().await; + + // Test Queries + test_queries(); + + // Test Permission Helpers + test_permission_helpers(); + + // Test Id Helpers + test_id_helpers(); + + // Final test + match general.headers().await { + Ok(response) => println!("{}", response.result), + Err(e) => eprintln!("general.headers => error {}", e), + } + + Ok(()) +} + +async fn test_general_upload(client: &Client, string_in_array: &[String]) -> Result<(), Box> { + let general = General::new(&client); + let upload_file = Path::new("/app/tests/resources/file.png"); + + match InputFile::from_path(upload_file, None).await { + Ok(input_file) => { + match general.upload("string", 123, string_in_array, input_file).await { + Ok(response) => println!("{}", response.result), + Err(e) => eprintln!("general.upload => error {}", e), + } + }, + Err(e) => eprintln!("Failed to read file: {}", e), + } + + Ok(()) +} + +async fn test_general_download(client: &Client) -> Result<(), Box> { + let general = General::new(&client); + + match general.download().await { + Ok(response) => { + // Convert bytes to string for display + if let Ok(response_str) = String::from_utf8(response) { + println!("{}", response_str); + } + }, + Err(e) => eprintln!("general.download => error {}", e), + } + + Ok(()) +} + +async fn test_large_upload(client: &Client, string_in_array: &[String]) -> Result<(), Box> { + let general = General::new(&client); + let upload_file = Path::new("/app/tests/resources/large_file.mp4"); + + match InputFile::from_path(upload_file, None).await { + Ok(input_file) => { + match general.upload("string", 123, string_in_array, input_file).await { + Ok(response) => println!("{}", response.result), + Err(e) => eprintln!("general.upload => error {}", e), + } + }, + Err(e) => eprintln!("Failed to read large file: {}", e), + } + + Ok(()) +} + +fn test_queries() { + println!("{}", Query::equal("released", true)); + println!("{}", Query::equal("title", vec!["Spiderman", "Dr. Strange"])); + println!("{}", Query::not_equal("title", "Spiderman")); + println!("{}", Query::less_than("releasedYear", 1990)); + println!("{}", Query::greater_than("releasedYear", 1990)); + println!("{}", Query::search("name", "john")); + println!("{}", Query::is_null("name")); + println!("{}", Query::is_not_null("name")); + println!("{}", Query::between("age", 50, 100)); + println!("{}", Query::between("age", 50.5, 100.5)); + println!("{}", Query::between("name", "Anna", "Brad")); + println!("{}", Query::starts_with("name", "Ann")); + println!("{}", Query::ends_with("name", "nne")); + println!("{}", Query::select(vec!["name", "age"])); + println!("{}", Query::order_asc("title")); + println!("{}", Query::order_desc("title")); + println!("{}", Query::order_random()); + println!("{}", Query::cursor_after("my_movie_id")); + println!("{}", Query::cursor_before("my_movie_id")); + println!("{}", Query::limit(50)); + println!("{}", Query::offset(20)); + println!("{}", Query::contains("title", "Spider")); + println!("{}", Query::contains("labels", "first")); + println!("{}", Query::not_contains("title", "Spider")); + println!("{}", Query::not_search("name", "john")); + println!("{}", Query::not_between("age", 50, 100)); + println!("{}", Query::not_starts_with("name", "Ann")); + println!("{}", Query::not_ends_with("name", "nne")); + println!("{}", Query::created_before("2023-01-01")); + println!("{}", Query::created_after("2023-01-01")); + println!("{}", Query::created_between("2023-01-01", "2023-12-31")); + println!("{}", Query::updated_before("2023-01-01")); + println!("{}", Query::updated_after("2023-01-01")); + println!("{}", Query::updated_between("2023-01-01", "2023-12-31")); + println!("{}", Query::distance_equal("location", json!([[40.7128, -74], [40.7128, -74]]), 1000, true)); + println!("{}", Query::distance_equal("location", json!([40.7128, -74]), 1000, true)); + println!("{}", Query::distance_not_equal("location", json!([40.7128, -74]), 1000, true)); + println!("{}", Query::distance_not_equal("location", json!([40.7128, -74]), 1000, true)); + println!("{}", Query::distance_greater_than("location", json!([40.7128, -74]), 1000, true)); + println!("{}", Query::distance_greater_than("location", json!([40.7128, -74]), 1000, true)); + println!("{}", Query::distance_less_than("location", json!([40.7128, -74]), 1000, true)); + println!("{}", Query::distance_less_than("location", json!([40.7128, -74]), 1000, true)); + println!("{}", Query::intersects("location", json!([40.7128, -74]))); + println!("{}", Query::not_intersects("location", json!([40.7128, -74]))); + println!("{}", Query::crosses("location", json!([40.7128, -74]))); + println!("{}", Query::not_crosses("location", json!([40.7128, -74]))); + println!("{}", Query::overlaps("location", json!([40.7128, -74]))); + println!("{}", Query::not_overlaps("location", json!([40.7128, -74]))); + println!("{}", Query::touches("location", json!([40.7128, -74]))); + println!("{}", Query::not_touches("location", json!([40.7128, -74]))); + println!("{}", Query::contains("location", json!([[40.7128, -74], [40.7128, -74]]))); + println!("{}", Query::not_contains("location", json!([[40.7128, -74], [40.7128, -74]]))); + println!("{}", Query::equal("location", json!([[40.7128, -74], [40.7128, -74]]))); + println!("{}", Query::not_equal("location", json!([[40.7128, -74], [40.7128, -74]]))); + println!("{}", Query::or(vec![ + Query::equal("released", true).to_string(), + Query::less_than("releasedYear", 1990).to_string(), + ]).unwrap()); + println!("{}", Query::and(vec![ + Query::equal("released", false).to_string(), + Query::greater_than("releasedYear", 2015).to_string(), + ]).unwrap()); +} + +fn test_permission_helpers() { + println!("{}", Permission::read(&Role::any().to_string())); + println!("{}", Permission::write(&Role::user(ID::custom("userid"), None).to_string())); + println!("{}", Permission::create(&Role::users(None).to_string())); + println!("{}", Permission::update(&Role::guests().to_string())); + println!("{}", Permission::delete(&Role::team("teamId", Some("owner")).to_string())); + println!("{}", Permission::delete(&Role::team("teamId", None::<&str>).to_string())); + println!("{}", Permission::create(&Role::member("memberId").to_string())); + println!("{}", Permission::update(&Role::users(Some("verified")).to_string())); + println!("{}", Permission::update(&Role::user(ID::custom("userid"), Some("unverified")).to_string())); + println!("{}", Permission::create(&Role::label("admin").to_string())); +} + +fn test_id_helpers() { + println!("{}", ID::unique()); + println!("{}", ID::custom("custom_id")); +} diff --git a/tests/Rust183Test.php b/tests/Rust183Test.php new file mode 100644 index 000000000..b7b63c639 --- /dev/null +++ b/tests/Rust183Test.php @@ -0,0 +1,27 @@ +