diff --git a/Cargo.lock b/Cargo.lock index 45ebd2326..81a1f8d0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -675,6 +675,7 @@ dependencies = [ "tree-sitter", "tree-sitter-dscexpression", "tree-sitter-rust", + "url", "urlencoding", "uuid", "which", diff --git a/Cargo.toml b/Cargo.toml index dc5761a06..81afdf6d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -194,6 +194,8 @@ utfx = { version = "0.1" } # dsc-lib uuid = { version = "1.18", features = ["v4"] } # dsc-lib +url = { version = "2.5" } +# dsc-lib urlencoding = { version = "2.1" } # dsc-lib which = { version = "8.0" } diff --git a/docs/reference/schemas/config/functions/uri.md b/docs/reference/schemas/config/functions/uri.md new file mode 100644 index 000000000..929f9e7b9 --- /dev/null +++ b/docs/reference/schemas/config/functions/uri.md @@ -0,0 +1,347 @@ +--- +description: Reference for the 'uri' DSC configuration document function +ms.date: 01/10/2025 +ms.topic: reference +title: uri +--- + +# uri + +## Synopsis + +Creates an absolute URI by combining the baseUri and the relativeUri string. + +## Syntax + +```Syntax +uri(, ) +``` + +## Description + +The `uri()` function combines a base URI with a relative URI to create an absolute URI according to +[RFC 3986](https://www.rfc-editor.org/rfc/rfc3986) URI resolution rules. This standardized behavior +ensures consistent and predictable URI construction. + +### Requirements + +- **Base URI must be absolute**: The base URI must include a scheme (such as `https://`, `http://`, + or `file://`). Relative URIs or URIs without schemes return an error. +- **Base URI cannot be empty**: An empty base URI returns an error because an absolute URI requires + a valid base. + +### URI Resolution Behavior + +The function follows RFC 3986 Section 5.2 (Relative Resolution) rules: + +- **Absolute relative URIs**: If the relative URI contains a scheme (e.g., + `https://other.com/path`), it completely replaces the base URI. +- **Protocol-relative URIs** (starting with `//`): The relative URI inherits the scheme from the + base URI. For example, `uri('https://example.com/', '//cdn.example.org/assets')` returns + `https://cdn.example.org/assets`. +- **Path-absolute relative URIs** (starting with `/`): The relative path replaces the entire path + of the base URI, keeping the scheme and authority. For example, + `uri('https://example.com/old/path', '/new/path')` returns `https://example.com/new/path`. +- **Path-relative URIs** (not starting with `/`): The relative path is merged with the base URI's + path. The last segment of the base path is removed and replaced with the relative URI. For + example, `uri('https://example.com/api/v1', 'users')` returns `https://example.com/api/users`. +- **Empty relative URI**: Returns the base URI unchanged. + +### Special Cases + +- **Triple slash sequences** (`///`): Returns an error. Three or more consecutive slashes are + invalid URI syntax. +- **Path normalization**: The function automatically normalizes paths, resolving `.` (current + directory) and `..` (parent directory) references. For example, + `uri('https://example.com/', 'path/../other')` returns `https://example.com/other`. +- **Query strings and fragments**: Query strings (`?query=value`) and fragments (`#section`) in the + relative URI are preserved in the result. + +Use this function to build API endpoints, file paths, or resource URLs dynamically from +configuration parameters. + +## Examples + +### Example 1 - Build API endpoint with trailing slash + +The following example combines a base API URL ending with a slash with a relative path. + +```yaml +# uri.example.1.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + apiBase: + type: string + defaultValue: https://api.example.com/v1/ + resourcePath: + type: string + defaultValue: users/123 +resources: +- name: Build API endpoint + type: Microsoft.DSC.Debug/Echo + properties: + output: + endpoint: "[uri(parameters('apiBase'), parameters('resourcePath'))]" +``` + +```bash +dsc config get --file uri.example.1.dsc.config.yaml +``` + +```yaml +results: +- name: Build API endpoint + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + endpoint: https://api.example.com/v1/users/123 +messages: [] +hadErrors: false +``` + +### Example 2 - Handle duplicate slashes + +The following example shows how the function automatically handles cases where both the base URI +ends with a slash and the relative URI begins with a slash. + +```yaml +# uri.example.2.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: Combine URIs with duplicate slashes + type: Microsoft.DSC.Debug/Echo + properties: + output: + withDuplicateSlashes: "[uri('https://example.com/', '/api/data')]" + result: The function combines the slashes into one +``` + +```bash +dsc config get --file uri.example.2.dsc.config.yaml +``` + +```yaml +results: +- name: Combine URIs with duplicate slashes + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + withDuplicateSlashes: https://example.com/api/data + result: The function combines the slashes into one +messages: [] +hadErrors: false +``` + +### Example 3 - Replace path segments + +The following example demonstrates how `uri()` replaces the last path segment when the base URI +doesn't end with a trailing slash. It uses the [`concat()`][01] function to build the base URL. + +```yaml +# uri.example.3.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + currentVersion: + type: string + defaultValue: v1 + newVersion: + type: string + defaultValue: v2 +resources: +- name: Update API version + type: Microsoft.DSC.Debug/Echo + properties: + output: + oldEndpoint: "[concat('https://api.example.com/', parameters('currentVersion'))]" + newEndpoint: "[uri(concat('https://api.example.com/', parameters('currentVersion')), parameters('newVersion'))]" +``` + +```bash +dsc config get --file uri.example.3.dsc.config.yaml +``` + +```yaml +results: +- name: Update API version + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + oldEndpoint: https://api.example.com/v1 + newEndpoint: https://api.example.com/v2 +messages: [] +hadErrors: false +``` + +### Example 4 - Build resource URLs + +The following example shows how to use `uri()` with [`concat()`][01] to build complete resource +URLs from configuration parameters. + +```yaml +# uri.example.4.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + storageAccount: + type: string + defaultValue: mystorageaccount + containerName: + type: string + defaultValue: documents + blobName: + type: string + defaultValue: report.pdf +resources: +- name: Build blob URL + type: Microsoft.DSC.Debug/Echo + properties: + output: + blobUrl: >- + [uri( + concat('https://', parameters('storageAccount'), '.blob.core.windows.net/'), + concat(parameters('containerName'), '/', parameters('blobName')) + )] +``` + +```bash +dsc config get --file uri.example.4.dsc.config.yaml +``` + +```yaml +results: +- name: Build blob URL + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + blobUrl: https://mystorageaccount.blob.core.windows.net/documents/report.pdf +messages: [] +hadErrors: false +``` + +### Example 5 - Handle query strings and ports + +The following example demonstrates that `uri()` preserves query strings and port numbers correctly. + +```yaml +# uri.example.5.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: URI with special components + type: Microsoft.DSC.Debug/Echo + properties: + output: + withPort: "[uri('https://example.com:8080/', 'api')]" + withQuery: "[uri('https://example.com/api/', 'search?q=test&limit=10')]" +``` + +```bash +dsc config get --file uri.example.5.dsc.config.yaml +``` + +```yaml +results: +- name: URI with special components + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + withPort: https://example.com:8080/api + withQuery: https://example.com/api/search?q=test&limit=10 +messages: [] +hadErrors: false +``` + +### Example 6 - Protocol-relative URI + +The following example shows how protocol-relative URIs (starting with `//`) inherit the scheme +from the base URI. + +```yaml +# uri.example.6.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: Protocol-relative URI + type: Microsoft.DSC.Debug/Echo + properties: + output: + result: "[uri('https://example.com/', '//cdn.example.org/assets')]" + explanation: The relative URI inherits the https scheme from the base +``` + +```bash +dsc config get --file uri.example.6.dsc.config.yaml +``` + +```yaml +results: +- name: Protocol-relative URI + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + result: https://cdn.example.org/assets + explanation: The relative URI inherits the https scheme from the base +messages: [] +hadErrors: false +``` + +## Parameters + +### baseUri + +The base URI string. Must be an absolute URI containing a scheme (such as `https://`, `http://`, or +`file://`). The function uses this as the foundation for resolving the relative URI according to +RFC 3986 rules. + +```yaml +Type: string +Required: true +Position: 1 +``` + +### relativeUri + +The relative URI string to combine with the base URI. Can be: + +- An absolute URI (replaces the base entirely) +- A protocol-relative URI starting with `//` (inherits scheme from base) +- A path-absolute URI starting with `/` (replaces base path) +- A path-relative URI (merges with base path) +- An empty string (returns base unchanged) + +This is combined with the base URI according to RFC 3986 URI resolution rules. + +```yaml +Type: string +Required: true +Position: 2 +``` + +## Output + +The `uri()` function returns a string containing the absolute URI created by combining the base URI +and relative URI according to RFC 3986 URI resolution rules. + +```yaml +Type: string +``` + +## Related functions + +- [`concat()`][01] - Concatenates multiple strings together +- [`format()`][02] - Creates a formatted string from a template +- [`substring()`][03] - Extracts a portion of a string +- [`replace()`][04] - Replaces text in a string +- [`split()`][05] - Splits a string into an array +- [`parameters()`][06] - Retrieves parameter values + + +[01]: ./concat.md +[02]: ./format.md +[03]: ./substring.md +[04]: ./replace.md +[05]: ./split.md +[06]: ./parameters.md diff --git a/dsc/tests/dsc_functions.tests.ps1 b/dsc/tests/dsc_functions.tests.ps1 index 0af70c684..a8f5636a2 100644 --- a/dsc/tests/dsc_functions.tests.ps1 +++ b/dsc/tests/dsc_functions.tests.ps1 @@ -1116,4 +1116,76 @@ Describe 'tests for function expressions' { $errorContent = Get-Content $TestDrive/error.log -Raw $errorContent | Should -Match ([regex]::Escape('Invalid JSON string')) } + + It 'uri() function works for: + ' -TestCases @( + @{ base = 'https://example.com/'; relative = 'path/file.html'; expected = 'https://example.com/path/file.html' } + @{ base = 'https://example.com/'; relative = '/path/file.html'; expected = 'https://example.com/path/file.html' } + @{ base = 'https://example.com/api/v1'; relative = 'users'; expected = 'https://example.com/api/users' } + @{ base = 'https://example.com/api/v1'; relative = '/users'; expected = 'https://example.com/users' } + @{ base = 'https://example.com'; relative = 'path'; expected = 'https://example.com/path' } + @{ base = 'https://example.com'; relative = '/path'; expected = 'https://example.com/path' } + @{ base = 'https://api.example.com/v2/resource/'; relative = 'item/123'; expected = 'https://api.example.com/v2/resource/item/123' } + @{ base = 'https://example.com/a/b/c/'; relative = 'd/e/f'; expected = 'https://example.com/a/b/c/d/e/f' } + @{ base = 'https://example.com/old/path'; relative = 'new'; expected = 'https://example.com/old/new' } + @{ base = 'https://example.com/api/'; relative = 'search?q=test'; expected = 'https://example.com/api/search?q=test' } + @{ base = 'https://example.com/page'; relative = '#section'; expected = 'https://example.com/page#section' } + @{ base = 'https://example.com/page'; relative = '?query=value#section'; expected = 'https://example.com/page?query=value#section' } + @{ base = 'https://example.com/'; relative = ''; expected = 'https://example.com/' } + @{ base = 'http://example.com/'; relative = 'page.html'; expected = 'http://example.com/page.html' } + @{ base = 'ftp://example.com/'; relative = 'file.txt'; expected = 'ftp://example.com/file.txt' } + @{ base = 'file:///C:/path/'; relative = 'file.txt'; expected = 'file:///C:/path/file.txt' } + @{ base = 'https://example.com:8080/'; relative = 'api'; expected = 'https://example.com:8080/api' } + @{ base = 'https://example.com:8080/api'; relative = '/v2'; expected = 'https://example.com:8080/v2' } + @{ base = 'https://example.com/'; relative = 'path'; expected = 'https://example.com/path' } + @{ base = 'https://example.com/path/'; relative = 'file%20name.txt'; expected = 'https://example.com/path/file%20name.txt' } + @{ base = 'https://example.com/'; relative = 'path with spaces'; expected = 'https://example.com/path%20with%20spaces' } + @{ base = 'https://example.com/'; relative = 'path/../other'; expected = 'https://example.com/other' } + @{ base = 'https://example.com/a/b/'; relative = '../c'; expected = 'https://example.com/a/c' } + @{ base = 'https://example.com/a/b/'; relative = './c'; expected = 'https://example.com/a/b/c' } + @{ base = 'https://example.com/path'; relative = 'https://other.com/other'; expected = 'https://other.com/other' } + @{ base = 'https://example.com/path'; relative = 'http://different.com/path'; expected = 'http://different.com/path' } + @{ base = 'https://user:pass@example.com/'; relative = 'path'; expected = 'https://user:pass@example.com/path' } + @{ base = 'https://example.com/'; relative = 'café/file.txt'; expected = 'https://example.com/caf%C3%A9/file.txt' } + @{ base = 'https://[::1]/'; relative = 'path'; expected = 'https://[::1]/path' } + @{ base = 'https://[2001:db8::1]/'; relative = 'api/v1'; expected = 'https://[2001:db8::1]/api/v1' } + @{ base = 'https://[2001:db8::1]:8080/'; relative = 'api'; expected = 'https://[2001:db8::1]:8080/api' } + @{ base = 'http://192.168.1.1/'; relative = 'api/v1'; expected = 'http://192.168.1.1/api/v1' } + ) { + param($base, $relative, $expected) + + $expression = "[uri('$($base -replace "'", "''")','$($relative -replace "'", "''")')]" + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "$expression" +"@ + $out = $config_yaml | dsc config get -f - | ConvertFrom-Json + $out.results[0].result.actualState.output | Should -BeExactly $expected + } + + It 'uri() error handling: ' -TestCases @( + @{ base = ''; relative = 'path'; expectedError = 'The baseUri parameter cannot be empty' } + @{ base = 'example.com'; relative = 'path'; expectedError = 'The baseUri must be an absolute URI (must include a scheme such as https:// or file://)' } + @{ base = '/relative/path'; relative = 'file.txt'; expectedError = 'The baseUri must be an absolute URI' } + @{ base = 'https://example.com/'; relative = '///foo'; expectedError = 'Invalid URI: The relative URI contains an invalid sequence.' } + ) { + param($base, $relative, $expectedError) + + $expression = "[uri('$($base -replace "'", "''")','$($relative -replace "'", "''")')]" + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "$expression" +"@ + $null = dsc -l trace config get -i $config_yaml 2>$TestDrive/error.log + $LASTEXITCODE | Should -Not -Be 0 + $errorContent = Get-Content $TestDrive/error.log -Raw + $errorContent | Should -Match ([regex]::Escape($expectedError)) + } } diff --git a/lib/dsc-lib/Cargo.toml b/lib/dsc-lib/Cargo.toml index 058c4a370..17025fade 100644 --- a/lib/dsc-lib/Cargo.toml +++ b/lib/dsc-lib/Cargo.toml @@ -37,6 +37,7 @@ tracing-indicatif = { workspace = true } tree-sitter = { workspace = true } tree-sitter-rust = { workspace = true} uuid = { workspace = true } +url = { workspace = true } urlencoding = { workspace = true } which = { workspace = true } # workspace crate dependencies diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index 9c1a71628..a2e6a7881 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -542,6 +542,12 @@ invalidArgType = "All arguments must either be arrays or objects" description = "Returns a deterministic unique string from the given strings" invoked = "uniqueString function" +[functions.uri] +description = "Creates an absolute URI by combining the baseUri and the relativeUri string" +emptyBaseUri = "The baseUri parameter cannot be empty. An absolute URI requires a valid base URI." +notAbsoluteUri = "The baseUri must be an absolute URI (must include a scheme such as https:// or file://)." +invalidRelativeUri = "Invalid URI: The relative URI contains an invalid sequence. Three or more consecutive slashes (///) are not allowed." + [functions.uriComponent] description = "Encodes a URI component using percent-encoding" diff --git a/lib/dsc-lib/src/functions/mod.rs b/lib/dsc-lib/src/functions/mod.rs index 9e915f221..01133987e 100644 --- a/lib/dsc-lib/src/functions/mod.rs +++ b/lib/dsc-lib/src/functions/mod.rs @@ -72,6 +72,7 @@ pub mod r#true; pub mod try_get; pub mod union; pub mod unique_string; +pub mod uri; pub mod uri_component; pub mod uri_component_to_string; pub mod user_function; @@ -197,9 +198,9 @@ impl FunctionDispatcher { Box::new(trim::Trim{}), Box::new(r#true::True{}), Box::new(try_get::TryGet{}), - Box::new(utc_now::UtcNow{}), Box::new(union::Union{}), Box::new(unique_string::UniqueString{}), + Box::new(uri::Uri{}), Box::new(uri_component::UriComponent{}), Box::new(uri_component_to_string::UriComponentToString{}), Box::new(utc_now::UtcNow{}), diff --git a/lib/dsc-lib/src/functions/uri.rs b/lib/dsc-lib/src/functions/uri.rs new file mode 100644 index 000000000..19c3ebcac --- /dev/null +++ b/lib/dsc-lib/src/functions/uri.rs @@ -0,0 +1,241 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::DscError; +use crate::configure::context::Context; +use crate::functions::{FunctionArgKind, FunctionCategory, FunctionMetadata}; +use rust_i18n::t; +use serde_json::Value; +use super::Function; +use url::Url; + +#[derive(Debug, Default)] +pub struct Uri {} + +impl Function for Uri { + fn get_metadata(&self) -> FunctionMetadata { + FunctionMetadata { + name: "uri".to_string(), + description: t!("functions.uri.description").to_string(), + category: vec![FunctionCategory::String], + min_args: 2, + max_args: 2, + accepted_arg_ordered_types: vec![ + vec![FunctionArgKind::String], + vec![FunctionArgKind::String], + ], + remaining_arg_accepted_types: None, + return_types: vec![FunctionArgKind::String], + } + } + + fn invoke(&self, args: &[Value], _context: &Context) -> Result { + let base_uri = args[0].as_str().unwrap(); + let relative_uri = args[1].as_str().unwrap(); + + if base_uri.is_empty() { + return Err(DscError::Parser(t!("functions.uri.emptyBaseUri").to_string())); + } + + let base = Url::parse(base_uri) + .map_err(|_| DscError::Parser(t!("functions.uri.notAbsoluteUri").to_string()))?; + + if relative_uri.is_empty() { + return Ok(Value::String(base.to_string())); + } + + if relative_uri.starts_with("///") { + return Err(DscError::Parser(t!("functions.uri.invalidRelativeUri").to_string())); + } + + let result = base.join(relative_uri) + .map_err(|e| DscError::Parser(format!("{}: {}", t!("functions.uri.invalidRelativeUri"), e)))?; + + Ok(Value::String(result.to_string())) + } +} + +#[cfg(test)] +mod tests { + use crate::configure::context::Context; + use crate::parser::Statement; + + #[test] + fn test_uri_basic_trailing_slash() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[uri('https://example.com/', 'path/file.html')]", &Context::new()).unwrap(); + assert_eq!(result, "https://example.com/path/file.html"); + } + + #[test] + fn test_uri_trailing_and_leading_slash() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[uri('https://example.com/', '/path/file.html')]", &Context::new()).unwrap(); + assert_eq!(result, "https://example.com/path/file.html"); + } + + #[test] + fn test_uri_no_trailing_slash_with_path() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[uri('https://example.com/api/v1', 'users')]", &Context::new()).unwrap(); + assert_eq!(result, "https://example.com/api/users"); + } + + #[test] + fn test_uri_no_trailing_slash_with_leading_slash() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[uri('https://example.com/api/v1', '/users')]", &Context::new()).unwrap(); + // When relative starts with '/', it replaces the entire path + assert_eq!(result, "https://example.com/users"); + } + + #[test] + fn test_uri_no_slashes_after_scheme() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[uri('https://example.com', 'path')]", &Context::new()).unwrap(); + assert_eq!(result, "https://example.com/path"); + } + + #[test] + fn test_uri_no_slashes_after_scheme_with_leading_slash() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[uri('https://example.com', '/path')]", &Context::new()).unwrap(); + assert_eq!(result, "https://example.com/path"); + } + + #[test] + fn test_uri_complex_path() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[uri('https://api.example.com/v2/resource/', 'item/123')]", &Context::new()).unwrap(); + assert_eq!(result, "https://api.example.com/v2/resource/item/123"); + } + + #[test] + fn test_uri_query_string() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[uri('https://example.com/api/', 'search?q=test')]", &Context::new()).unwrap(); + assert_eq!(result, "https://example.com/api/search?q=test"); + } + + #[test] + fn test_uri_empty_relative() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[uri('https://example.com/', '')]", &Context::new()).unwrap(); + assert_eq!(result, "https://example.com/"); + } + + #[test] + fn test_uri_http_scheme() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[uri('http://example.com/', 'page.html')]", &Context::new()).unwrap(); + assert_eq!(result, "http://example.com/page.html"); + } + + #[test] + fn test_uri_with_port() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[uri('https://example.com:8080/', 'api')]", &Context::new()).unwrap(); + assert_eq!(result, "https://example.com:8080/api"); + } + + #[test] + fn test_uri_nested_function() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[uri(concat('https://example.com', '/'), 'path')]", &Context::new()).unwrap(); + assert_eq!(result, "https://example.com/path"); + } + + #[test] + fn test_uri_multiple_path_segments() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[uri('https://example.com/a/b/c/', 'd/e/f')]", &Context::new()).unwrap(); + assert_eq!(result, "https://example.com/a/b/c/d/e/f"); + } + + #[test] + fn test_uri_replace_last_segment() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[uri('https://example.com/old/path', 'new')]", &Context::new()).unwrap(); + assert_eq!(result, "https://example.com/old/new"); + } + + #[test] + fn test_uri_empty_base_uri_error() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[uri('', 'path')]", &Context::new()); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.to_string().contains("baseUri")); + } + + #[test] + fn test_uri_triple_slash_error() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[uri('https://example.com/', '///foo')]", &Context::new()); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.to_string().contains("Invalid") || err.to_string().contains("invalid")); + } + + #[test] + fn test_uri_double_slash_protocol_relative() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[uri('https://example.com/', '//foo')]", &Context::new()).unwrap(); + assert_eq!(result, "https://foo/"); + } + + #[test] + fn test_uri_not_absolute_no_scheme() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[uri('example.com', 'path')]", &Context::new()); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.to_string().contains("absolute")); + } + + #[test] + fn test_uri_not_absolute_relative_path() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[uri('/relative/path', 'file.txt')]", &Context::new()); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.to_string().contains("absolute")); + } + + #[test] + fn test_uri_double_slash_with_path() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[uri('https://example.com/', '//foo/bar')]", &Context::new()).unwrap(); + assert_eq!(result, "https://foo/bar"); + } + + #[test] + fn test_uri_ipv6_localhost() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[uri('https://[::1]/', 'path')]", &Context::new()).unwrap(); + // IPv6 uses compressed format (standard representation) + assert_eq!(result, "https://[::1]/path"); + } + + #[test] + fn test_uri_ipv6_address() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[uri('https://[2001:db8::1]/', 'api/v1')]", &Context::new()).unwrap(); + // IPv6 uses compressed format (standard representation) + assert_eq!(result, "https://[2001:db8::1]/api/v1"); + } + + #[test] + fn test_uri_ipv6_with_port() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[uri('https://[2001:db8::1]:8080/', 'api')]", &Context::new()).unwrap(); + assert_eq!(result, "https://[2001:db8::1]:8080/api"); + } + + #[test] + fn test_uri_ipv4_address() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[uri('http://192.168.1.1/', 'api/v1')]", &Context::new()).unwrap(); + assert_eq!(result, "http://192.168.1.1/api/v1"); + } +}