initial commit

code structure like in speedrun-api and gitlab-api
This commit is contained in:
Dmitriy Kholkin 2025-01-09 16:48:41 +03:00
commit 4b6a895c94
Signed by: AtaraxiaDev
GPG Key ID: FD266B810DF48DF2
20 changed files with 3198 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
/target
/result*
/.vscode
/.devenv
.envrc

1939
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

29
Cargo.toml Normal file
View File

@ -0,0 +1,29 @@
[package]
name = "jikan-rs"
version = "0.1.0"
edition = "2024"
authors = ["Dmitriy Kholkin <ataraxiadev@ataraxiadev.com>"]
description = "A wrapper for the jikan.moe REST API"
license = "MIT OR Apache-2.0"
repository = "https://github.com/AtaraxiaSjel/jikan-rs"
[dependencies]
async-trait = "0.1.85"
bytes = "1.9.0"
derive_builder = "0.20.2"
form_urlencoded = "1.2.1"
futures = "0.3.31"
http = "1.2.0"
iso8601-timestamp = { version = "0.3.3", features = ["verify"] }
log = "0.4.22"
page-turner = "1.0.0"
reqwest = { version = "0.12.12", features = ["blocking", "json"] }
serde = { version = "1.0.217", features = ["derive"] }
serde_json = "1.0.135"
serde_urlencoded = "0.7.1"
thiserror = "2.0.10"
url = { version = "2.5.4", features = ["serde"] }
[dev-dependencies]
tokio = { version = "1", features = ["full"] }
env_logger = "0.11.3"

21
LICENSE.md Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Dmitriy Kholkin <ataraxiadev@ataraxiadev.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

333
flake.lock generated Normal file
View File

