reorganised into a workspace

This commit is contained in:
Djkáťo 2024-02-27 17:50:48 +01:00
parent c37f3df8de
commit ad720187c5
26 changed files with 783 additions and 546 deletions

3
.env
View file

@ -1,6 +1,3 @@
APP_AUTHOR="Djkáťo"
APP_DESCRIPTION="A simple rust app for doing many things"
APP_NAME="Rust test app"
REQUIRED_SALEOR_VERSION="^3.13"
SALEOR_APP_ID="dummy-saleor-app-rs"
APP_API_BASE_URL="http://10.100.110.234:3000"

View file

@ -1,7 +1,6 @@
APP_NAME="Rust test app"
REQUIRED_SALEOR_VERSION=">=3.11.7 <4"
REQUIRED_SALEOR_VERSION="^3.13"
SALEOR_APP_ID="dummy-saleor-app-rs"
APP_API_BASE_URL="http://localhost:8000/graphql/"
APL="redis"
APL_URL="redis://redis:6379/2"
APP_API_BASE_URL="http://10.100.110.234:3000"
APL="Redis"
APL_URL="redis://localhost:6380/2"
LOG_LEVEL="DEBUG"

1
.gitignore vendored
View file

@ -1 +1,2 @@
/target
.env

75
Cargo.lock generated
View file

