Initialize API
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
use serde::Deserialize;
|
||||
use turbostore::TurboStore;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Source {
|
||||
Discord,
|
||||
GitHub,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum Key {
|
||||
ProfileCacheV1(Source, String),
|
||||
}
|
||||
|
||||
pub type Cache = TurboStore<Key>;
|
||||
@@ -0,0 +1,115 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{Router, routing::get};
|
||||
use dedent::dedent;
|
||||
|
||||
use crate::state::State;
|
||||
|
||||
pub mod v1;
|
||||
|
||||
pub fn router() -> Router<Arc<State>> {
|
||||
Router::new()
|
||||
.route("/", get(docs))
|
||||
.nest("/v1", v1::router())
|
||||
}
|
||||
|
||||
async fn docs() -> &'static str {
|
||||
dedent!(
|
||||
"
|
||||
Welcome to Nyeki's API! This is a tiny API I built to provide some basic information I
|
||||
found myself needing in more than one place. This at the moment only includes Discord and
|
||||
GitHub profiles, but I may add more stuff as I see myself needing it.
|
||||
|
||||
The API is versioned, so API endpoints will always be prefixed with `/v{version}`. The
|
||||
current version is 1 (i.e. stuff is prefixed by `/v1`). All responses are JSON (or
|
||||
redirects).
|
||||
|
||||
The API is not meant to be used by anyone but me, so I don't guarantee that it will always
|
||||
be available or that it will always return the same data (nor that it's in any way reliable
|
||||
in production). You're free to use it if you want, but don't expect any serious API
|
||||
guarantees.
|
||||
|
||||
The current endpoints are:
|
||||
|
||||
GET /v1/profile
|
||||
Return's the user's profile (username, name, avatar, banner, and URL).
|
||||
|
||||
Query parameters:
|
||||
- `id`: The ID of the user you want to get the profile of (or their username
|
||||
for supported sources).
|
||||
- `source`: The source of the profile (either `discord` or `github`).
|
||||
|
||||
Response:
|
||||
```ts
|
||||
{
|
||||
// The user's full username.
|
||||
\"username\": String,
|
||||
|
||||
// The user's name to display.
|
||||
\"name\": String,
|
||||
|
||||
// The URL to the biggest version of the user's avatar. WEBP versions are
|
||||
// preferred when available.
|
||||
\"avatar_url\": String,
|
||||
|
||||
// The URL to the user's banner, if available.
|
||||
\"banner_url\": String | null,
|
||||
|
||||
// The URL to the user's profile on the source.
|
||||
\"url\": String,
|
||||
}
|
||||
```
|
||||
|
||||
GET /v1/profile/avatar
|
||||
Redirects to the user's avatar URL.
|
||||
|
||||
Query parameters:
|
||||
- `id`: The ID of the user you want to get the profile of (or their username
|
||||
for supported sources).
|
||||
- `source`: The source of the profile (either `discord` or `github`).
|
||||
|
||||
GET /v1/profile/banner
|
||||
Redirects to the user's banner URL, or returns nothing if the user has no banner.
|
||||
|
||||
Query parameters:
|
||||
- `id`: The ID of the user you want to get the profile of (or their username
|
||||
for supported sources).
|
||||
- `source`: The source of the profile (either `discord` or `github`).
|
||||
|
||||
GET /v1/profile/url
|
||||
Redirects to the user's profile URL on the source.
|
||||
|
||||
Query parameters:
|
||||
- `id`: The ID of the user you want to get the profile of (or their username
|
||||
for supported sources).
|
||||
- `source`: The source of the profile (either `discord` or `github`).
|
||||
|
||||
They all take 2 query parameters: `id` and `source`. The `id` is the ID of the user you
|
||||
want to get the profile of, and the `source` is the source of the profile. The source can
|
||||
be either `discord` or `github`.
|
||||
|
||||
For GitHub IDs, numeric IDs will be considered the user's ID, while anything else will be
|
||||
considered a username. For Discord, they're all snowflakes since there's no API to fetch
|
||||
users by username. As a sidenote, if you were to want to fetch a Discord user by username,
|
||||
I'd try to use a user token to send a friend request to the user, then read the ID from the
|
||||
pending friend requests, fetch the profile, and remove the friend request. Feel free to
|
||||
play with that (Discord won't like you automating with your user token, it's against ToS).
|
||||
|
||||
Errors are quite simple and they all follow the same structure:
|
||||
|
||||
```ts
|
||||
{
|
||||
// The HTTP status code of the error.
|
||||
\"status\": Number,
|
||||
|
||||
// A human-readable message describing the error.
|
||||
\"message\": String,
|
||||
}
|
||||
```
|
||||
|
||||
This API is open source and available at https://git.nyeki.dev/nyeki/api to self-host.
|
||||
Liked it? Found it useful? A donation would be appreciated! You can do so at
|
||||
https://ko-fi.com/nyeki.
|
||||
"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
mod schemas;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Query, State as AxumState},
|
||||
response::Redirect,
|
||||
routing::get,
|
||||
};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use turbostore::{Decode, Duration, Encode};
|
||||
|
||||
use crate::{cache::Key, state::State};
|
||||
use crate::{cache::Source, handlers::v1::schemas::Error};
|
||||
|
||||
pub fn router() -> Router<Arc<State>> {
|
||||
Router::new()
|
||||
.route("/profile", get(get_profile))
|
||||
.route("/profile/avatar", get(get_profile_avatar))
|
||||
.route("/profile/banner", get(get_profile_banner))
|
||||
.route("/profile/url", get(get_profile_url))
|
||||
.fallback(error_404)
|
||||
.method_not_allowed_fallback(error_405)
|
||||
}
|
||||
|
||||
async fn error_404() -> Error {
|
||||
Error {
|
||||
status: 404,
|
||||
message: "This endpoint does not exist. Did you make a typo?".into(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn error_405() -> Error {
|
||||
Error {
|
||||
status: 405,
|
||||
message: "This endpoint does not support this HTTP method".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Encode, Decode)]
|
||||
struct Profile {
|
||||
username: String,
|
||||
name: String,
|
||||
avatar_url: String,
|
||||
banner_url: Option<String>,
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DiscordProfile {
|
||||
id: String,
|
||||
username: String,
|
||||
global_name: Option<String>,
|
||||
discriminator: String,
|
||||
avatar: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
banner: Option<String>,
|
||||
}
|
||||
|
||||
impl From<DiscordProfile> for Profile {
|
||||
fn from(value: DiscordProfile) -> Self {
|
||||
Self {
|
||||
username: match value.discriminator.as_str() {
|
||||
"0" => value.username.clone(),
|
||||
_ => format!("{}#{}", value.username, value.discriminator),
|
||||
},
|
||||
name: value.global_name.unwrap_or(value.username),
|
||||
avatar_url: value
|
||||
.avatar
|
||||
.map(|hash| {
|
||||
format!(
|
||||
"https://cdn.discordapp.com/avatars/{}/{}.webp?size=256",
|
||||
value.id, hash
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"https://cdn.discordapp.com/embed/avatars/{}.png?size=256",
|
||||
match value.discriminator.as_str() {
|
||||
"0" => (value.id.parse::<u64>().unwrap() >> 22) % 6,
|
||||
_ => value.discriminator.parse::<u64>().unwrap() % 6,
|
||||
}
|
||||
)
|
||||
}),
|
||||
banner_url: value.banner.map(|hash| {
|
||||
format!(
|
||||
"https://cdn.discordapp.com/banners/{}/{}.webp?size=2048",
|
||||
value.id, hash
|
||||
)
|
||||
}),
|
||||
url: format!("https://discord.com/users/{}", value.id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GitHubProfile {
|
||||
login: String,
|
||||
name: Option<String>,
|
||||
avatar_url: String,
|
||||
html_url: String,
|
||||
}
|
||||
|
||||
impl From<GitHubProfile> for Profile {
|
||||
fn from(value: GitHubProfile) -> Self {
|
||||
Self {
|
||||
username: value.login.clone(),
|
||||
name: value.name.unwrap_or(value.login),
|
||||
avatar_url: value.avatar_url,
|
||||
banner_url: None,
|
||||
url: value.html_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
enum FetchError {
|
||||
Fetch,
|
||||
Status(u16),
|
||||
Parsing,
|
||||
}
|
||||
|
||||
trait ToApiError<T> {
|
||||
fn to_api(self) -> Result<T, Error>;
|
||||
}
|
||||
|
||||
impl<T> ToApiError<T> for Result<T, FetchError> {
|
||||
fn to_api(self) -> Result<T, Error> {
|
||||
self.map_err(|error| match error {
|
||||
FetchError::Fetch => Error {
|
||||
status: 500,
|
||||
message: "Could not fetch profile from external source".into(),
|
||||
},
|
||||
FetchError::Status(404) => Error {
|
||||
status: 404,
|
||||
message: "There was no user for this ID and source".into(),
|
||||
},
|
||||
FetchError::Status(_) => Error {
|
||||
status: 500,
|
||||
message: "The profile could not be fetched".into(),
|
||||
},
|
||||
FetchError::Parsing => Error {
|
||||
status: 500,
|
||||
message: "The profile could be fetched, but the data was corrupt".into(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_discord_profile(id: &str, token: &str) -> Result<Profile, FetchError> {
|
||||
let client = Client::new();
|
||||
|
||||
let profile = client
|
||||
.get(format!(
|
||||
"https://discord.com/api/v10/users/{}",
|
||||
urlencoding::encode(id)
|
||||
))
|
||||
.header("Authorization", format!("Bot {token}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| FetchError::Fetch)?
|
||||
.error_for_status()
|
||||
.map_err(|err| {
|
||||
println!("{}", err.url().unwrap());
|
||||
|
||||
FetchError::Status(err.status().unwrap().as_u16())
|
||||
})?
|
||||
.json::<DiscordProfile>()
|
||||
.await
|
||||
.map_err(|_| FetchError::Parsing)?;
|
||||
|
||||
Ok(profile.into())
|
||||
}
|
||||
|
||||
async fn fetch_github_profile(id: &str, token: &str) -> Result<Profile, FetchError> {
|
||||
let client = Client::new();
|
||||
|
||||
let profile = client
|
||||
.get(if id.parse::<u32>().is_ok() {
|
||||
// Fetch by user ID
|
||||
format!("https://api.github.com/user/{}", urlencoding::encode(id))
|
||||
} else {
|
||||
// Fetch by username
|
||||
format!("https://api.github.com/users/{}", urlencoding::encode(id))
|
||||
})
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "NyekisAPI")
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| FetchError::Fetch)?
|
||||
.error_for_status()
|
||||
.map_err(|err| {
|
||||
println!("{}", err.url().unwrap());
|
||||
|
||||
FetchError::Status(err.status().unwrap().as_u16())
|
||||
})?
|
||||
.json::<GitHubProfile>()
|
||||
.await
|
||||
.map_err(|_| FetchError::Parsing)?;
|
||||
|
||||
Ok(profile.into())
|
||||
}
|
||||
|
||||
impl ProfileIdentifier {
|
||||
async fn fetch(&self, state: &State) -> Result<Profile, FetchError> {
|
||||
match self.source {
|
||||
Source::Discord => fetch_discord_profile(&self.id, &state.discord_token).await,
|
||||
Source::GitHub => fetch_github_profile(&self.id, &state.github_token).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ProfileIdentifier {
|
||||
id: String,
|
||||
source: Source,
|
||||
}
|
||||
|
||||
async fn get_cached_profile_or_fetch(
|
||||
state: &State,
|
||||
identifier: &ProfileIdentifier,
|
||||
) -> Result<Profile, FetchError> {
|
||||
let cache = &state.cache;
|
||||
|
||||
let cached_profile = cache
|
||||
.get::<Profile>(&Key::ProfileCacheV1(
|
||||
identifier.source.clone(),
|
||||
identifier.id.clone(),
|
||||
))
|
||||
.await;
|
||||
|
||||
if let Some(profile) = cached_profile {
|
||||
Ok(profile.unwrap().value)
|
||||
} else {
|
||||
let profile = identifier.fetch(state).await?;
|
||||
|
||||
cache
|
||||
.set(
|
||||
Key::ProfileCacheV1(identifier.source.clone(), identifier.id.clone()),
|
||||
&profile,
|
||||
Duration::minutes(15),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_profile(
|
||||
AxumState(state): AxumState<Arc<State>>,
|
||||
Query(identifier): Query<ProfileIdentifier>,
|
||||
) -> Result<Json<Profile>, Error> {
|
||||
Ok(Json(
|
||||
get_cached_profile_or_fetch(&state, &identifier)
|
||||
.await
|
||||
.to_api()?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_profile_avatar(
|
||||
AxumState(state): AxumState<Arc<State>>,
|
||||
Query(identifier): Query<ProfileIdentifier>,
|
||||
) -> Result<Redirect, Error> {
|
||||
let profile = get_cached_profile_or_fetch(&state, &identifier)
|
||||
.await
|
||||
.to_api()?;
|
||||
|
||||
Ok(Redirect::to(&profile.avatar_url))
|
||||
}
|
||||
|
||||
async fn get_profile_banner(
|
||||
AxumState(state): AxumState<Arc<State>>,
|
||||
Query(identifier): Query<ProfileIdentifier>,
|
||||
) -> Result<Redirect, Error> {
|
||||
let profile = get_cached_profile_or_fetch(&state, &identifier)
|
||||
.await
|
||||
.to_api()?;
|
||||
|
||||
profile
|
||||
.banner_url
|
||||
.map(|url| Redirect::to(&url))
|
||||
.ok_or(Error {
|
||||
status: 404,
|
||||
message: "The profile does not have a banner".into(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_profile_url(
|
||||
AxumState(state): AxumState<Arc<State>>,
|
||||
Query(identifier): Query<ProfileIdentifier>,
|
||||
) -> Result<Redirect, Error> {
|
||||
let profile = get_cached_profile_or_fetch(&state, &identifier)
|
||||
.await
|
||||
.to_api()?;
|
||||
|
||||
Ok(Redirect::to(&profile.url))
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
use axum::{Json, response::IntoResponse};
|
||||
use reqwest::StatusCode;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Error {
|
||||
pub status: u16,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl IntoResponse for Error {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
(
|
||||
StatusCode::from_u16(self.status)
|
||||
.expect("The status code provided was not a valid status code"),
|
||||
Json(self),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use axum::Router;
|
||||
use clap::Parser;
|
||||
|
||||
use crate::{cache::Cache, state::State};
|
||||
|
||||
pub mod cache;
|
||||
mod handlers;
|
||||
mod state;
|
||||
|
||||
/// Simple program to greet a person
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about, long_about = None)]
|
||||
struct Args {
|
||||
/// IP and port to bind the server
|
||||
#[arg(short, long, default_value_t = String::from("0.0.0.0:8000"), env = "BIND")]
|
||||
bind: String,
|
||||
|
||||
/// Discord bot token
|
||||
#[arg(long, env = "DISCORD_TOKEN")]
|
||||
discord_token: String,
|
||||
|
||||
/// GitHub PAT with `read:user` scope
|
||||
#[arg(long, env = "GITHUB_TOKEN")]
|
||||
github_token: String,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
let state = Arc::new(State {
|
||||
cache: Cache::default(),
|
||||
discord_token: args.discord_token,
|
||||
github_token: args.github_token,
|
||||
});
|
||||
|
||||
let eviction_state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
|
||||
eviction_state.cache.evict().await;
|
||||
}
|
||||
});
|
||||
|
||||
let app = Router::new().merge(handlers::router()).with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(args.bind).await.unwrap();
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
use crate::cache::Cache;
|
||||
|
||||
pub struct State {
|
||||
pub cache: Cache,
|
||||
pub discord_token: String,
|
||||
pub github_token: String,
|
||||
}
|
||||
Reference in New Issue
Block a user