remove coupling with reqwest
This commit is contained in:
parent
94be793df7
commit
dde4762a05
9 changed files with 209 additions and 132 deletions
4
.gitmodules
vendored
4
.gitmodules
vendored
|
@ -1,6 +1,6 @@
|
|||
[submodule "crates/subresource_integrity"]
|
||||
path = crates/subresource_integrity
|
||||
url = https://fem.mint.lgbt/kitsunecafe/subresource-integrity.git
|
||||
url = https://forgejo.archandle.net/rowan/subresource-integrity.git
|
||||
[submodule "crates/maybe_owned"]
|
||||
path = crates/maybe_owned
|
||||
url = https://fem.mint.lgbt/kitsunecafe/maybe-owned.git
|
||||
url = https://forgejo.archandle.net/rowan/maybe-owned.git
|
||||
|
|
25
Cargo.lock
generated
25
Cargo.lock
generated
|
@ -988,9 +988,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
|||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.79"
|
||||
version = "2.0.87"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590"
|
||||
checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
@ -1040,6 +1040,26 @@ dependencies = [
|
|||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.36"
|
||||
|
@ -1216,6 +1236,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"subresource_integrity",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tokio",
|
||||
"url",
|
||||
|
|
|
@ -12,6 +12,7 @@ reqwest = "0.12.8"
|
|||
serde = { version = "1.0.210", features = ["derive"] }
|
||||
serde_json = "1.0.128"
|
||||
subresource_integrity = { version = "0.1.0", path = "crates/subresource_integrity", features = ["serde", "md5"] }
|
||||
thiserror = "2.0.12"
|
||||
time = { version = "0.3.36", features = ["serde"] }
|
||||
url = "2.5.2"
|
||||
|
||||
|
|
35
src/client.rs
Normal file
35
src/client.rs
Normal file
|
@ -0,0 +1,35 @@
|
|||
use crate::{error::Error, response::PagedResponse};
|
||||
|
||||
pub trait Client {
|
||||
type Request;
|
||||
|
||||
fn get(&self, url: url::Url) -> Result<Self::Request, Error>;
|
||||
fn fetch(
|
||||
&self,
|
||||
req: Self::Request,
|
||||
) -> impl std::future::Future<Output = Result<PagedResponse, Error>> + Send;
|
||||
}
|
||||
|
||||
impl Client for reqwest::Client {
|
||||
type Request = reqwest::Request;
|
||||
|
||||
fn get(&self, url: url::Url) -> Result<Self::Request, Error> {
|
||||
reqwest::Client::get(self, url)
|
||||
.build()
|
||||
.map_err(|e| Error::Request(Box::new(e)))
|
||||
}
|
||||
|
||||
async fn fetch(&self, req: Self::Request) -> Result<PagedResponse, Error> {
|
||||
let response = self
|
||||
.execute(req)
|
||||
.await
|
||||
.map_err(|e| Error::Response(Box::new(e)))?;
|
||||
|
||||
let response = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| Error::Response(Box::new(e)))?;
|
||||
|
||||
str::parse(&response)
|
||||
}
|
||||
}
|
|
@ -33,8 +33,9 @@ impl Display for Order {
|
|||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum Stream {
|
||||
Supported,
|
||||
#[serde(rename = "LTS")]
|
||||
LTS,
|
||||
Lts,
|
||||
Beta,
|
||||
Alpha,
|
||||
Tech,
|
||||
|
@ -43,7 +44,8 @@ pub enum Stream {
|
|||
impl Display for Stream {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::LTS => write!(f, "LTS"),
|
||||
Self::Supported => write!(f, "SUPPORTED"),
|
||||
Self::Lts => write!(f, "LTS"),
|
||||
Self::Beta => write!(f, "BETA"),
|
||||
Self::Alpha => write!(f, "ALPHA"),
|
||||
Self::Tech => write!(f, "TECH"),
|
||||
|
@ -57,7 +59,7 @@ pub enum Platform {
|
|||
Windows,
|
||||
Linux,
|
||||
#[serde(rename = "MAC_OS")]
|
||||
MacOS,
|
||||
MacOs,
|
||||
}
|
||||
|
||||
impl Display for Platform {
|
||||
|
@ -65,7 +67,7 @@ impl Display for Platform {
|
|||
match self {
|
||||
Self::Windows => write!(f, "WINDOWS"),
|
||||
Self::Linux => write!(f, "LINUX"),
|
||||
Self::MacOS => write!(f, "MAC_OS"),
|
||||
Self::MacOs => write!(f, "MAC_OS"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
21
src/error.rs
Normal file
21
src/error.rs
Normal file
|
@ -0,0 +1,21 @@
|
|||
use thiserror::Error;
|
||||
|
||||
use crate::response::APIError;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("error parsing result: {0}")]
|
||||
Parse(serde_json::Error),
|
||||
#[error("invalid request: {0}")]
|
||||
Request(Box<dyn core::error::Error>),
|
||||
#[error("connection error: {0}")]
|
||||
Response(Box<dyn core::error::Error>),
|
||||
#[error("api responded with error: {0}")]
|
||||
Api(APIError),
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for Error {
|
||||
fn from(value: serde_json::Error) -> Self {
|
||||
Self::Parse(value)
|
||||
}
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
#![feature(result_flattening)]
|
||||
pub mod client;
|
||||
pub mod common;
|
||||
pub mod error;
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
|
|
142
src/request.rs
142
src/request.rs
|
@ -1,17 +1,17 @@
|
|||
use std::borrow::Cow;
|
||||
use std::ops::DerefMut;
|
||||
|
||||
use maybe_owned::MaybeOwned;
|
||||
use maybe_owned::MaybeOwnedMut;
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
common::*,
|
||||
response::{self, OffsetConnection},
|
||||
};
|
||||
use crate::{client::Client, common::*, error, response::PagedResponse};
|
||||
|
||||
type Parameter<'a> = (&'a str, &'a str);
|
||||
|
||||
pub struct RequestBuilder<'a> {
|
||||
client: Option<&'a reqwest::Client>,
|
||||
const RELEASE_API_URL: &'static str = "https://services.api.unity.com/unity/editor/release/";
|
||||
|
||||
pub struct RequestBuilder<'a, T> {
|
||||
client: Option<MaybeOwnedMut<'a, T>>,
|
||||
url: Option<Url>,
|
||||
api_version: APIVersion,
|
||||
limit: u8,
|
||||
offset: usize,
|
||||
|
@ -22,11 +22,12 @@ pub struct RequestBuilder<'a> {
|
|||
version: Option<Version>,
|
||||
}
|
||||
|
||||
impl Default for RequestBuilder<'_> {
|
||||
impl<T: Default> Default for RequestBuilder<'_, T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
limit: 10,
|
||||
client: Default::default(),
|
||||
url: Some(Self::default_api_url()),
|
||||
api_version: Default::default(),
|
||||
offset: Default::default(),
|
||||
order: Default::default(),
|
||||
|
@ -38,11 +39,39 @@ impl Default for RequestBuilder<'_> {
|
|||
}
|
||||
}
|
||||
|
||||
impl<'a> RequestBuilder<'a> {
|
||||
pub fn new(client: &'a reqwest::Client) -> Self {
|
||||
impl<'a, T: 'a> RequestBuilder<'a, T> {
|
||||
fn default_api_url() -> Url {
|
||||
Url::parse(RELEASE_API_URL).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T: 'a + Client> RequestBuilder<'a, T> {
|
||||
pub fn new<I: Into<MaybeOwnedMut<'a, T>>>(client: I) -> RequestBuilder<'a, T> {
|
||||
Self {
|
||||
client: Some(client),
|
||||
..Default::default()
|
||||
client: Some(client.into()),
|
||||
url: Some(Self::default_api_url()),
|
||||
limit: 10,
|
||||
api_version: Default::default(),
|
||||
offset: Default::default(),
|
||||
order: Default::default(),
|
||||
stream: Default::default(),
|
||||
platform: Default::default(),
|
||||
architecture: Default::default(),
|
||||
version: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_client<I: Into<MaybeOwnedMut<'a, T>>>(self, client: I) -> RequestBuilder<'a, T> {
|
||||
Self {
|
||||
client: Some(client.into()),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_url(self, url: Url) -> Self {
|
||||
Self {
|
||||
url: Some(url),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -86,24 +115,23 @@ impl<'a> RequestBuilder<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn send(self) -> Result<OffsetConnection, response::Error> {
|
||||
Into::<Request>::into(self).send().await
|
||||
pub async fn send(self) -> Result<PagedResponse, error::Error> {
|
||||
Into::<Request<'a, T>>::into(self).send().await
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<RequestBuilder<'a>> for Request<'a> {
|
||||
fn from(val: RequestBuilder<'a>) -> Self {
|
||||
impl<'a, T: 'a + Client> From<RequestBuilder<'a, T>> for Request<'a, T> {
|
||||
fn from(val: RequestBuilder<'a, T>) -> Self {
|
||||
Request {
|
||||
client: Cow::Borrowed(val.client.unwrap()),
|
||||
client: val.client.unwrap(),
|
||||
api_url: val.url.unwrap_or_else(RequestBuilder::<T>::default_api_url),
|
||||
api_version: val.api_version.to_string(),
|
||||
limit: val.limit.to_string(),
|
||||
offset: val.offset.to_string(),
|
||||
order: val.order.to_string(),
|
||||
stream: val.stream.map_or_else(String::default, |s| s.to_string()),
|
||||
|
||||
platform: val
|
||||
.platform
|
||||
.map_or_else(String::default, |p| p.to_string()),
|
||||
platform: val.platform.map_or_else(String::default, |p| p.to_string()),
|
||||
|
||||
architecture: val
|
||||
.architecture
|
||||
|
@ -116,8 +144,9 @@ impl<'a> From<RequestBuilder<'a>> for Request<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
pub struct Request<'a> {
|
||||
client: Cow<'a, reqwest::Client>,
|
||||
pub struct Request<'a, T> {
|
||||
api_url: Url,
|
||||
client: MaybeOwnedMut<'a, T>,
|
||||
api_version: String,
|
||||
limit: String,
|
||||
offset: String,
|
||||
|
@ -128,10 +157,8 @@ pub struct Request<'a> {
|
|||
version: String,
|
||||
}
|
||||
|
||||
impl Request<'_> {
|
||||
const API_URI: &'static str = "https://services.api.unity.com/unity/editor/release/";
|
||||
|
||||
fn as_parameters(&self) -> impl Iterator<Item = Parameter> {
|
||||
impl<T: Client> Request<'_, T> {
|
||||
fn as_parameters(&self) -> impl Iterator<Item = Parameter<'_>> {
|
||||
[
|
||||
("limit", self.limit.as_str()),
|
||||
("offset", self.offset.as_str()),
|
||||
|
@ -145,50 +172,45 @@ impl Request<'_> {
|
|||
.filter(|p| !p.1.is_empty())
|
||||
}
|
||||
|
||||
fn as_dir(value: &str) -> String {
|
||||
format!("{value}/")
|
||||
}
|
||||
|
||||
fn api_root(&self) -> Result<Url, url::ParseError> {
|
||||
Url::parse(Self::API_URI)
|
||||
.and_then(|url| url.join(&Self::as_dir(&self.api_version)))
|
||||
.and_then(|url| url.join("releases"))
|
||||
fn get_url(&self) -> Result<Url, url::ParseError> {
|
||||
let version = format!("{}/", self.api_version);
|
||||
self.api_url.join(&version)?.join("releases")
|
||||
}
|
||||
|
||||
fn build_uri(&self) -> Result<Url, url::ParseError> {
|
||||
self.api_root()
|
||||
self.get_url()
|
||||
.and_then(|url| Url::parse_with_params(url.as_str(), self.as_parameters()))
|
||||
}
|
||||
|
||||
pub async fn send(&self) -> Result<OffsetConnection, response::Error> {
|
||||
let response = self.client.get(self.build_uri().unwrap()).send().await?;
|
||||
response::parse(response).await
|
||||
pub async fn send(&mut self) -> Result<PagedResponse, error::Error> {
|
||||
let url = self.build_uri().unwrap();
|
||||
let req = self.client.get(url)?;
|
||||
self.client.fetch(req).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct UnityReleaseClient<'a> {
|
||||
client: MaybeOwned<'a, reqwest::Client>,
|
||||
pub struct UnityReleaseClient<'a, T> {
|
||||
client: MaybeOwnedMut<'a, T>,
|
||||
}
|
||||
|
||||
impl<'a> UnityReleaseClient<'a> {
|
||||
pub fn new(client: impl Into<MaybeOwned<'a, reqwest::Client>>) -> Self {
|
||||
impl<'a, T: 'a> UnityReleaseClient<'a, T> {
|
||||
pub fn new(client: impl Into<MaybeOwnedMut<'a, T>>) -> Self {
|
||||
Self {
|
||||
client: client.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request(&self) -> RequestBuilder {
|
||||
match &self.client {
|
||||
MaybeOwned::Owned(x) => RequestBuilder::new(x),
|
||||
MaybeOwned::Borrowed(x) => RequestBuilder::new(x),
|
||||
}
|
||||
|
||||
impl<'a, T: 'a + Client> UnityReleaseClient<'a, T> {
|
||||
pub fn request(&mut self) -> RequestBuilder<'_, T> {
|
||||
RequestBuilder::new(MaybeOwnedMut::Borrowed(self.client.deref_mut()))
|
||||
}
|
||||
|
||||
pub async fn send(
|
||||
&self,
|
||||
request: impl Into<Request<'a>>,
|
||||
) -> Result<OffsetConnection, response::Error> {
|
||||
request: impl Into<Request<'a, T>>,
|
||||
) -> Result<PagedResponse, error::Error> {
|
||||
request.into().send().await
|
||||
}
|
||||
}
|
||||
|
@ -196,12 +218,13 @@ impl<'a> UnityReleaseClient<'a> {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::request::*;
|
||||
use reqwest;
|
||||
|
||||
#[test]
|
||||
fn defaut_request_uri() {
|
||||
let expected = "https://services.api.unity.com/unity/editor/release/v1/releases?limit=10&offset=0&order=RELEASE_DATE_DESC";
|
||||
let client = UnityReleaseClient::default();
|
||||
let request: Request = client.request().into();
|
||||
let mut client = UnityReleaseClient::new(reqwest::Client::default());
|
||||
let request: Request<reqwest::Client> = client.request().into();
|
||||
assert_eq!(request.build_uri().unwrap().as_str(), expected);
|
||||
}
|
||||
|
||||
|
@ -209,13 +232,13 @@ mod tests {
|
|||
fn custom_request_uri() {
|
||||
let expected = "https://services.api.unity.com/unity/editor/release/v1/releases?limit=5&offset=20&order=RELEASE_DATE_ASC&stream=LTS&platform=LINUX&architecture=ARM64&version=2020.3.44f1";
|
||||
|
||||
let client = UnityReleaseClient::default();
|
||||
let request: Request = client
|
||||
let mut client = UnityReleaseClient::default();
|
||||
let request: Request<reqwest::Client> = client
|
||||
.request()
|
||||
.with_order(Order::Ascending)
|
||||
.with_limit(5)
|
||||
.with_offset(20)
|
||||
.with_stream(Stream::LTS)
|
||||
.with_stream(Stream::Lts)
|
||||
.with_platform(Platform::Linux)
|
||||
.with_architecture(Architecture::Arm64)
|
||||
.with_version("2020.3.44f1".to_string())
|
||||
|
@ -228,17 +251,18 @@ mod tests {
|
|||
fn borrowed_client() {
|
||||
let expected = "https://services.api.unity.com/unity/editor/release/v1/releases?limit=10&offset=0&order=RELEASE_DATE_ASC";
|
||||
let request_client = reqwest::Client::default();
|
||||
let client = UnityReleaseClient::new(request_client);
|
||||
let request: Request = client.request().with_order(Order::Ascending).into();
|
||||
let mut client = UnityReleaseClient::new(request_client);
|
||||
let request: Request<reqwest::Client> =
|
||||
client.request().with_order(Order::Ascending).into();
|
||||
|
||||
assert_eq!(request.build_uri().unwrap().as_str(), expected);
|
||||
}
|
||||
|
||||
//#[tokio::test]
|
||||
//async fn test_connection() {
|
||||
// let client = UnityReleaseClient::default();
|
||||
// let mut client = UnityReleaseClient::default();
|
||||
// let request = client.request().with_offset(25).with_limit(25);
|
||||
// let req: Request = request.into();
|
||||
// let mut req: Request<reqwest::Client> = request.into();
|
||||
// // println!("{}", req.build_uri().unwrap());
|
||||
// let response = req.send().await;
|
||||
// print!("{response:?}");
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
use std::path::PathBuf;
|
||||
use std::{fmt::Display, path::PathBuf, str::FromStr};
|
||||
|
||||
use reqwest::Response;
|
||||
use serde::Deserialize;
|
||||
use subresource_integrity::Integrity;
|
||||
use time::Date;
|
||||
|
||||
use crate::common::*;
|
||||
use crate::{common::*, error::Error};
|
||||
|
||||
fn handle_fractional_numbers<'de, D>(deserializer: D) -> Result<usize, D::Error>
|
||||
where
|
||||
|
@ -56,18 +55,18 @@ pub struct ThirdPartyNotice {
|
|||
pub enum FileKind {
|
||||
Text,
|
||||
#[serde(rename = "TAR_GZ")]
|
||||
TarGZ,
|
||||
TarGz,
|
||||
#[serde(rename = "TAR_XZ")]
|
||||
TarXZ,
|
||||
ZIP,
|
||||
PKG,
|
||||
EXE,
|
||||
PO,
|
||||
DMG,
|
||||
LZMA,
|
||||
LZ4,
|
||||
MD,
|
||||
PDF,
|
||||
TarXz,
|
||||
Zip,
|
||||
Pkg,
|
||||
Exe,
|
||||
Po,
|
||||
Dmg,
|
||||
Lzma,
|
||||
Lz4,
|
||||
Md,
|
||||
Pdf,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
|
@ -84,7 +83,7 @@ pub enum ModuleCategory {
|
|||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum SKUFamily {
|
||||
DOTS,
|
||||
Dots,
|
||||
Classic,
|
||||
}
|
||||
|
||||
|
@ -157,60 +156,25 @@ pub struct Release {
|
|||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct OffsetConnection {
|
||||
pub struct PagedResponse {
|
||||
pub offset: usize,
|
||||
pub limit: u8,
|
||||
pub total: usize,
|
||||
pub results: Vec<Release>,
|
||||
}
|
||||
|
||||
pub async fn parse(response: Response) -> Result<OffsetConnection, Error> {
|
||||
let body = response.text().await?;
|
||||
println!("{body}");
|
||||
serde_json::from_str::<ResponseKind>(body.as_str())
|
||||
.map(Result::from)
|
||||
.map_err(Error::from)
|
||||
.flatten()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ResponseKind {
|
||||
Ok(OffsetConnection),
|
||||
pub enum APIResponse {
|
||||
Ok(PagedResponse),
|
||||
Err(APIError),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
API(APIError),
|
||||
Parse(serde_json::Error),
|
||||
Request(reqwest::Error),
|
||||
}
|
||||
impl FromStr for PagedResponse {
|
||||
type Err = Error;
|
||||
|
||||
impl From<ResponseKind> for Result<OffsetConnection, Error> {
|
||||
fn from(value: ResponseKind) -> Self {
|
||||
match value {
|
||||
ResponseKind::Ok(v) => Ok(v),
|
||||
ResponseKind::Err(e) => Err(Error::from(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<APIError> for Error {
|
||||
fn from(value: APIError) -> Self {
|
||||
Self::API(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for Error {
|
||||
fn from(value: serde_json::Error) -> Self {
|
||||
Self::Parse(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for Error {
|
||||
fn from(value: reqwest::Error) -> Self {
|
||||
Self::Request(value)
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
serde_json::from_str(s).map_err(Error::from)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -220,3 +184,11 @@ pub struct APIError {
|
|||
pub status: u16,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl Display for APIError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{} ({}): {}", self.title, self.status, self.detail)
|
||||
}
|
||||
}
|
||||
|
||||
impl core::error::Error for APIError {}
|
||||
|
|
Loading…
Add table
Reference in a new issue