@ -134,6 +134,12 @@ version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf"
[[package]]
name = "byteorder"
version = "1.5.0"
@ -504,6 +510,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ce4ef31cda248bbdb6e6820603b82dfcd9e833db65a43e997a0ccec777d11fe"
[[package]]
name = "httparse"
version = "1.8.0"
@ -638,6 +650,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.7.2"
@ -891,7 +913,7 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa"
dependencies = [
"bitflags",
"bitflags 1.3.2",
]
[[package]]
@ -933,7 +955,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c"
[[package]]
name = "saleor-app-rs"
name = "saleor-app-sdk"
version = "0.1.0"
dependencies = [
"anyhow",
"redis",
"serde",
"serde_json",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "saleor-app-template"
version = "0.1.0"
dependencies = [
"anyhow",
@ -942,9 +976,12 @@ dependencies = [
"dotenvy",
"envy",
"redis",
"saleor-app-sdk",
"serde",
"serde_json",
"tokio",
"tower",
"tower-http",
"tracing",
"tracing-serde",
"tracing-subscriber",
@ -1213,6 +1250,31 @@ dependencies = [
"tracing",
]
[[package]]
name = "tower-http"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
dependencies = [
"bitflags 2.4.2",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-layer"
version = "0.3.2"
@ -1293,6 +1355,15 @@ dependencies = [
"tracing-log",
]
[[package]]
name = "unicase"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89"
dependencies = [
"version_check",
]
[[package]]
name = "unicode-bidi"
version = "0.3.15"

View file

@ -1,14 +1,10 @@
[package]
name = "saleor-app-rs"
version = "0.1.0"
edition = "2021"
[workspace]
members = ["saleor-app-sdk", "saleor-app-template"]
resolver = "2"
[dependencies]
[workspace.dependencies]
anyhow = "1.0.79"
#color-eyre = "0.6.2"
cynic = "3.4.3"
#dotenvy_macro = "0.15.7"
#poem = {version = "2.0.0", features = ["redis-session"]}
serde = "1.0.196"
serde_json = "1.0.113"
tokio = {version = "1.36.0", features = ["full"]}
@ -19,3 +15,6 @@ tracing-serde = "0.1.3"
tracing-subscriber = { version = "0.3.18" }
dotenvy = "0.15.7"
axum = "0.7.4"
saleor-app-sdk = {path = "saleor-app-sdk"}
tower = { version = "0.4.13", features = ["util"] }
tower-http = { version = "0.5.2", features = ["fs", "trace"] }

BIN
app logo template.xcf Normal file

Binary file not shown.

20
saleor-app-sdk/Cargo.toml Normal file
View file

@ -0,0 +1,20 @@
[package]
name = "saleor-app-sdk"
authors = ["Djkáťo <djkatovfx@gmail.com>"]
version = "0.1.0"
edition = "2021"
description = "Unofficial Saleor App SDK like library, made to work with rust."
keywords = ["saleor", "sdk", "plugin"]
categories = [ "api-bindings", "web-programming::http-server"]
homepage = "https://github.com/djkato/saleor-app-rs-template"
repository = "https://github.com/djkato/saleor-app-rs-template"
documentation = "https://github.com/djkato/saleor-app-rs-template"
license = "MIT OR Apache-2.0"
[dependencies]
anyhow.workspace = true
redis = { workspace=true, features = ["aio", "tokio-comp", "connection-manager"] }
serde.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
serde_json.workspace = true

View file

@ -0,0 +1,29 @@
use super::APL;
use anyhow::Result;
#[derive(Clone, Debug)]
/**
is not implemented yet!
*/
pub struct EnvApl {}
impl APL for EnvApl {
async fn set(&self, auth_data: crate::AuthData) -> Result<()> {
todo!()
}
async fn get(&self, saleor_api_url: &str) -> Result<crate::AuthData> {
todo!()
}
async fn get_all(&self) -> Result<Vec<crate::AuthData>> {
todo!()
}
async fn delete(&self, saleor_api_url: &str) -> Result<()> {
todo!()
}
async fn is_ready(&self) -> Result<()> {
todo!()
}
async fn is_configured(&self) -> Result<()> {
todo!()
}
}

View file

@ -0,0 +1,34 @@
use std::path::Path;
use super::APL;
use anyhow::{bail, Result};
use std::fs::{read, write};
#[derive(Clone, Debug)]
/**
is not implemented yet!
*/
pub struct FileApl {
pub path: String,
}
impl APL for FileApl {
async fn set(&self, auth_data: crate::AuthData) -> Result<()> {
todo!()
}
async fn get(&self, saleor_api_url: &str) -> Result<crate::AuthData> {
todo!()
}
async fn get_all(&self) -> Result<Vec<crate::AuthData>> {
todo!()
}
async fn delete(&self, saleor_api_url: &str) -> Result<()> {
todo!()
}
async fn is_ready(&self) -> Result<()> {
todo!()
}
async fn is_configured(&self) -> Result<()> {
todo!()
}
}

View file

@ -0,0 +1,24 @@
pub mod env_apl;
pub mod file_apl;
pub mod redis_apl;
use crate::AuthData;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::future::Future;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AplType {
Redis,
File,
Env,
}
pub trait APL: Sized + Send + Sync + Clone + std::fmt::Debug {
fn get(&self, saleor_api_url: &str) -> impl Future<Output = Result<AuthData>> + Send;
fn set(&self, auth_data: AuthData) -> impl Future<Output = Result<()>> + Send;
fn delete(&self, saleor_api_url: &str) -> impl Future<Output = Result<()>> + Send;
fn get_all(&self) -> impl Future<Output = Result<Vec<AuthData>>> + Send;
fn is_ready(&self) -> impl Future<Output = Result<()>> + Send;
fn is_configured(&self) -> impl Future<Output = Result<()>> + Send;
}

View file

@ -0,0 +1,97 @@
use std::time::Duration;
use redis::AsyncCommands;
use tracing::{debug, info};
use super::APL;
use crate::AuthData;
use anyhow::{bail, Result};
#[derive(Debug, Clone)]
pub struct RedisApl {
pub client: redis::Client,
pub app_api_base_url: String,
}
impl APL for RedisApl {
async fn get(&self, saleor_api_url: &str) -> Result<AuthData> {
debug!(" get()");
let mut conn = self.client.get_async_connection().await?;
let val: String = conn.get(self.prepare_key(saleor_api_url)).await?;
debug!("received {val}");
let val: AuthData = serde_json::from_str(&val)?;
info!("sucessful get");
debug!("parsed {val}");
Ok(val)
}
async fn set(&self, auth_data: AuthData) -> Result<()> {
debug!("set(), {}", auth_data);
let mut conn = self.client.get_async_connection().await?;
conn.set(
self.prepare_key(&auth_data.saleor_api_url),
serde_json::to_string(&auth_data)?,
)
.await?;
info!("sucessful set");
Ok(())
}
async fn delete(&self, saleor_api_url: &str) -> Result<()> {
debug!("delete(), {}", saleor_api_url);
let mut conn = self.client.get_async_connection().await?;
let val: String = conn.get_del(self.prepare_key(saleor_api_url)).await?;
debug!("sucessful delete(), {}", val);
info!("sucessful del");
Ok(())
}
async fn is_ready(&self) -> Result<()> {
debug!("is_ready()");
let mut conn = self.client.get_async_connection().await?;
let val: String = redis::cmd("INFO")
.arg("server")
.query_async(&mut conn)
.await?;
debug!("sucessful is_ready(), info: {}", val);
info!("sucessful is_ready");
Ok(())
}
async fn is_configured(&self) -> Result<()> {
debug!("is_configured()");
let mut conn = self.client.get_async_connection().await?;
let val: String = redis::cmd("INFO")
.arg("server")
.query_async(&mut conn)
.await?;
debug!("sucessful is_configured(), info: {}", val);
info!("sucessful is_configured");
Ok(())
}
async fn get_all(&self) -> Result<Vec<AuthData>> {
anyhow::bail!("Redis doens't support getall")
}
}
impl RedisApl {
pub fn new(redis_url: String, app_api_base_url: String) -> Result<Self> {
let client = redis::Client::open(redis_url)?;
let mut conn = client.get_connection_with_timeout(Duration::from_secs(3))?;
let val: Result<String, redis::RedisError> =
redis::cmd("INFO").arg("server").query(&mut conn);
match val {
Ok(_) => Ok(Self {
client,
app_api_base_url,
}),
Err(e) => bail!("failed redis connection, {:?}", e),
}
}
pub fn prepare_key(&self, saleor_api_url: &str) -> String {
let key = format!("{}:{saleor_api_url}", self.app_api_base_url);
debug!("made key:'{}'", key);
key
}
}

View file

@ -0,0 +1,6 @@
pub const SALEOR_DOMAIN_HEADER: &str = "saleor-domain";
pub const SALEOR_EVENT_HEADER: &str = "saleor-event";
pub const SALEOR_SIGNATURE_HEADER: &str = "saleor-signature";
pub const SALEOR_AUTHORIZATION_BEARER_HEADER: &str = "authorization-bearer";
pub const SALEOR_API_URL_HEADER: &str = "saleor-api-url";
pub const SALEOR_SCHEMA_VERSION: &str = "saleor-schema-version";

40
saleor-app-sdk/src/lib.rs Normal file
View file

@ -0,0 +1,40 @@
pub mod apl;
pub mod headers;
pub mod manifest;
pub mod webhooks;
use apl::APL;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthToken {
pub auth_token: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthData {
pub domain: Option<String>,
pub token: String,
pub saleor_api_url: String,
pub app_id: String,
pub jwks: Option<String>,
}
impl std::fmt::Display for AuthData {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"(domain:{}\ntoken:{}\nsaleor_api_url:{}\napp_id:{}\njwks:{})",
self.domain.clone().unwrap_or_default(),
self.token,
self.saleor_api_url,
self.app_id,
self.jwks.clone().unwrap_or_default()
)
}
}
#[derive(Debug, Clone)]
pub struct SaleorApp<A: APL> {
pub apl: A,
}

View file

@ -0,0 +1,174 @@
use serde::{Deserialize, Serialize};
use crate::webhooks::WebhookManifest;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum AppPermission {
ManageUsers,
ManageStaff,
ImpersonateUser,
ManageObservability,
ManageCheckouts,
HandleCheckouts,
HandleTaxes,
ManageTaxes,
ManageChannels,
ManageDiscounts,
ManageGiftCard,
ManageMenus,
ManageOrders,
ManagePages,
ManagePageTypesAndAttributes,
HandlePayments,
ManagePlugins,
ManageProducts,
ManageProductTypesAndAttributes,
ManageShipping,
ManageSettings,
ManageTranslations,
ManageApps,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum AppExtensionMount {
ProductDetailsMoreActions,
ProductOverviewCreate,
ProductOverviewMoreActions,
NavigationCatalog,
NavigationOrders,
NavigationCustomers,
NavigationDiscounts,
NavigationTranslations,
NavigationPages,
OrderDetailsMoreActions,
OrderOverviewCreate,
OrderOverviewMoreActions,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum AppExtensionTarget {
Popup,
AppPage,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppExtension {
/** Name which will be displayed in the dashboard */
pub label: String,
/** the place where the extension will be mounted */
pub mount: AppExtensionMount,
/** Method of presenting the interface
`POPUP` will present the interface in a modal overlay
`APP_PAGE` will navigate to the application page
@default `POPUP`
*/
pub target: AppExtensionTarget,
pub permissions: Vec<AppPermission>,
/** URL of the view to display,
you can skip the domain and protocol when target is set to `APP_PAGE`, or when your manifest defines an `appUrl`.
When target is set to `POPUP`, the url will be used to render an `<iframe>`.
*/
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppManifest {
/** ID of the application used internally by Saleor */
pub id: String,
pub version: String,
/** App's name displayed in the dashboard */
pub name: String,
/** Description of the app displayed in the dashboard */
#[serde(skip_serializing_if = "Option::is_none")]
pub about: Option<String>,
/** Array of permissions requested by the app */
pub permissions: Vec<AppPermission>,
/** App website rendered in the dashboard */
pub app_url: String,
/** Address to the app configuration page, which is rendered in the dashboard
@deprecated in Saleor 3.5, use appUrl instead
*/
#[serde(skip_serializing_if = "Option::is_none")]
pub configuration_url: Option<String>,
/** Endpoint used during process of app installation
@see [Installing an app](https://docs.saleor.io/docs/3.x/developer/extending/apps/installing-apps#installing-an-app)
*/
pub token_target_url: String,
/** Short description of privacy policy displayed in the dashboard
@deprecated in Saleor 3.5, use dataPrivacyUrl instead
*/
#[serde(skip_serializing_if = "Option::is_none")]
pub data_privacy: Option<String>,
/** URL to the full privacy policy */
#[serde(skip_serializing_if = "Option::is_none")]
pub data_privacy_url: Option<String>,
/** External URL to the app homepage */
#[serde(skip_serializing_if = "Option::is_none")]
pub homepage_url: Option<String>,
/** External URL to the page where app users can find support */
#[serde(skip_serializing_if = "Option::is_none")]
pub support_url: Option<String>,
/** List of extensions that will be mounted in Saleor's dashboard
@see For details, please see the [extension section](https://docs.saleor.io/docs/3.x/developer/extending/apps/extending-dashboard-with-apps#key-concepts)
*/
#[serde(skip_serializing_if = "Option::is_none")]
pub extensions: Option<Vec<AppExtension>>,
/** List of webhooks that will be set.
@see For details, please look at [asynchronous webhooks](https://docs.saleor.io/docs/3.x/developer/extending/apps/asynchronous-webhooks),
[synchronous-webhooks](https://docs.saleor.io/docs/3.x/developer/extending/apps/synchronous-webhooks/key-concepts)
and [webhooks' subscription](https://docs.saleor.io/docs/3.x/developer/extending/apps/subscription-webhook-payloads)
Be aware that subscription queries are required in manifest sections
*/
#[serde(skip_serializing_if = "Option::is_none")]
pub webhooks: Option<Vec<WebhookManifest>>,
/**
* Allows app installation for specific Saleor versions, using semver.
* https://github.com/npm/node-semver#versions
*
* If not set, Saleor will allow installation for every version
*
* In Saleor versions lower than 3.13, this field will be ignored
*
* Examples:
* ">=3.10" - allow for versions 3.10 or newer
* ">=3.10 <4" - allow for versions 3.10 and newer, but not 4.0 and newer
* ">=3.10 <4 || 4.0.0" - 3.10 and newer, less than 4, but allow exactly 4.0.0
*/
#[serde(skip_serializing_if = "Option::is_none")]
pub required_saleor_version: Option<String>,
/**
* App author name displayed in the dashboard
*
* In Saleor versions lower than 3.13, this field will be ignored
*/
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
/**
* Add brand-specific metadata to the app
*
* Available from Saleor 3.15. In previous versions will be ignored
*/
#[serde(skip_serializing_if = "Option::is_none")]
pub brand: Option<SaleorAppBranding>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaleorAppBranding {
pub logo: SaleorAppBrandingDefault,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaleorAppBrandingDefault {
pub default: String,
}

View file

@ -0,0 +1,165 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum AsyncWebhookEventType {
AccountConfirmationRequested,
AccountDeleteRequested,
AddressCreated,
AddressUpdated,
AddressDeleted,
AppInstalled,
AppUpdated,
AppDeleted,
AppStatusChanged,
AttributeCreated,
AttributeUpdated,
AttributeDeleted,
AttributeValueCreated,
AttributeValueUpdated,
AttributeValueDeleted,
CategoryCreated,
CategoryUpdated,
CategoryDeleted,
ChannelCreated,
ChannelUpdated,
ChannelDeleted,
ChannelStatusChanged,
GiftCardCreated,
GiftCardUpdated,
GiftCardDeleted,
GiftCardSent,
GiftCardStatusChanged,
GiftCardMetadataUpdated,
MenuCreated,
MenuUpdated,
MenuDeleted,
MenuItemCreated,
MenuItemUpdated,
MenuItemDeleted,
OrderCreated,
OrderConfirmed,
OrderPaid,
OrderFullyPaid,
OrderRefunded,
OrderFullyRefunded,
OrderUpdated,
OrderCancelled,
OrderExpired,
OrderFulfilled,
OrderMetadataUpdated,
OrderBulkCreated,
DraftOrderCreated,
DraftOrderUpdated,
DraftOrderDeleted,
SaleCreated,
SaleUpdated,
SaleDeleted,
SaleToggle,
InvoiceRequested,
InvoiceDeleted,
InvoiceSent,
CustomerCreated,
CustomerUpdated,
CustomerDeleted,
CustomerMetadataUpdated,
CollectionCreated,
CollectionUpdated,
CollectionDeleted,
CollectionMetadataUpdated,
ProductCreated,
ProductUpdated,
ProductDeleted,
ProductMediaCreated,
ProductMediaUpdated,
ProductMediaDeleted,
ProductMetadataUpdated,
ProductVariantCreated,
ProductVariantUpdated,
ProductVariantDeleted,
ProductVariantOutOfStock,
ProductVariantBackInStock,
ProductVariantStockUpdated,
ProductVariantMetadataUpdated,
CheckoutCreated,
CheckoutUpdated,
CheckoutFullyPaid,
CheckoutMetadataUpdated,
FulfillmentCreated,
FulfillmentCanceled,
FulfillmentApproved,
FulfillmentMetadataUpdated,
NotifyUser,
PageCreated,
PageUpdated,
PageDeleted,
PageTypeCreated,
PageTypeUpdated,
PageTypeDeleted,
PermissionGroupCreated,
PermissionGroupUpdated,
PermissionGroupDeleted,
ShippingPriceCreated,
ShippingPriceUpdated,
ShippingPriceDeleted,
ShippingZoneCreated,
ShippingZoneUpdated,
ShippingZoneDeleted,
ShippingZoneMetadataUpdated,
StaffCreated,
StaffUpdated,
StaffDeleted,
TransactionActionRequest,
TransactionItemMetadataUpdated,
TranslationCreated,
TranslationUpdated,
WarehouseCreated,
WarehouseUpdated,
WarehouseDeleted,
WarehouseMetadataUpdated,
VoucherCreated,
VoucherUpdated,
VoucherDeleted,
VoucherMetadataUpdated,
OBSERVABILITY,
ThumbnailCreated,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum SyncWebhookEventType {
CheckoutCalculateTaxes,
OrderCalculateTaxes,
ShippingListMethodsForCheckout,
CheckoutFilterShippingMethods,
OrderFilterShippingMethods,
TransactionChargeRequested,
TransactionRefundRequested,
TransactionCancelationRequested,
PaymentGatewayInitializeSession,
TransactionInitializeSession,
TransactionProcessSession,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WebhookManifest {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub async_events: Option<Vec<AsyncWebhookEventType>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sync_events: Option<Vec<SyncWebhookEventType>>,
/**
* Query is required for a subscription.
* If you don't need a payload, you can provide empty query like this:
*
* subscription {
* event {
* __typename
* }
* }
*/
pub query: String,
/** The full URL of the endpoint where request will be sent */
pub target_url: String,
pub is_active: Option<bool>,
}

View file

@ -0,0 +1,29 @@
[package]
name = "saleor-app-template"
version = "0.1.0"
edition = "2021"
authors = ["Djkáťo <djkatovfx@gmail.com>"]
description = "A simple rust app template for Saleor using axum"
homepage = "https://github.com/djkato/saleor-app-rs-template"
repository = "https://github.com/djkato/saleor-app-rs-template"
documentation = "https://github.com/djkato/saleor-app-rs-template"
keywords = ["saleor", "sdk", "plugin", "template"]
categories = [ "api-bindings", "web-programming::http-server"]
license = "MIT OR Apache-2.0"
[dependencies]
anyhow.workspace = true
cynic.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio = { workspace = true, features = ["full"] }
redis = { workspace = true, features = ["aio", "tokio-comp", "connection-manager"] }
envy.workspace = true
tracing.workspace = true
tracing-serde.workspace = true
tracing-subscriber.workspace = true
dotenvy.workspace = true
axum.workspace = true
saleor-app-sdk.workspace = true
tower = { workspace = true, features = ["util"] }
tower-http = { workspace = true , features = ["fs", "trace"] }

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -5,11 +5,8 @@ use axum::{
response::{IntoResponse, Response},
};
use crate::{
config::Config,
saleor::{AppManifest, SaleorApp, APL},
};
use crate::config::Config;
use saleor_app_sdk::{apl::APL, manifest::AppManifest, SaleorApp};
// Make our own error that wraps `anyhow::Error`.
pub struct AppError(anyhow::Error);

View file

@ -1,6 +1,6 @@
use saleor_app_sdk::apl::AplType;
use serde::Deserialize;
use crate::saleor::AplType;
use tracing::{debug, Level};
#[derive(Debug, Deserialize)]
@ -20,7 +20,6 @@ fn version_default() -> String {
#[derive(Deserialize, Debug, Clone)]
//#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub struct Config {
pub app_name: String,
#[serde(default = "version_default")]
pub required_saleor_version: String,
pub saleor_app_id: String,
@ -29,8 +28,6 @@ pub struct Config {
pub apl_url: String,
#[serde(with = "LocalTracingLevel")]
pub log_level: tracing::Level,
pub app_description: Option<String>,
pub app_author: Option<String>,
}
impl std::fmt::Display for Config {

View file

@ -1,8 +1,13 @@
mod app;
mod config;
mod routes;
mod saleor;
use saleor_app_sdk::{
apl::{env_apl::EnvApl, file_apl::FileApl, redis_apl::RedisApl, AplType, APL},
manifest::{AppManifest, AppPermission, SaleorAppBranding, SaleorAppBrandingDefault},
webhooks::{AsyncWebhookEventType, WebhookManifest},
SaleorApp,
};
use std::sync::Arc;
use tokio::sync::Mutex;
@ -10,25 +15,29 @@ use crate::{
app::{trace_to_std, AppState},
config::Config,
routes::create_routes,
saleor::{
AppManifest, AppPermission, AsyncWebhookEventType, RedisApl, SaleorApp, WebhookManifest,
},
};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let config = Config::load()?;
trace_to_std(&config);
let saleor_app = SaleorApp::<RedisApl> {
apl: RedisApl::new(config.apl_url.clone(), config.app_api_base_url.clone())?,
let apl: Box<dyn APL> = match config.apl {
AplType::File => FileApl {
path: "apl.json".to_owned(),
},
AplType::Redis => RedisApl::new(config.apl_url, config.app_api_base_url),
AplType::Env => EnvApl {},
};
let saleor_app = SaleorApp { apl };
let app_manifest = AppManifest {
id: config.saleor_app_id.clone(),
required_saleor_version: Some(config.required_saleor_version.clone()),
name: config.app_name.clone(),
about: config.app_description.clone(),
brand: None,
author: config.app_author.clone(),
name: env!("CARGO_PKG_NAME").to_owned(),
about: Some(env!("CARGO_PKG_DESCRIPTION").to_owned()),
author: Some(env!("CARGO_PKG_AUTHORS").to_owned()),
version: env!("CARGO_PKG_VERSION").to_owned(),
app_url: config.app_api_base_url.clone(),
token_target_url: format!("{}/api/register", config.app_api_base_url.clone()),
@ -36,9 +45,14 @@ async fn main() -> anyhow::Result<()> {
permissions: vec![AppPermission::ManageProducts],
support_url: None,
data_privacy: None,
homepage_url: None,
homepage_url: Some(env!("CARGO_PKG_HOMEPAGE").to_owned()),
data_privacy_url: None,
configuration_url: None,
brand: Some(SaleorAppBranding {
logo: SaleorAppBrandingDefault {
default: format!("{}/logo.png", config.app_api_base_url),
},
}),
webhooks: Some(vec![WebhookManifest {
name: "GetProducts for demo rust app".to_owned(),
query: r#"
@ -66,10 +80,18 @@ async fn main() -> anyhow::Result<()> {
config,
saleor_app: Arc::new(Mutex::new(saleor_app)),
};
let app = create_routes(app_state);
/* Router::new()
.route("/api/manifest", get(manifest))
.route("/api/register", post(register))
.with_state(app_state);
*/
// let app = create_routes(app_state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
tracing::debug!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app);
Ok(())
match axum::serve(listener, app).await {
Ok(o) => Ok(o),
Err(e) => anyhow::bail!(e),
}
}

View file

@ -1,9 +1,7 @@
use axum::{extract::State, Json};
use saleor_app_sdk::{apl::APL, manifest::AppManifest};
use crate::{
app::{AppError, AppState},
saleor::{AppManifest, APL},
};
use crate::app::{AppError, AppState};
pub async fn manifest<A: APL>(
State(state): State<AppState<A>>,

View file

@ -0,0 +1,35 @@
use axum::{
handler::HandlerWithoutStateExt,
http::StatusCode,
routing::{get, post},
Router,
};
use saleor_app_sdk::apl::APL;
use tower_http::services::ServeDir;
use crate::app::AppState;
pub mod manifest;
pub mod register;
use manifest::manifest;
use register::register;
pub fn create_routes<T: APL + 'static>(state: AppState<T>) -> Router {
async fn handle_404() -> (StatusCode, &'static str) {
(StatusCode::NOT_FOUND, "Not found")
}
let service = handle_404.into_service();
let serve_dir = ServeDir::new("saleor-app-template/public").not_found_service(service);
Router::new()
//handles just path, eg. localhost:3000/
.route(
"/",
get(|| async { "Your app got installed successfully!" }),
)
//handles files, eg. localhost:3000/logo.png
.fallback_service(serve_dir)
.route("/api/manifest", get(manifest))
.route("/api/register", post(register))
.with_state(state)
}

View file

@ -1,21 +1,18 @@
use anyhow::Context;
use axum::{
extract::Json,
extract::State,
http::{HeaderMap, StatusCode},
response::Json,
};
use saleor_app_sdk::{apl::APL, AuthData, AuthToken};
use tracing::{debug, info};
use crate::{
app::AppError,
saleor::{AuthData, AuthToken, APL},
AppState,
};
use crate::app::{AppError, AppState};
pub async fn register<A: APL>(
headers: HeaderMap,
Json(auth_token): Json<AuthToken>,
State(state): State<AppState<A>>,
Json(auth_token): Json<AuthToken>,
) -> Result<StatusCode, AppError> {
debug!(
"/api/register:\nsaleor_api_url:{:?}\nauth_token:{:?}",

View file

@ -1,17 +0,0 @@
mod manifest;
mod register;
use axum::{
routing::{get, post},
Router,
};
use manifest::manifest;
use register::register;
use crate::{app::AppState, saleor::APL};
pub fn create_routes<T: APL>(state: AppState<T>) -> Router<AppState<T>> {
Router::new()
.route("/api/manifest", get(manifest))
.route("/api/register", post(register))
.with_state(state)
}

View file

@ -1,477 +0,0 @@
use std::time::Duration;
use anyhow::{bail, Result};
use redis::{AsyncCommands, Commands, ConnectionLike, RedisError};
use serde::{Deserialize, Serialize};
use tracing::{debug, info};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthToken {
pub auth_token: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum AsyncWebhookEventType {
AccountConfirmationRequested,
AccountDeleteRequested,
AddressCreated,
AddressUpdated,
AddressDeleted,
AppInstalled,
AppUpdated,
AppDeleted,
AppStatusChanged,
AttributeCreated,
AttributeUpdated,
AttributeDeleted,
AttributeValueCreated,
AttributeValueUpdated,
AttributeValueDeleted,
CategoryCreated,
CategoryUpdated,
CategoryDeleted,
ChannelCreated,
ChannelUpdated,
ChannelDeleted,
ChannelStatusChanged,
GiftCardCreated,
GiftCardUpdated,
GiftCardDeleted,
GiftCardSent,
GiftCardStatusChanged,
GiftCardMetadataUpdated,
MenuCreated,
MenuUpdated,
MenuDeleted,
MenuItemCreated,
MenuItemUpdated,
MenuItemDeleted,
OrderCreated,
OrderConfirmed,
OrderPaid,
OrderFullyPaid,
OrderRefunded,
OrderFullyRefunded,
OrderUpdated,
OrderCancelled,
OrderExpired,
OrderFulfilled,
OrderMetadataUpdated,
OrderBulkCreated,
DraftOrderCreated,
DraftOrderUpdated,
DraftOrderDeleted,
SaleCreated,
SaleUpdated,
SaleDeleted,
SaleToggle,
InvoiceRequested,
InvoiceDeleted,
InvoiceSent,
CustomerCreated,
CustomerUpdated,
CustomerDeleted,
CustomerMetadataUpdated,
CollectionCreated,
CollectionUpdated,
CollectionDeleted,
CollectionMetadataUpdated,
ProductCreated,
ProductUpdated,
ProductDeleted,
ProductMediaCreated,
ProductMediaUpdated,
ProductMediaDeleted,
ProductMetadataUpdated,
ProductVariantCreated,
ProductVariantUpdated,
ProductVariantDeleted,
ProductVariantOutOfStock,
ProductVariantBackInStock,
ProductVariantStockUpdated,
ProductVariantMetadataUpdated,
CheckoutCreated,
CheckoutUpdated,
CheckoutFullyPaid,
CheckoutMetadataUpdated,
FulfillmentCreated,
FulfillmentCanceled,
FulfillmentApproved,
FulfillmentMetadataUpdated,
NotifyUser,
PageCreated,
PageUpdated,
PageDeleted,
PageTypeCreated,
PageTypeUpdated,
PageTypeDeleted,
PermissionGroupCreated,
PermissionGroupUpdated,
PermissionGroupDeleted,
ShippingPriceCreated,
ShippingPriceUpdated,
ShippingPriceDeleted,
ShippingZoneCreated,
ShippingZoneUpdated,
ShippingZoneDeleted,
ShippingZoneMetadataUpdated,
StaffCreated,
StaffUpdated,
StaffDeleted,
TransactionActionRequest,
TransactionItemMetadataUpdated,
TranslationCreated,
TranslationUpdated,
WarehouseCreated,
WarehouseUpdated,
WarehouseDeleted,
WarehouseMetadataUpdated,
VoucherCreated,
VoucherUpdated,
VoucherDeleted,
VoucherMetadataUpdated,
OBSERVABILITY,
ThumbnailCreated,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum SyncWebhookEventType {
CheckoutCalculateTaxes,
OrderCalculateTaxes,
ShippingListMethodsForCheckout,
CheckoutFilterShippingMethods,
OrderFilterShippingMethods,
TransactionChargeRequested,
TransactionRefundRequested,
TransactionCancelationRequested,
PaymentGatewayInitializeSession,
TransactionInitializeSession,
TransactionProcessSession,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WebhookManifest {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub async_events: Option<Vec<AsyncWebhookEventType>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sync_events: Option<Vec<SyncWebhookEventType>>,
/**
* Query is required for a subscription.
* If you don't need a payload, you can provide empty query like this:
*
* subscription {
* event {
* __typename
* }
* }
*/
pub query: String,
/** The full URL of the endpoint where request will be sent */
pub target_url: String,
pub is_active: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum AppPermission {
ManageUsers,
ManageStaff,
ImpersonateUser,
ManageObservability,
ManageCheckouts,
HandleCheckouts,
HandleTaxes,
ManageTaxes,
ManageChannels,
ManageDiscounts,
ManageGiftCard,
ManageMenus,
ManageOrders,
ManagePages,
ManagePageTypesAndAttributes,
HandlePayments,
ManagePlugins,
ManageProducts,
ManageProductTypesAndAttributes,
ManageShipping,
ManageSettings,
ManageTranslations,
ManageApps,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum AppExtensionMount {
ProductDetailsMoreActions,
ProductOverviewCreate,
ProductOverviewMoreActions,
NavigationCatalog,
NavigationOrders,
NavigationCustomers,
NavigationDiscounts,
NavigationTranslations,
NavigationPages,
OrderDetailsMoreActions,
OrderOverviewCreate,
OrderOverviewMoreActions,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum AppExtensionTarget {
Popup,
AppPage,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppExtension {
/** Name which will be displayed in the dashboard */
pub label: String,
/** the place where the extension will be mounted */
pub mount: AppExtensionMount,
/** Method of presenting the interface
`POPUP` will present the interface in a modal overlay
`APP_PAGE` will navigate to the application page
@default `POPUP`
*/
pub target: AppExtensionTarget,
pub permissions: Vec<AppPermission>,
/** URL of the view to display,
you can skip the domain and protocol when target is set to `APP_PAGE`, or when your manifest defines an `appUrl`.
When target is set to `POPUP`, the url will be used to render an `<iframe>`.
*/
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppManifest {
/** ID of the application used internally by Saleor */
pub id: String,
pub version: String,
/** App's name displayed in the dashboard */
pub name: String,
/** Description of the app displayed in the dashboard */
#[serde(skip_serializing_if = "Option::is_none")]
pub about: Option<String>,
/** Array of permissions requested by the app */
pub permissions: Vec<AppPermission>,
/** App website rendered in the dashboard */
pub app_url: String,
/** Address to the app configuration page, which is rendered in the dashboard
@deprecated in Saleor 3.5, use appUrl instead
*/
#[serde(skip_serializing_if = "Option::is_none")]
pub configuration_url: Option<String>,
/** Endpoint used during process of app installation
@see [Installing an app](https://docs.saleor.io/docs/3.x/developer/extending/apps/installing-apps#installing-an-app)
*/
pub token_target_url: String,
/** Short description of privacy policy displayed in the dashboard
@deprecated in Saleor 3.5, use dataPrivacyUrl instead
*/
#[serde(skip_serializing_if = "Option::is_none")]
pub data_privacy: Option<String>,
/** URL to the full privacy policy */
#[serde(skip_serializing_if = "Option::is_none")]
pub data_privacy_url: Option<String>,
/** External URL to the app homepage */
#[serde(skip_serializing_if = "Option::is_none")]
pub homepage_url: Option<String>,
/** External URL to the page where app users can find support */
#[serde(skip_serializing_if = "Option::is_none")]
pub support_url: Option<String>,
/** List of extensions that will be mounted in Saleor's dashboard
@see For details, please see the [extension section](https://docs.saleor.io/docs/3.x/developer/extending/apps/extending-dashboard-with-apps#key-concepts)
*/
#[serde(skip_serializing_if = "Option::is_none")]
pub extensions: Option<Vec<AppExtension>>,
/** List of webhooks that will be set.
@see For details, please look at [asynchronous webhooks](https://docs.saleor.io/docs/3.x/developer/extending/apps/asynchronous-webhooks),
[synchronous-webhooks](https://docs.saleor.io/docs/3.x/developer/extending/apps/synchronous-webhooks/key-concepts)
and [webhooks' subscription](https://docs.saleor.io/docs/3.x/developer/extending/apps/subscription-webhook-payloads)
Be aware that subscription queries are required in manifest sections
*/
#[serde(skip_serializing_if = "Option::is_none")]
pub webhooks: Option<Vec<WebhookManifest>>,
/**
* Allows app installation for specific Saleor versions, using semver.
* https://github.com/npm/node-semver#versions
*
* If not set, Saleor will allow installation for every version
*
* In Saleor versions lower than 3.13, this field will be ignored
*
* Examples:
* ">=3.10" - allow for versions 3.10 or newer
* ">=3.10 <4" - allow for versions 3.10 and newer, but not 4.0 and newer
* ">=3.10 <4 || 4.0.0" - 3.10 and newer, less than 4, but allow exactly 4.0.0
*/
#[serde(skip_serializing_if = "Option::is_none")]
pub required_saleor_version: Option<String>,
/**
* App author name displayed in the dashboard
*
* In Saleor versions lower than 3.13, this field will be ignored
*/
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
/**
* Add brand-specific metadata to the app
*
* Available from Saleor 3.15. In previous versions will be ignored
*/
#[serde(skip_serializing_if = "Option::is_none")]
pub brand: Option<SaleorAppBranding>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaleorAppBranding {
pub logo: SaleorAppBrandingDefault,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaleorAppBrandingDefault {
pub default: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthData {
pub domain: Option<String>,
pub token: String,
pub saleor_api_url: String,
pub app_id: String,
pub jwks: Option<String>,
}
impl std::fmt::Display for AuthData {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"(domain:{}\ntoken:{}\nsaleor_api_url:{}\napp_id:{}\njwks:{})",
self.domain.clone().unwrap_or_default(),
self.token,
self.saleor_api_url,
self.app_id,
self.jwks.clone().unwrap_or_default()
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AplType {
Redis,
}
pub trait APL: Sized + Send + Sync + Clone + std::fmt::Debug {
async fn get(&self, saleor_api_url: &str) -> Result<AuthData>;
async fn set(&self, auth_data: AuthData) -> Result<()>;
async fn delete(&self, saleor_api_url: &str) -> Result<()>;
async fn get_all(&self) -> Result<Vec<AuthData>>;
async fn is_ready(&self) -> Result<()>;
async fn is_configured(&self) -> Result<()>;
}
#[derive(Debug, Clone)]
pub struct SaleorApp<A: APL> {
pub apl: A,
}
#[derive(Debug, Clone)]
pub struct RedisApl {
pub client: redis::Client,
pub app_api_base_url: String,
}
impl APL for RedisApl {
async fn get(&self, saleor_api_url: &str) -> Result<AuthData> {
debug!(" get()");
let mut conn = self.client.get_async_connection().await?;
let val: String = conn.get(self.prepare_key(saleor_api_url)).await?;
debug!("received {val}");
let val: AuthData = serde_json::from_str(&val)?;
info!("sucessful get");
debug!("parsed {val}");
Ok(val)
}
async fn set(&self, auth_data: AuthData) -> Result<()> {
debug!("set(), {}", auth_data);
let mut conn = self.client.get_async_connection().await?;
conn.set(
self.prepare_key(&auth_data.saleor_api_url),
serde_json::to_string(&auth_data)?,
)
.await?;
info!("sucessful set");
Ok(())
}
async fn delete(&self, saleor_api_url: &str) -> Result<()> {
debug!("delete(), {}", saleor_api_url);
let mut conn = self.client.get_async_connection().await?;
let val: String = conn.get_del(self.prepare_key(saleor_api_url)).await?;
debug!("sucessful delete(), {}", val);
info!("sucessful del");
Ok(())
}
async fn is_ready(&self) -> Result<()> {
debug!("is_ready()");
let mut conn = self.client.get_async_connection().await?;
let val: String = redis::cmd("INFO")
.arg("server")
.query_async(&mut conn)
.await?;
debug!("sucessful is_ready(), info: {}", val);
info!("sucessful is_ready");
Ok(())
}
async fn is_configured(&self) -> Result<()> {
debug!("is_configured()");
let mut conn = self.client.get_async_connection().await?;
let val: String = redis::cmd("INFO")
.arg("server")
.query_async(&mut conn)
.await?;
debug!("sucessful is_configured(), info: {}", val);
info!("sucessful is_configured");
Ok(())
}
async fn get_all(&self) -> Result<Vec<AuthData>> {
anyhow::bail!("Redis doens't support getall")
}
}
impl RedisApl {
pub fn new(redis_url: String, app_api_base_url: String) -> Result<Self> {
let client = redis::Client::open(redis_url)?;
let mut conn = client.get_connection_with_timeout(Duration::from_secs(3))?;
let val: Result<String, redis::RedisError> =
redis::cmd("INFO").arg("server").query(&mut conn);
match val {
Ok(_) => Ok(Self {
client,
app_api_base_url,
}),
Err(e) => bail!("failed redis connection, {:?}", e),
}
}
pub fn prepare_key(&self, saleor_api_url: &str) -> String {
let key = format!("{}:{saleor_api_url}", self.app_api_base_url);
debug!("made key:'{}'", key);
key
}
}