diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cad3f90..c874359 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,7 +28,7 @@ jobs: uses: actions-rs/cargo@v1 with: command: check - args: --target ${{matrix.target}} + args: --target ${{matrix.target}} --no-default-features --features=reqwest-async test: strategy: @@ -62,12 +62,8 @@ jobs: command: test args: --target ${{matrix.target}} --all-features -- --test-threads 1 - # This is currently broken because - # reqwest is not actually optional - # - # - name: test --no-default-features - # uses: actions-rs/cargo@v1 - # with: - # command: test - # args: --target ${{matrix.target}} --no-default-features - + - name: test --no-default-features + uses: actions-rs/cargo@v1 + with: + command: test + args: --target ${{matrix.target}} --no-default-features diff --git a/Cargo.toml b/Cargo.toml index ec3fe23..bb729c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,8 +11,10 @@ license = "MIT/Apache-2.0" keywords = ["http", "reader", "buffer"] [features] -default = ["reqwest"] -sync = ["reqwest?/blocking"] +default = ["reqwest-sync", "reqwest-async"] +logging = ["log"] +reqwest-async = ["reqwest"] +reqwest-sync = ["reqwest/blocking"] [dependencies] byteorder = "1.4.2" diff --git a/README.md b/README.md index dc2dd74..4f690a0 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,9 @@ HTTP client for HTTP Range requests with a buffer optimized for sequential requests. +Implements Seek+Read for blocking clients, which makes it a drop-in replacement for local files. -Usage example: +Usage examples: use http_range_client::*; @@ -12,3 +13,10 @@ Usage example: assert_eq!(bytes, b"fgb"); let version = client.get_bytes(1).await?; // From buffer - no HTTP request! assert_eq!(version, [3]); + + let mut client = + HttpReader::new("https://www.rust-lang.org/static/images/favicon-32x32.png"); + client.seek(SeekFrom::Start(1)).ok(); + let mut bytes = [0; 3]; + client.read_exact(&mut bytes)?; + assert_eq!(&bytes, b"PNG"); diff --git a/src/buffered_range_client.rs b/src/buffered_range_client.rs index bdc264c..a3534ff 100644 --- a/src/buffered_range_client.rs +++ b/src/buffered_range_client.rs @@ -1,12 +1,10 @@ use crate::error::Result; -use crate::HttpClient; use bytes::{BufMut, BytesMut}; use std::cmp::{max, min}; use std::str; -/// HTTP client for HTTP Range requests with a buffer optimized for sequential requests -pub struct BufferedHttpRangeClient { - http_client: HttpClient, +/// Buffer for Range request reader (https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests) +struct HttpRangeBuffer { buf: BytesMut, min_req_size: usize, /// Current position for Read+Seek implementation @@ -15,10 +13,9 @@ pub struct BufferedHttpRangeClient { head: usize, } -impl BufferedHttpRangeClient { - pub fn new(url: &str) -> Self { - BufferedHttpRangeClient { - http_client: HttpClient::new(url), +impl HttpRangeBuffer { + pub fn new() -> Self { + HttpRangeBuffer { buf: BytesMut::new(), min_req_size: 1024, offset: 0, @@ -26,17 +23,6 @@ impl BufferedHttpRangeClient { } } - /// Set minimal request size. - pub fn set_min_req_size(&mut self, size: usize) { - self.min_req_size = size; - } - - /// Set minimal request size. - pub fn min_req_size(&mut self, size: usize) -> &mut Self { - self.set_min_req_size(size); - self - } - fn tail(&self) -> usize { self.head + self.buf.len() } @@ -53,7 +39,7 @@ impl BufferedHttpRangeClient { // +---+ // length - #[cfg(feature = "log")] + #[cfg(feature = "logging")] log::trace!("read begin: {begin}, Length: {length}"); // Download additional bytes if requested range is not in buffer if begin + length > self.tail() || begin < self.head { @@ -76,73 +62,170 @@ impl BufferedHttpRangeClient { } } -#[cfg(not(feature = "sync"))] -mod nonblocking { +pub(crate) mod nonblocking { use super::*; + use crate::range_client::AsyncHttpRangeClient; + + /// HTTP client adapter for HTTP Range requests with a buffer optimized for sequential requests + pub struct AsyncBufferedHttpRangeClient { + http_client: T, + url: String, + buffer: HttpRangeBuffer, + #[cfg(feature = "logging")] + stats: stats::RequestStats, + } + + impl AsyncBufferedHttpRangeClient { + pub fn with(http_client: T, url: &str) -> AsyncBufferedHttpRangeClient { + AsyncBufferedHttpRangeClient { + http_client, + url: url.to_string(), + buffer: HttpRangeBuffer::new(), + #[cfg(feature = "logging")] + stats: stats::RequestStats::default(), + } + } + + /// Set minimal request size. + pub fn set_min_req_size(&mut self, size: usize) { + self.buffer.min_req_size = size; + } + + /// Set minimal request size. + pub fn min_req_size(&mut self, size: usize) -> &mut Self { + self.set_min_req_size(size); + self + } + + fn range(&mut self, begin: usize, length: usize) -> String { + let range = format!("bytes={begin}-{}", begin + length - 1); + #[cfg(feature = "logging")] + self.stats.log_get_range(begin, length, &range); + range + } - impl BufferedHttpRangeClient { /// Get `length` bytes with offset `begin`. pub async fn get_range(&mut self, begin: usize, length: usize) -> Result<&[u8]> { - let slice_len = - if let Some((range_begin, range_length)) = self.get_request_range(begin, length) { - let bytes = self - .http_client - .get_range(range_begin, range_length) - .await?; - let len = bytes.len(); - self.buf.put(bytes); - min(len, length) - } else { - length - }; - self.offset = begin + slice_len; + let slice_len = if let Some((range_begin, range_length)) = + self.buffer.get_request_range(begin, length) + { + let range = self.range(range_begin, range_length); + let bytes = self.http_client.get_range(&self.url, &range).await?; + let len = bytes.len(); + self.buffer.buf.put(bytes); + min(len, length) + } else { + length + }; + self.buffer.offset = begin + slice_len; // Return slice from buffer - let lower = begin - self.head; - Ok(&self.buf[lower..lower + slice_len]) + let lower = begin - self.buffer.head; + Ok(&self.buffer.buf[lower..lower + slice_len]) } /// Get `length` bytes from current offset. pub async fn get_bytes(&mut self, length: usize) -> Result<&[u8]> { - self.get_range(self.offset, length).await + self.get_range(self.buffer.offset, length).await + } + } +} + +#[cfg(feature = "logging")] +pub(crate) mod stats { + use log::debug; + + #[derive(Default)] + pub(crate) struct RequestStats { + requests_ever_made: usize, + bytes_ever_requested: usize, + } + + impl RequestStats { + pub fn log_get_range(&mut self, _begin: usize, length: usize, range: &str) { + self.requests_ever_made += 1; + self.bytes_ever_requested += length; + debug!( + "request: #{}, bytes: (this_request: {length}, ever: {}), Range: {range}", + self.requests_ever_made, self.bytes_ever_requested, + ); } } } -#[cfg(feature = "sync")] -mod sync { +pub(crate) mod sync { use super::*; + use crate::range_client::SyncHttpRangeClient; use bytes::Buf; use std::io::{Read, Seek, SeekFrom}; - impl BufferedHttpRangeClient { + /// HTTP client adapter for HTTP Range requests with a buffer optimized for sequential requests + pub struct SyncBufferedHttpRangeClient { + http_client: T, + url: String, + buffer: HttpRangeBuffer, + #[cfg(feature = "logging")] + stats: stats::RequestStats, + } + + impl SyncBufferedHttpRangeClient { + pub fn with(http_client: T, url: &str) -> SyncBufferedHttpRangeClient { + SyncBufferedHttpRangeClient { + http_client, + url: url.to_string(), + buffer: HttpRangeBuffer::new(), + #[cfg(feature = "logging")] + stats: stats::RequestStats::default(), + } + } + + /// Set minimal request size. + pub fn set_min_req_size(&mut self, size: usize) { + self.buffer.min_req_size = size; + } + + /// Set minimal request size. + pub fn min_req_size(&mut self, size: usize) -> &mut Self { + self.set_min_req_size(size); + self + } + + fn range(&mut self, begin: usize, length: usize) -> String { + let range = format!("bytes={begin}-{}", begin + length - 1); + #[cfg(feature = "logging")] + self.stats.log_get_range(begin, length, &range); + range + } + /// Get `length` bytes with offset `begin`. pub fn get_range(&mut self, begin: usize, length: usize) -> Result<&[u8]> { - let slice_len = - if let Some((range_begin, range_length)) = self.get_request_range(begin, length) { - let bytes = self.http_client.get_range(range_begin, range_length)?; - let len = bytes.len(); - self.buf.put(bytes); - min(len, length) - } else { - length - }; - self.offset = begin + slice_len; + let slice_len = if let Some((range_begin, range_length)) = + self.buffer.get_request_range(begin, length) + { + let range = self.range(range_begin, range_length); + let bytes = self.http_client.get_range(&self.url, &range)?; + let len = bytes.len(); + self.buffer.buf.put(bytes); + min(len, length) + } else { + length + }; + self.buffer.offset = begin + slice_len; // Return slice from buffer - let lower = begin - self.head; - Ok(&self.buf[lower..lower + slice_len]) + let lower = begin - self.buffer.head; + Ok(&self.buffer.buf[lower..lower + slice_len]) } /// Get `length` bytes from current offset. pub fn get_bytes(&mut self, length: usize) -> Result<&[u8]> { - self.get_range(self.offset, length) + self.get_range(self.buffer.offset, length) } } - impl Read for BufferedHttpRangeClient { + impl Read for SyncBufferedHttpRangeClient { fn read(&mut self, buf: &mut [u8]) -> std::result::Result { let length = buf.len(); let mut bytes = self - .get_range(self.offset, length) + .get_range(self.buffer.offset, length) .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; // TODO: return Error::from(ErrorKind::UnexpectedEof) for HTTP status 416 bytes.copy_to_slice(&mut buf[0..bytes.len()]); @@ -150,11 +233,11 @@ mod sync { } } - impl Seek for BufferedHttpRangeClient { + impl Seek for SyncBufferedHttpRangeClient { fn seek(&mut self, pos: SeekFrom) -> std::result::Result { match pos { SeekFrom::Start(p) => { - self.offset = p as usize; + self.buffer.offset = p as usize; Ok(p) } SeekFrom::End(_) => Err(std::io::Error::new( @@ -162,8 +245,8 @@ mod sync { "Request size unkonwn", )), SeekFrom::Current(p) => { - self.offset = self.offset.saturating_add_signed(p as isize); - Ok(self.offset as u64) + self.buffer.offset = self.buffer.offset.saturating_add_signed(p as isize); + Ok(self.buffer.offset as u64) } } } @@ -171,9 +254,9 @@ mod sync { } #[cfg(test)] -#[cfg(not(feature = "sync"))] +#[cfg(feature = "reqwest-async")] mod test_async { - use crate::{BufferedHttpRangeClient, Result}; + use crate::{AsyncBufferedHttpRangeClient, BufferedHttpRangeClient, Result}; fn init_logger() { let _ = env_logger::builder().is_test(true).try_init(); @@ -227,12 +310,28 @@ mod test_async { Ok(()) } + + #[tokio::test] + async fn custom_headers() -> Result<()> { + init_logger(); + let http_client = reqwest::Client::builder() + .user_agent("rust-client") + .build() + .unwrap(); + let mut client = AsyncBufferedHttpRangeClient::with( + http_client, + "https://flatgeobuf.org/test/data/countries.fgb", + ); + let bytes = client.min_req_size(256).get_range(0, 3).await?; + assert_eq!(bytes, b"fgb"); + Ok(()) + } } #[cfg(test)] -#[cfg(feature = "sync")] +#[cfg(feature = "reqwest-sync")] mod test_sync { - use crate::{BufferedHttpRangeClient, Result}; + use crate::{HttpReader, Result}; use std::io::{Read, Seek, SeekFrom}; fn init_logger() { @@ -242,8 +341,7 @@ mod test_sync { #[test] fn http_read_sync() -> Result<()> { init_logger(); - let mut client = - BufferedHttpRangeClient::new("https://flatgeobuf.org/test/data/countries.fgb"); + let mut client = HttpReader::new("https://flatgeobuf.org/test/data/countries.fgb"); let bytes = client.min_req_size(256).get_range(0, 3)?; assert_eq!(bytes, b"fgb"); @@ -258,8 +356,7 @@ mod test_sync { #[test] fn http_read_sync_zero_range() -> Result<()> { init_logger(); - let mut client = - BufferedHttpRangeClient::new("https://flatgeobuf.org/test/data/countries.fgb"); + let mut client = HttpReader::new("https://flatgeobuf.org/test/data/countries.fgb"); let bytes = client.min_req_size(256).get_range(0, 0)?; assert_eq!(bytes, []); Ok(()) @@ -268,8 +365,7 @@ mod test_sync { #[test] fn io_read() -> std::io::Result<()> { init_logger(); - let mut client = - BufferedHttpRangeClient::new("https://flatgeobuf.org/test/data/countries.fgb"); + let mut client = HttpReader::new("https://flatgeobuf.org/test/data/countries.fgb"); client.seek(SeekFrom::Start(3)).ok(); let mut version = [0; 1]; client.min_req_size(256).read_exact(&mut version)?; @@ -284,8 +380,7 @@ mod test_sync { #[test] fn io_read_over_min_req_size() -> std::io::Result<()> { init_logger(); - let mut client = - BufferedHttpRangeClient::new("https://flatgeobuf.org/test/data/countries.fgb"); + let mut client = HttpReader::new("https://flatgeobuf.org/test/data/countries.fgb"); let mut bytes = [0; 8]; client.min_req_size(4).read_exact(&mut bytes)?; assert_eq!(bytes, [b'f', b'g', b'b', 3, b'f', b'g', b'b', 0]); @@ -295,8 +390,7 @@ mod test_sync { #[test] fn io_read_non_exact() -> std::io::Result<()> { init_logger(); - let mut client = - BufferedHttpRangeClient::new("https://flatgeobuf.org/test/data/countries.fgb"); + let mut client = HttpReader::new("https://flatgeobuf.org/test/data/countries.fgb"); let mut bytes = [0; 8]; // We could only read 4 bytes in this case client.min_req_size(4).read(&mut bytes)?; @@ -308,8 +402,7 @@ mod test_sync { fn after_end() -> std::io::Result<()> { init_logger(); // countries.fgb has 205680 bytes - let mut client = - BufferedHttpRangeClient::new("https://flatgeobuf.org/test/data/countries.fgb"); + let mut client = HttpReader::new("https://flatgeobuf.org/test/data/countries.fgb"); client.seek(SeekFrom::Start(205670)).ok(); let mut bytes = [0; 10]; client.read_exact(&mut bytes)?; @@ -328,4 +421,22 @@ mod test_sync { Ok(()) } + + #[test] + fn remote_png() -> std::io::Result<()> { + init_logger(); + let mut client = + HttpReader::new("https://www.rust-lang.org/static/images/favicon-32x32.png"); + let mut bytes = [0; 4]; + client.read_exact(&mut bytes)?; + assert_eq!(&bytes[1..4], b"PNG"); + + let mut client = + HttpReader::new("https://www.rust-lang.org/static/images/favicon-32x32.png"); + client.seek(SeekFrom::Start(1)).ok(); + let mut bytes = [0; 3]; + client.read_exact(&mut bytes)?; + assert_eq!(&bytes, b"PNG"); + Ok(()) + } } diff --git a/src/lib.rs b/src/lib.rs index 940a327..5d57e32 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,9 +5,9 @@ //! ``` //! use http_range_client::*; //! -//! // Async API (without feature `sync`): -//! # #[cfg(not(feature = "sync"))] -//! # async fn get() -> Result<()> { +//! // Async API +//! # #[cfg(feature = "reqwest-async")] +//! # async fn get_async() -> Result<()> { //! let mut client = BufferedHttpRangeClient::new("https://flatgeobuf.org/test/data/countries.fgb"); //! let bytes = client.min_req_size(256).get_range(0, 3).await?; //! assert_eq!(bytes, b"fgb"); @@ -16,10 +16,10 @@ //! # Ok(()) //! # } //! -//! // Blocking API (with feature `sync`): -//! # #[cfg(feature = "sync")] -//! # fn get() -> Result<()> { -//! let mut client = BufferedHttpRangeClient::new("https://flatgeobuf.org/test/data/countries.fgb"); +//! // Blocking API +//! # #[cfg(feature = "reqwest-sync")] +//! # fn get_sync() -> Result<()> { +//! let mut client = HttpReader::new("https://flatgeobuf.org/test/data/countries.fgb"); //! let bytes = client.min_req_size(256).get_range(0, 3)?; //! assert_eq!(bytes, b"fgb"); //! let version = client.get_bytes(1)?; // From buffer - no HTTP request! @@ -27,18 +27,15 @@ //! # Ok(()) //! # } //! -//! // Seek+Read API (with feature `sync`): -//! # #[cfg(feature = "sync")] +//! // Seek+Read API +//! # #[cfg(feature = "reqwest-sync")] //! # fn read() -> std::io::Result<()> { //! use std::io::{Read, Seek, SeekFrom}; -//! let mut client = BufferedHttpRangeClient::new("https://flatgeobuf.org/test/data/countries.fgb"); -//! client.seek(SeekFrom::Start(3)).ok(); -//! let mut version = [0; 1]; -//! client.min_req_size(256).read_exact(&mut version)?; -//! assert_eq!(version, [3]); +//! let mut client = HttpReader::new("https://www.rust-lang.org/static/images/favicon-32x32.png"); +//! client.seek(SeekFrom::Start(1)).ok(); //! let mut bytes = [0; 3]; //! client.read_exact(&mut bytes)?; -//! assert_eq!(&bytes, b"fgb"); +//! assert_eq!(&bytes, b"PNG"); //! # Ok(()) //! # } //! ``` @@ -46,12 +43,15 @@ mod buffered_range_client; mod error; mod range_client; -#[cfg(feature = "reqwest")] +#[cfg(any(feature = "reqwest-async", feature = "reqwest-sync"))] mod reqwest_client; -pub use buffered_range_client::BufferedHttpRangeClient; +pub use buffered_range_client::nonblocking::AsyncBufferedHttpRangeClient; +pub use buffered_range_client::sync::SyncBufferedHttpRangeClient; pub use error::*; -#[cfg(all(feature = "reqwest", not(feature = "sync")))] -pub(crate) use reqwest_client::nonblocking::HttpClient; -#[cfg(all(feature = "reqwest", feature = "sync"))] -pub(crate) use reqwest_client::sync::HttpClient; +pub use range_client::*; + +#[cfg(feature = "reqwest-async")] +pub use crate::reqwest_client::nonblocking::BufferedHttpRangeClient; +#[cfg(feature = "reqwest-sync")] +pub use crate::reqwest_client::sync::HttpReader; diff --git a/src/range_client.rs b/src/range_client.rs index 87d6a06..090c14b 100644 --- a/src/range_client.rs +++ b/src/range_client.rs @@ -1,84 +1,23 @@ use crate::error::Result; -#[cfg(not(feature = "sync"))] use async_trait::async_trait; use bytes::Bytes; use std::str; #[cfg(not(target_arch = "wasm32"))] -#[cfg(not(feature = "sync"))] #[async_trait] -pub(crate) trait HttpRangeClient { - fn new() -> Self; +/// Async HTTP client for Range requests +pub trait AsyncHttpRangeClient { async fn get_range(&self, url: &str, range: &str) -> Result; } #[cfg(target_arch = "wasm32")] -#[cfg(not(feature = "sync"))] #[async_trait(?Send)] -pub(crate) trait HttpRangeClient { - fn new() -> Self; +/// Async HTTP client for Range requests +pub trait AsyncHttpRangeClient { async fn get_range(&self, url: &str, range: &str) -> Result; } -#[cfg(feature = "sync")] -pub(crate) trait HttpRangeClient { - fn new() -> Self; +/// Sync HTTP client for Range requests +pub trait SyncHttpRangeClient { fn get_range(&self, url: &str, range: &str) -> Result; } - -/// HTTP client for HTTP Range requests (https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests) -pub(crate) struct GenericHttpRangeClient { - client: T, - url: String, - #[cfg(feature = "log")] - stats: stats::RequestStats, -} - -#[cfg(feature = "log")] -pub(crate) mod stats { - use log::debug; - - #[derive(Default)] - pub(crate) struct RequestStats { - requests_ever_made: usize, - bytes_ever_requested: usize, - } - - impl RequestStats { - pub fn log_get_range(&mut self, _begin: usize, length: usize, range: &str) { - self.requests_ever_made += 1; - self.bytes_ever_requested += length; - debug!( - "request: #{}, bytes: (this_request: {length}, ever: {}), Range: {range}", - self.requests_ever_made, self.bytes_ever_requested, - ); - } - } -} - -impl GenericHttpRangeClient { - pub fn new(url: &str) -> Self { - GenericHttpRangeClient { - client: T::new(), - url: url.to_string(), - #[cfg(feature = "log")] - stats: stats::RequestStats::default(), - } - } - fn get_range_header(&mut self, begin: usize, length: usize) -> String { - let range = format!("bytes={begin}-{}", begin + length - 1); - #[cfg(feature = "log")] - self.stats.log_get_range(begin, length, &range); - range - } - #[cfg(not(feature = "sync"))] - pub async fn get_range(&mut self, begin: usize, length: usize) -> Result { - let range = self.get_range_header(begin, length); - self.client.get_range(&self.url, &range).await - } - #[cfg(feature = "sync")] - pub fn get_range(&mut self, begin: usize, length: usize) -> Result { - let range = self.get_range_header(begin, length); - self.client.get_range(&self.url, &range) - } -} diff --git a/src/reqwest_client.rs b/src/reqwest_client.rs index ca0c38f..7d31340 100644 --- a/src/reqwest_client.rs +++ b/src/reqwest_client.rs @@ -1,18 +1,15 @@ use crate::error::{HttpError, Result}; -use crate::range_client::{GenericHttpRangeClient, HttpRangeClient}; use bytes::Bytes; -#[cfg(not(feature = "sync"))] +#[cfg(feature = "reqwest-async")] pub(crate) mod nonblocking { use super::*; + use crate::range_client::AsyncHttpRangeClient; use async_trait::async_trait; #[cfg(not(target_arch = "wasm32"))] #[async_trait] - impl HttpRangeClient for reqwest::Client { - fn new() -> Self { - Self::new() - } + impl AsyncHttpRangeClient for reqwest::Client { async fn get_range(&self, url: &str, range: &str) -> Result { let response = self .get(url) @@ -32,10 +29,7 @@ pub(crate) mod nonblocking { #[cfg(target_arch = "wasm32")] #[async_trait(?Send)] - impl HttpRangeClient for reqwest::Client { - fn new() -> Self { - Self::new() - } + impl AsyncHttpRangeClient for reqwest::Client { async fn get_range(&self, url: &str, range: &str) -> Result { let response = self .get(url) @@ -53,17 +47,22 @@ pub(crate) mod nonblocking { } } - pub(crate) type HttpClient = GenericHttpRangeClient; + /// Async HTTP client for HTTP Range requests with a buffer optimized for sequential requests. + pub type BufferedHttpRangeClient = crate::AsyncBufferedHttpRangeClient; + + impl BufferedHttpRangeClient { + pub fn new(url: &str) -> Self { + Self::with(reqwest::Client::new(), url) + } + } } -#[cfg(feature = "sync")] +#[cfg(feature = "reqwest-sync")] pub(crate) mod sync { use super::*; + use crate::range_client::SyncHttpRangeClient; - impl HttpRangeClient for reqwest::blocking::Client { - fn new() -> Self { - Self::new() - } + impl SyncHttpRangeClient for reqwest::blocking::Client { fn get_range(&self, url: &str, range: &str) -> Result { let response = self .get(url) @@ -79,5 +78,12 @@ pub(crate) mod sync { } } - pub(crate) type HttpClient = GenericHttpRangeClient; + /// Sync HTTP client for HTTP Range requests with a buffer optimized for sequential requests. + pub type HttpReader = crate::SyncBufferedHttpRangeClient; + + impl HttpReader { + pub fn new(url: &str) -> Self { + Self::with(reqwest::blocking::Client::new(), url) + } + } }