@ -0,0 +1,333 @@
{
"nodes": {
"cachix": {
"inputs": {
"devenv": [
"devenv"
],
"flake-compat": [
"devenv"
],
"git-hooks": [
"devenv"
],
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1728672398,
"narHash": "sha256-KxuGSoVUFnQLB2ZcYODW7AVPAh9JqRlD5BrfsC/Q4qs=",
"owner": "cachix",
"repo": "cachix",
"rev": "aac51f698309fd0f381149214b7eee213c66ef0a",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "latest",
"repo": "cachix",
"type": "github"
}
},
"devenv": {
"inputs": {
"cachix": "cachix",
"flake-compat": "flake-compat",
"git-hooks": "git-hooks",
"nix": "nix",
"nixpkgs": "nixpkgs_3"
},
"locked": {
"lastModified": 1736184348,
"narHash": "sha256-LvuuwJBlZjLgjl6uzhfXP0rBveoNx7q0nz21xYb1AII=",
"owner": "cachix",
"repo": "devenv",
"rev": "07219f00c633f756d1f0cc5bb6c4c311b5c4cb0d",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"fenix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1735713283,
"narHash": "sha256-xC6X49L55xo7AV+pAYclOj5UNWtBo/xx5aB5IehJD0M=",
"owner": "nix-community",
"repo": "fenix",
"rev": "bfba822a4220b0e2c4dc7f36a35e4c8450cd9a9c",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "monthly",
"repo": "fenix",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1696426674,
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"devenv",
"nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1712014858,
"narHash": "sha256-sB4SWl2lX95bExY2gMFG5HIzvva5AVMJd4Igm+GpZNw=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "9126214d0a59633752a136528f5f3b9aa8565b7d",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-parts_2": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1736143030,
"narHash": "sha256-+hu54pAoLDEZT9pjHlqL9DNzWz0NbUn8NEAHP7PQPzU=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "b905f6fc23a9051a6e1b741e1438dbfc0634c6de",
"type": "github"
},
"original": {
"id": "flake-parts",
"type": "indirect"
}
},
"git-hooks": {
"inputs": {
"flake-compat": [
"devenv"
],
"gitignore": "gitignore",
"nixpkgs": [
"devenv",
"nixpkgs"
],
"nixpkgs-stable": [
"devenv"
]
},
"locked": {
"lastModified": 1730302582,
"narHash": "sha256-W1MIJpADXQCgosJZT8qBYLRuZls2KSiKdpnTVdKBuvU=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "af8a16fe5c264f5e9e18bcee2859b40a656876cf",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"devenv",
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"libgit2": {
"flake": false,
"locked": {
"lastModified": 1697646580,
"narHash": "sha256-oX4Z3S9WtJlwvj0uH9HlYcWv+x1hqp8mhXl7HsLu2f0=",
"owner": "libgit2",
"repo": "libgit2",
"rev": "45fd9ed7ae1a9b74b957ef4f337bc3c8b3df01b5",
"type": "github"
},
"original": {
"owner": "libgit2",
"repo": "libgit2",
"type": "github"
}
},
"nix": {
"inputs": {
"flake-compat": [
"devenv"
],
"flake-parts": "flake-parts",
"libgit2": "libgit2",
"nixpkgs": "nixpkgs_2",
"nixpkgs-23-11": [
"devenv"
],
"nixpkgs-regression": [
"devenv"
],
"pre-commit-hooks": [
"devenv"
]
},
"locked": {
"lastModified": 1727438425,
"narHash": "sha256-X8ES7I1cfNhR9oKp06F6ir4Np70WGZU5sfCOuNBEwMg=",
"owner": "domenkozar",
"repo": "nix",
"rev": "f6c5ae4c1b2e411e6b1e6a8181cc84363d6a7546",
"type": "github"
},
"original": {
"owner": "domenkozar",
"ref": "devenv-2.24",
"repo": "nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1730531603,
"narHash": "sha256-Dqg6si5CqIzm87sp57j5nTaeBbWhHFaVyG7V6L8k3lY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "7ffd9ae656aec493492b44d0ddfb28e79a1ea25d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1735774519,
"narHash": "sha256-CewEm1o2eVAnoqb6Ml+Qi9Gg/EfNAxbRx1lANGVyoLI=",
"type": "tarball",
"url": "https://github.com/NixOS/nixpkgs/archive/e9b51731911566bbf7e4895475a87fe06961de0b.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://github.com/NixOS/nixpkgs/archive/e9b51731911566bbf7e4895475a87fe06961de0b.tar.gz"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1717432640,
"narHash": "sha256-+f9c4/ZX5MWDOuB1rKoWj+lBNm0z0rs4CK47HBLxy1o=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "88269ab3044128b7c2f4c7d68448b2fb50456870",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "release-24.05",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1716977621,
"narHash": "sha256-Q1UQzYcMJH4RscmpTkjlgqQDX5yi1tZL0O345Ri6vXQ=",
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "4267e705586473d3e5c8d50299e71503f16a6fb6",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "rolling",
"repo": "devenv-nixpkgs",
"type": "github"
}
},
"nixpkgs_4": {
"locked": {
"lastModified": 1736344531,
"narHash": "sha256-8YVQ9ZbSfuUk2bUf2KRj60NRraLPKPS0Q4QFTbc+c2c=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "bffc22eb12172e6db3c5dde9e3e5628f8e3e7912",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"fenix": "fenix",
"flake-parts": "flake-parts_2",
"nixpkgs": "nixpkgs_4"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1735659655,
"narHash": "sha256-DQgwi3pwaasWWDfNtXIX0lW5KvxQ+qVhxO1J7l68Qcc=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "085ad107943996c344633d58f26467b05f8e2ff0",
"type": "github"
},
"original": {
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

59
flake.nix Normal file
View File

@ -0,0 +1,59 @@
{
description = "devenv for jikan-rs lib";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
devenv.url = "github:cachix/devenv";
fenix = {
url = "github:nix-community/fenix/monthly";
inputs.nixpkgs.follows = "nixpkgs";
};
};
nixConfig = {
extra-trusted-public-keys = "devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw=";
extra-substituters = "https://devenv.cachix.org";
};
outputs =
inputs@{ flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
imports = [ inputs.devenv.flakeModule ];
systems = [ "x86_64-linux" ];
perSystem = { config, self', inputs', pkgs, system, lib, ... }:
{
_module.args.pkgs = import inputs.nixpkgs {
inherit system;
overlays = [ inputs.fenix.overlays.default ];
};
devenv.shells.default = let
libs = with pkgs; [ openssl ];
toolchain = (pkgs.fenix.complete.withComponents [
"cargo"
"clippy"
"rust-docs"
"rust-src"
"rustc"
"rustfmt"
]);
in {
name = "jikan-rs-devenv";
env = {
LD_LIBRARY_PATH = lib.makeLibraryPath libs;
LIBCLANG_PATH = "${pkgs.libclang.lib}/lib";
};
packages = with pkgs; libs ++ [
nixfmt-rfc-style
rust-analyzer-nightly
toolchain
];
languages.rust = {
enable = true;
channel = "nightly";
components = [];
toolchain = lib.mkForce toolchain;
};
};
};
};
}

10
rustfmt.toml Normal file
View File

@ -0,0 +1,10 @@
format_code_in_doc_comments = true
format_macro_matchers = true
format_strings = true
group_imports = "StdExternalCrate"
imports_granularity = "Crate"
imports_layout = "Vertical"
overflow_delimited_expr = true
use_field_init_shorthand = true
use_try_shorthand = true
wrap_comments = true

24
src/api.rs Normal file
View File

@ -0,0 +1,24 @@
mod client;
mod endpoint;
mod error;
mod page;
mod query;
mod query_params;
mod utils;
pub use client::{
AsyncClient,
Client,
RestClient,
};
pub use error::ApiError;
pub use page::{
AsyncIterator,
PagedEndpointExt,
};
pub use query::{
AsyncQuery,
Query,
};
pub use crate::types::Root;

44
src/api/client.rs Normal file
View File

@ -0,0 +1,44 @@
use std::error::Error;
use async_trait::async_trait;
use bytes::Bytes;
use http::{
Response,
request::Builder as RequestBuilder,
};
use url::Url;
use super::error::ApiError;
/// A parent trait representing a client which can communicate with jikan.moe
pub trait RestClient {
/// The error that may occur for this client
type Error: Error + Send + Sync + 'static;
/// Get the URL for the endpoint for the client.
///
/// This method adds the hostname for the target api.
fn rest_endpoint(&self, endpoint: &str) -> Result<Url, ApiError<Self::Error>>;
}
/// A trait representing a blocking client which can communicate with jikan.moe
pub trait Client: RestClient {
/// Send a REST query
fn rest(
&self,
request: RequestBuilder,
body: Vec<u8>,
) -> Result<Response<Bytes>, ApiError<Self::Error>>;
}
/// A trait representing an asynchronous client which can communicate with
/// jikan.moe
#[async_trait]
pub trait AsyncClient: RestClient {
/// Send a REST query asynchronously
async fn rest_async(
&self,
request: RequestBuilder,
body: Vec<u8>,
) -> Result<Response<Bytes>, ApiError<Self::Error>>;
}

71
src/api/endpoint.rs Normal file
View File

@ -0,0 +1,71 @@
use std::borrow::Cow;
use async_trait::async_trait;
use http::Method;
use serde::de::DeserializeOwned;
use super::{
ApiError,
AsyncClient,
Client,
error::BodyError,
query::{
AsyncQuery,
Query,
},
query_params::QueryParams,
utils,
};
pub trait Endpoint {
fn method(&self) -> Method {
Method::GET
}
fn endpoint(&self) -> Cow<'static, str>;
fn query_params(&self) -> Result<QueryParams<'_>, BodyError> {
Ok(QueryParams::default())
}
fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, BodyError> {
Ok(None)
}
}
impl<E, T, C> Query<T, C> for E
where
E: Endpoint,
T: DeserializeOwned,
C: Client,
{
fn query(&self, client: &C) -> Result<T, ApiError<<C>::Error>> {
let (req, data) = utils::build_request(self, client)?;
let url = req.uri_ref().cloned().unwrap_or_default();
let rsp = client.rest(req, data)?;
utils::deserialize_response::<_>(rsp)
.map(|value| value.data)
.map_err(|err| ApiError::from_http_response(err, url))
}
}
#[async_trait]
impl<E, T, C> AsyncQuery<T, C> for E
where
E: Endpoint + Sync,
T: DeserializeOwned + 'static,
C: AsyncClient + Sync,
{
async fn query_async(&self, client: &C) -> Result<T, ApiError<C::Error>> {
let (req, data) = utils::build_request(self, client)?;
let url = req.uri_ref().cloned().unwrap_or_default();
let rsp = client.rest_async(req, data).await?;
utils::deserialize_response::<_>(rsp)
.map(|value| value.data)
.map_err(|err| ApiError::from_http_response(err, url))
}
}

71
src/api/error.rs Normal file
View File

@ -0,0 +1,71 @@
use std::error::Error;
use thiserror::Error;
/// Errors from response
#[derive(Debug, Error)]
pub enum ResponseError {
#[error("Parsing JSON: {0}")]
Parse(#[from] serde_json::Error),
#[error("Deserializing value: {source}")]
DataType {
source: serde_json::Error,
value: serde_json::Value,
type_name: &'static str,
},
#[error("HTTP error: {status}")]
HttpStatus {
value: serde_json::Value,
status: http::StatusCode,
},
}
/// Errors that occur when creating form data.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum BodyError {
/// Error serializing body data from form paramaters
#[error("URL encode error: {0}")]
UrlEncoded(#[from] serde_urlencoded::ser::Error),
#[error("JSON encode error: {0}")]
Json(#[from] serde_json::Error),
}
/// Errors that occur from API endpoints.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ApiError<E>
where
E: Error + Send + Sync + 'static,
{
/// Error creating body data
#[error("failed to create form data: {0}")]
Body(#[from] BodyError),
/// The client encountered an error.
#[error("client error: {0}")]
Client(E),
/// The URL failed to parse.
#[error("url parse error: {0}")]
Parse(#[from] url::ParseError),
#[error("Error in the HTTP response at url [{url}]: source")]
Response {
/// Source of the error
source: ResponseError,
/// URL of the error
url: http::Uri,
},
}
impl<E> ApiError<E>
where
E: Error + Send + Sync + 'static,
{
/// Create an API error from a client error
pub fn client(source: E) -> Self {
Self::Client(source)
}
pub(crate) fn from_http_response(source: ResponseError, url: http::Uri) -> Self {
Self::Response { source, url }
}
}

183
src/api/page.rs Normal file
View File

@ -0,0 +1,183 @@
use async_trait::async_trait;
use log::debug;
use serde::{
Serialize,
de::DeserializeOwned,
};
use super::{
ApiError,
AsyncClient,
AsyncQuery,
Client,
Query,
RestClient,
endpoint::Endpoint,
utils,
};
use crate::types::Pagination;
/// Marker trait to indicate that an endpoint is pageable.
pub trait Pageable {}
// Adapters specific to [`Pageable`] endpoints.
pub trait PagedEndpointExt<'a, E> {
/// Create an Iterator over the results of the paginated endpoint.
fn iter<T, C>(&'a self, client: &'a C) -> PagedIter<'a, E, C, T>
where
C: RestClient,
T: DeserializeOwned;
}
pub trait AsyncIterator {
type Item;
// async fn next(&mut self) -> Option<Self::Item>;
fn next(&mut self) -> impl Future<Output = Option<Self::Item>> + Send;
}
pub struct PagedIter<'a, E, C, T> {
client: &'a C,
state: InnerState<'a, E>,
current_page: Vec<T>,
last_page: bool,
}
#[derive(Debug, Serialize, Builder)]
#[builder(pattern = "owned")]
pub struct InnerState<'a, E> {
#[serde(skip)]
pub(crate) endpoint: &'a E,
pub(crate) page: u32,
}
impl<'a, E, C, T> PagedIter<'a, E, C, T>
where
E: Endpoint + Pageable,
{
pub(crate) fn new(endpoint: &'a E, client: &'a C) -> Self {
let state = InnerStateBuilder::default()
.endpoint(endpoint)
.page(1)
.build()
.unwrap();
Self {
client,
state,
current_page: Vec::new(),
last_page: false,
}
}
}
impl<E, C, T> Iterator for PagedIter<'_, E, C, T>
where
E: Endpoint + Pageable,
T: DeserializeOwned,
C: Client,
{
type Item = Result<T, ApiError<C::Error>>;
fn next(&mut self) -> Option<Self::Item> {
if self.current_page.is_empty() {
if self.last_page {
return None;
}
debug!("Fetching page {}", self.state.page);
(self.current_page, self.last_page) = match self.state.query(self.client) {
Ok((data, pagination)) => (data, !pagination.has_next_page),
Err(err) => {
debug!("Error in query: {:?}", err);
return Some(Err(err));
}
};
self.state.page += 1;
self.current_page.reverse();
}
self.current_page.pop().map(Ok)
}
}
impl<E, C, T> AsyncIterator for PagedIter<'_, E, C, T>
where
E: Endpoint + Pageable + Sync,
T: DeserializeOwned + Send + 'static,
C: AsyncClient + Sync,
{
type Item = Result<T, ApiError<C::Error>>;
async fn next(&mut self) -> Option<Self::Item> {
if self.current_page.is_empty() {
if self.last_page {
return None;
}
debug!("Fetching page {}", self.state.page);
(self.current_page, self.last_page) = match self.state.query_async(self.client).await {
Ok((data, pagination)) => (data, !pagination.has_next_page),
Err(err) => {
debug!("Error in query: {:?}", err);
return Some(Err(err));
}
};
self.state.page += 1;
self.current_page.reverse();
}
self.current_page.pop().map(Ok)
}
}
impl<'a, E> PagedEndpointExt<'a, E> for E
where
E: Endpoint + Pageable,
{
fn iter<T, C>(&'a self, client: &'a C) -> PagedIter<'a, E, C, T>
where
C: RestClient,
T: DeserializeOwned,
{
PagedIter::new(self, client)
}
}
impl<E, T, C> Query<(Vec<T>, Pagination), C> for InnerState<'_, E>
where
E: Endpoint + Pageable,
T: DeserializeOwned,
C: Client,
{
fn query(&self, client: &C) -> Result<(Vec<T>, Pagination), ApiError<C::Error>> {
let (req, data) = utils::build_paged_request(self, client)?;
let url = req.uri_ref().cloned().unwrap_or_default();
let rsp = client.rest(req, data)?;
utils::deserialize_response::<_>(rsp)
.map(|value| (value.data, value.pagination.unwrap_or_default()))
.map_err(|err| ApiError::from_http_response(err, url))
}
}
#[async_trait]
impl<E, T, C> AsyncQuery<(Vec<T>, Pagination), C> for InnerState<'_, E>
where
E: Endpoint + Pageable + Sync,
T: DeserializeOwned + Send + 'static,
C: AsyncClient + Sync,
{
async fn query_async(&self, client: &C) -> Result<(Vec<T>, Pagination), ApiError<C::Error>> {
let (req, data) = utils::build_paged_request(self, client)?;
let url = req.uri_ref().cloned().unwrap_or_default();
let rsp = client.rest_async(req, data).await?;
utils::deserialize_response::<_>(rsp)
.map(|value| (value.data, value.pagination.unwrap_or_default()))
.map_err(|err| ApiError::from_http_response(err, url))
}
}

26
src/api/query.rs Normal file
View File

@ -0,0 +1,26 @@
use async_trait::async_trait;
use super::{
ApiError,
AsyncClient,
Client,
};
/// Query made to a client.
pub trait Query<T, C>
where
C: Client,
{
/// Perform a query against the client.
fn query(&self, client: &C) -> Result<T, ApiError<C::Error>>;
}
/// Asynchronous query made to a client.
#[async_trait]
pub trait AsyncQuery<T, C>
where
C: AsyncClient,
{
/// Perform an asynchronous query against the client.
async fn query_async(&self, client: &C) -> Result<T, ApiError<C::Error>>;
}

77
src/api/query_params.rs Normal file
View File

@ -0,0 +1,77 @@
use std::borrow::Borrow;
use serde::Serialize;
use url::Url;
use super::error::BodyError;
pub struct QueryParams<'a>(form_urlencoded::Serializer<'a, String>);
impl QueryParams<'_> {
pub(crate) fn new() -> Self {
Self(form_urlencoded::Serializer::new(String::new()))
}
#[allow(dead_code)]
pub(crate) fn clear(&mut self) -> &mut Self {
self.0.clear();
self
}
#[allow(dead_code)]
pub(crate) fn append_pair(
&mut self,
key: impl AsRef<str>,
value: impl AsRef<str>,
) -> &mut Self {
self.0.append_pair(key.as_ref(), value.as_ref());
self
}
#[allow(dead_code)]
pub(crate) fn extend_pairs<I, K, V>(&mut self, iter: I) -> &mut Self
where
I: IntoIterator,
I::Item: Borrow<(K, V)>,
K: AsRef<str>,
V: AsRef<str>,
{
self.0.extend_pairs(iter);
self
}
pub(crate) fn extend_from(&mut self, value: &impl Serialize) -> Result<&mut Self, BodyError> {
value.serialize(self.serializer())?;
Ok(self)
}
#[allow(dead_code)]
pub(crate) fn with(value: &impl Serialize) -> Result<Self, BodyError> {
let mut out = Self::new();
out.extend_from(value)?;
Ok(out)
}
pub(crate) fn apply_to(self, url: &mut Url) {
let query = &self.finish();
if !query.is_empty() {
url.set_query(Some(query))
}
}
pub(crate) fn finish(mut self) -> String {
self.0.finish()
}
}
impl<'a> QueryParams<'a> {
pub(crate) fn serializer<'b>(&'b mut self) -> serde_urlencoded::Serializer<'a, 'b, String> {
serde_urlencoded::Serializer::new(&mut self.0)
}
}
impl Default for QueryParams<'_> {
fn default() -> Self {
Self::new()
}
}

91
src/api/utils.rs Normal file
View File

@ -0,0 +1,91 @@
use bytes::Bytes;
use http::{
header,
request::Builder as RequestBuilder,
};
use serde::de::DeserializeOwned;
use super::{
ApiError,
RestClient,
Root,
endpoint::Endpoint,
error::ResponseError,
page::{
InnerState,
Pageable,
},
};
pub fn url_to_http_uri(url: url::Url) -> http::Uri {
url.as_str()
.parse::<http::Uri>()
.expect("failed to parse url::Url as http::Uri")
}
pub(crate) fn build_request<E, C>(
endpoint: &E,
client: &C,
) -> Result<(RequestBuilder, Vec<u8>), ApiError<C::Error>>
where
E: Endpoint,
C: RestClient,
{
let url = client.rest_endpoint(&endpoint.endpoint())?;
build_request_internal(url, endpoint, client)
}
pub(crate) fn build_paged_request<E, C>(
state: &InnerState<'_, E>,
client: &C,
) -> Result<(RequestBuilder, Vec<u8>), ApiError<C::Error>>
where
E: Endpoint + Pageable,
C: RestClient,
{
let mut url = client.rest_endpoint(&state.endpoint.endpoint())?;
let mut params = state.endpoint.query_params()?;
params.extend_from(&state)?;
params.apply_to(&mut url);
build_request_internal(url, state.endpoint, client)
}
pub(crate) fn build_request_internal<E, C>(
mut url: url::Url,
endpoint: &E,
_client: &C,
) -> Result<(RequestBuilder, Vec<u8>), ApiError<C::Error>>
where
E: Endpoint,
C: RestClient,
{
endpoint.query_params()?.apply_to(&mut url);
let req = RequestBuilder::new()
.method(endpoint.method())
.uri(url_to_http_uri(url));
if let Some((mime, data)) = endpoint.body()? {
let req = req.header(header::CONTENT_TYPE, mime);
Ok((req, data))
} else {
Ok((req, Vec::new()))
}
}
pub(crate) fn deserialize_response<T>(rsp: http::Response<Bytes>) -> Result<Root<T>, ResponseError>
where
T: DeserializeOwned,
{
let status = rsp.status();
let value = serde_json::from_slice(rsp.body())?;
if !status.is_success() {
return Err(ResponseError::HttpStatus { value, status });
}
serde_json::from_value::<Root<T>>(value.clone()).map_err(|err| ResponseError::DataType {
source: err,
value,
type_name: std::any::type_name::<T>(),
})
}

136
src/client.rs Normal file
View File

@ -0,0 +1,136 @@
use std::convert::TryInto;
use async_trait::async_trait;
use futures::TryFutureExt;
use log::debug;
use reqwest::{
Client as AsyncHttpClient,
blocking::Client as HttpClient,
};
use url::Url;
use crate::{
api,
error::RestError,
};
const API_BASE_URL: &str = "https://api.jikan.moe/v4/";
/// A client for communicating with the jikan.moe API
#[derive(Clone, Debug)]
pub struct JikanApiClient {
client: HttpClient,
rest_url: Url,
}
impl JikanApiClient {
/// Create a new jikan.moe API client.
pub fn new() -> Self {
let rest_url =
Url::parse(API_BASE_URL).expect("Unable to parse API_BASE_URL into url::Url");
Self {
client: HttpClient::new(),
rest_url,
}
}
}
impl Default for JikanApiClient {
fn default() -> Self {
Self::new()
}
}
impl api::RestClient for JikanApiClient {
type Error = RestError;
fn rest_endpoint(&self, endpoint: &str) -> Result<Url, api::ApiError<Self::Error>> {
debug!("REST api call {}", endpoint);
self.rest_url
.join(endpoint.trim_start_matches('/'))
.map_err(From::from)
}
}
impl api::Client for JikanApiClient {
fn rest(
&self,
request: http::request::Builder,
body: Vec<u8>,
) -> Result<http::Response<bytes::Bytes>, api::ApiError<Self::Error>> {
let call = || -> Result<_, RestError> {
let http_request = request.body(body)?;
let request: reqwest::blocking::Request = http_request.try_into()?;
let rsp = self.client.execute(request)?;
let mut http_rsp = http::Response::builder()
.status(rsp.status())
.version(rsp.version());
let headers = http_rsp.headers_mut().unwrap();
for (key, val) in rsp.headers() {
headers.insert(key, val.clone());
}
http_rsp.body(rsp.bytes()?).map_err(From::from)
};
call().map_err(api::ApiError::client)
}
}
/// An asynchronous client for communicating with the jikan.moe API
#[derive(Clone, Debug)]
pub struct JikanApiClientAsync {
client: AsyncHttpClient,
rest_url: Url,
}
impl JikanApiClientAsync {
/// Create a new asynchronous jikan.moe API client
pub fn new() -> Self {
let rest_url =
Url::parse(API_BASE_URL).expect("Unable to parse API_BASE_URL into url::Url");
let client = AsyncHttpClient::new();
Self { client, rest_url }
}
}
impl Default for JikanApiClientAsync {
fn default() -> Self {
Self::new()
}
}
impl api::RestClient for JikanApiClientAsync {
type Error = RestError;
fn rest_endpoint(&self, endpoint: &str) -> Result<Url, api::ApiError<Self::Error>> {
debug!("REST api call {}", endpoint);
self.rest_url
.join(endpoint.trim_start_matches('/'))
.map_err(From::from)
}
}
#[async_trait]
impl api::AsyncClient for JikanApiClientAsync {
async fn rest_async(
&self,
request: http::request::Builder,
body: Vec<u8>,
) -> Result<http::Response<bytes::Bytes>, api::ApiError<Self::Error>> {
let call = || async {
let http_request = request.body(body)?;
let request: reqwest::Request = http_request.try_into()?;
let rsp = self.client.execute(request).await?;
let mut http_rsp = http::Response::builder()
.status(rsp.status())
.version(rsp.version());
let headers = http_rsp.headers_mut().unwrap();
for (key, val) in rsp.headers() {
headers.insert(key, val.clone());
}
http_rsp.body(rsp.bytes().await?).map_err(From::from)
};
call().map_err(api::ApiError::client).await
}
}

31
src/error.rs Normal file
View File

@ -0,0 +1,31 @@
//! Error types for the crate
use thiserror::Error;
use crate::api;
/// An alias for result types returned by this crate.
pub type JikanApiResult<T> = Result<T, JikanApiError>;
/// Errors from the jikan.moe api client.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum JikanApiError {
/// Error from the jikan.moe API
#[error("API error: {0}")]
Api(#[from] api::ApiError<RestError>),
/// Error parsing URL
#[error("url parse error: {0}")]
Parse(#[from] url::ParseError),
}
/// Error communicating with the REST endpoint.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum RestError {
/// Reqwest client error
#[error("communication: {0}")]
Communication(#[from] reqwest::Error),
/// HTTP protocol error
#[error("HTTP error: {0}")]
Http(#[from] http::Error),
}

17
src/lib.rs Normal file
View File

@ -0,0 +1,17 @@
#![forbid(unsafe_code)]
#![warn(future_incompatible, rust_2024_compatibility, unused)]
#![warn(missing_docs)]
#![warn(clippy::all)]
#[macro_use]
extern crate derive_builder;
mod client;
pub mod api;
pub mod error;
pub mod types;
pub use client::{
JikanApiClient,
JikanApiClientAsync,
};

7
src/types.rs Normal file
View File

@ -0,0 +1,7 @@
mod common;
pub use common::{
Pagination,
PaginationItems,
Root,
};

24
src/types/common.rs Normal file
View File

@ -0,0 +1,24 @@
use serde::{
Deserialize,
Serialize,
};
#[derive(Default, Debug, Clone, PartialEq, Deserialize)]
pub struct PaginationItems {
pub count: u64,
pub total: u64,
pub per_page: u64,
}
#[derive(Default, Debug, Clone, PartialEq, Deserialize)]
pub struct Pagination {
pub last_visible_page: u64,
pub has_next_page: bool,
pub items: Option<PaginationItems>,
}
#[derive(Default, Debug, Clone, PartialEq, Deserialize)]
pub struct Root<T> {
pub data: T,
pub pagination: Option<Pagination>,
}