fix logging, docker building- sitemap args

This commit is contained in:
Djkáťo 2024-03-18 16:12:39 +01:00
parent b69cbf840e
commit 47e18919f0
21 changed files with 238 additions and 58 deletions

View file

@ -1,6 +1,11 @@
target/ target/
tmp/ tmp/
.git
.github
volumes/
temp/
Cargo.lock Cargo.lock
Dockerfile Dockerfile
Makefile.toml Makefile.toml
rust-toolchain.toml

1
.gitignore vendored
View file

@ -2,6 +2,7 @@
.env .env
temp temp
temp/**.* temp/**.*
volumes/
# Allow # Allow
!.env.example !.env.example

7
Cargo.lock generated
View file

@ -2133,6 +2133,12 @@ dependencies = [
"sha2 0.10.8", "sha2 0.10.8",
] ]
[[package]]
name = "pico-args"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]] [[package]]
name = "pin-project" name = "pin-project"
version = "1.1.4" version = "1.1.4"
@ -2983,6 +2989,7 @@ dependencies = [
"envy", "envy",
"fd-lock", "fd-lock",
"flate2", "flate2",
"pico-args",
"quick-xml", "quick-xml",
"redis", "redis",
"saleor-app-sdk", "saleor-app-sdk",

View file

@ -1,24 +1,28 @@
FROM rust:alpine as chef FROM rust:latest as chef
RUN apk add musl-dev pkgconfig openssl openssl-dev RUN apt-get update -y && \
ENV OPENSSL_DIR=/usr apt-get install -y pkg-config libssl-dev
# RUN rustup default nightly # ENV OPENSSL_DIR=/usr
# RUN rustup target add x86_64-unknown-linux-musl RUN rustup default nightly
RUN cargo install cargo-chef RUN cargo install cargo-chef
WORKDIR /src WORKDIR /apps
FROM chef as planner FROM chef as planner
COPY . . COPY . .
RUN cargo chef prepare --recipe-path recipe.json RUN cargo chef prepare --recipe-path recipe.json
FROM chef as builder FROM chef as builder
COPY --from=planner /src/recipe.json recipe.json COPY --from=planner /apps/recipe.json recipe.json
RUN cargo chef cook --target=x86_64-unknown-linux-musl --release --recipe-path=recipe.json #--target=x86_64-unknown-linux-musl
RUN cargo chef cook --release --recipe-path=recipe.json
COPY . . COPY . .
RUN cargo build --target=x86_64-unknown-linux-musl --release RUN cargo build --release
FROM scratch as chef-sitemap-generator FROM debian:bookworm-slim as chef-sitemap-generator
COPY --from=builder /src/target/x86_64-unknown-linux-musl/release/sitemap-generator /sitemap-generator COPY --from=builder /apps/target/release/sitemap-generator /sitemap-generator
CMD [ "/sitemap-generator" ] RUN apt-get update -y && \
apt-get install -y pkg-config libssl-dev curl
RUN mkdir /sitemaps
CMD [ "./sitemap-generator" ]
LABEL service=chef-sitemap-generator LABEL service=chef-sitemap-generator
LABEL org.opencontainers.image.title="djkato/saleor-sitemap-generator"\ LABEL org.opencontainers.image.title="djkato/saleor-sitemap-generator"\
org.opencontainers.image.description="Creates and keeps Sitemap.xml uptodate with Saleor." \ org.opencontainers.image.description="Creates and keeps Sitemap.xml uptodate with Saleor." \
@ -27,9 +31,11 @@ LABEL org.opencontainers.image.title="djkato/saleor-sitemap-generator"\
org.opencontainers.image.authors="Djkáťo <djkatovfx@gmail.com>"\ org.opencontainers.image.authors="Djkáťo <djkatovfx@gmail.com>"\
org.opencontainers.image.licenses="PolyForm-Noncommercial-1.0.0" org.opencontainers.image.licenses="PolyForm-Noncommercial-1.0.0"
FROM scratch as chef-simple-payment-gateway FROM debian:bookworm-slim as chef-simple-payment-gateway
COPY --from=builder /src/target/x86_64-unknown-linux-musl/release/simple-payment-gateway /simple-payment-gateway COPY --from=builder /apps/target/release/simple-payment-gateway /simple-payment-gateway
CMD [ "/simple-payment-gateway" ] RUN apt-get update -y && \
apt-get install -y pkg-config libssl-dev curl
CMD [ "./simple-payment-gateway" ]
LABEL service=chef-simple-payment-gateway LABEL service=chef-simple-payment-gateway
LABEL org.opencontainers.image.title="djkato/saleor-simple-payment-gateway"\ LABEL org.opencontainers.image.title="djkato/saleor-simple-payment-gateway"\
org.opencontainers.image.description="Payment gateway that adds payment methods that don't need actual verification: Cash on delivery, Cash on warehouse pickup, bank tranfer." \ org.opencontainers.image.description="Payment gateway that adds payment methods that don't need actual verification: Cash on delivery, Cash on warehouse pickup, bank tranfer." \

View file

@ -22,7 +22,11 @@ docker tag $(docker image ls -q --filter=label=service=chef-simple-payment-gatew
[tasks.build-containers] [tasks.build-containers]
workspace = false workspace = false
dependencies = ["build-sitemap-generator", "build-simple-payment-gateway"] dependencies = [
"delete-images",
"build-sitemap-generator",
"build-simple-payment-gateway",
]
[tasks.push-containers] [tasks.push-containers]
workspace = false workspace = false
@ -31,9 +35,9 @@ docker push ghcr.io/djkato/saleor-sitemap-generator:latest
docker push ghcr.io/djkato/saleor-simple-payment-gateway:latest docker push ghcr.io/djkato/saleor-simple-payment-gateway:latest
''' '''
[tasks.delete-containers] [tasks.delete-images]
workspace = false workspace = false
script = ''' script = '''
docker image rm $(docker image ls -q --filter=label=service=chef-simple-payment-gateway) docker rmi -f $(docker image ls -q --filter=label=service=chef-sitemap-generator) 2>&1 || true
docker image rm $(docker image ls -q --filter=label=service=chef-sitemap-generator) docker rmi -f $(docker image ls -q --filter=label=service=chef-simple-payment-gateway) 2>&1 || true
''' '''

View file

@ -6,6 +6,8 @@ use axum::{
}; };
use saleor_app_sdk::{config::Config, manifest::AppManifest, SaleorApp}; use saleor_app_sdk::{config::Config, manifest::AppManifest, SaleorApp};
use tracing::level_filters::LevelFilter;
use tracing_subscriber::EnvFilter;
// Make our own error that wraps `anyhow::Error`. // Make our own error that wraps `anyhow::Error`.
pub struct AppError(anyhow::Error); pub struct AppError(anyhow::Error);
@ -32,9 +34,20 @@ where
} }
pub fn trace_to_std(config: &Config) { pub fn trace_to_std(config: &Config) {
let filter = EnvFilter::builder()
.with_default_directive(LevelFilter::DEBUG.into())
.from_env()
.unwrap()
.add_directive(
format!("{}={}", env!("CARGO_PKG_NAME"), config.log_level)
.parse()
.unwrap(),
);
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_max_level(config.log_level) .with_max_level(config.log_level)
.with_target(false) .with_env_filter(filter)
.with_target(true)
.compact()
.init(); .init();
} }

61
docker-compose.yml Normal file
View file

@ -0,0 +1,61 @@
services:
app-payment-gateway:
image: ghcr.io/djkato/saleor-simple-payment-gateway:latest
tty: true
restart: unless-stopped
stdin_open: true
env_file:
- docker-gateway.env
networks:
- saleor-app-tier
depends_on:
- redis-apl
ports:
- 3001:3001
app-sitemap-generator:
tty: true
restart: unless-stopped
stdin_open: true
image: ghcr.io/djkato/saleor-sitemap-generator:latest
env_file:
- docker-sitemap.env
networks:
- saleor-app-tier
depends_on:
- redis-apl
ports:
- 3002:3002
volumes:
- sitemaps:/sitemaps
redis-apl:
image: bitnami/redis:latest
environment:
- ALLOW_EMPTY_PASSWORD=yes
- DISABLE_COMMANDS=FLUSHDB,FLUSHALL,CONFIG
ports:
- 6380:6379
restart: unless-stopped
networks:
- saleor-app-tier
volumes:
- redis-apl:/bitnami/redis/data
volumes:
redis-apl:
driver: local
driver_opts:
type: none
device: ./temp/volumes/redis/
o: bind
sitemaps:
driver: local
driver_opts:
type: none
device: ./temp/docker-sitemaps/
o: bind
networks:
saleor-app-tier:
driver: bridge

25
docker-gateway.env Normal file
View file

@ -0,0 +1,25 @@
## COMMON VARIABLES FOR ALL APPS
REQUIRED_SALEOR_VERSION="^3.13"
APP_API_BASE_URL="http://0.0.0.0:3001"
APL="Redis"
APL_URL="redis://redis-apl:6379/1"
LOG_LEVEL="DEBUG"
## THESE VARIABLES ARE FOR SITEMAP-GENERATOR APP
SITEMAP_TARGET_FOLDER="./temp"
# Available fields can be found in ./sitemap-generator/src/queries/event_subjects_updated.rs: ProductUpdate
SITEMAP_PRODUCT_TEMPLATE="https://example.com/{product.category.slug}/{product.slug}"
# Available fields can be found in ./sitemap-generator/src/queries/event_subjects_updated.rs: CategoryUpdate
SITEMAP_CATEGORY_TEMPLATE="https://example.com/{category.slug}"
# Available fields can be found in ./sitemap-generator/src/queries/event_subjects_updated.rs: CollectionUpdate
SITEMAP_COLLECTION_TEMPLATE="https://example.com/collection/{collection.slug}"
# Available fields can be found in ./sitemap-generator/src/queries/event_subjects_updated.rs: PageUpdate
SITEMAP_PAGES_TEMPLATE="https://example.com/{page.slug}"
# Without trailing "/"!
SITEMAP_INDEX_HOSTNAME="https://example.com"
## THESE VARIABLES ARE FOR SIMPLE-PAYMENT-GATEWAY APP
#To see all possible options, check simple-payment-gateway/src/app:GatewayTypes
ACTIVE_GATEWAYS="cod,cash,transfer"
# only SK,EN available :). Determines what language the gateway names will be in storefront
LOCALE="SK"

25
docker-sitemap.env Normal file
View file

@ -0,0 +1,25 @@
## COMMON VARIABLES FOR ALL APPS
REQUIRED_SALEOR_VERSION="^3.13"
APP_API_BASE_URL="http://0.0.0.0:3002"
APL="Redis"
APL_URL="redis://redis-apl:6379/1"
LOG_LEVEL="DEBUG"
## THESE VARIABLES ARE FOR SITEMAP-GENERATOR APP
SITEMAP_TARGET_FOLDER="./sitemaps"
# Available fields can be found in ./sitemap-generator/src/queries/event_subjects_updated.rs: ProductUpdate
SITEMAP_PRODUCT_TEMPLATE="https://example.com/{product.category.slug}/{product.slug}"
# Available fields can be found in ./sitemap-generator/src/queries/event_subjects_updated.rs: CategoryUpdate
SITEMAP_CATEGORY_TEMPLATE="https://example.com/{category.slug}"
# Available fields can be found in ./sitemap-generator/src/queries/event_subjects_updated.rs: CollectionUpdate
SITEMAP_COLLECTION_TEMPLATE="https://example.com/collection/{collection.slug}"
# Available fields can be found in ./sitemap-generator/src/queries/event_subjects_updated.rs: PageUpdate
SITEMAP_PAGES_TEMPLATE="https://example.com/{page.slug}"
# Without trailing "/"!
SITEMAP_INDEX_HOSTNAME="https://example.com"
## THESE VARIABLES ARE FOR SIMPLE-PAYMENT-GATEWAY APP
#To see all possible options, check simple-payment-gateway/src/app:GatewayTypes
ACTIVE_GATEWAYS="cod,cash,transfer"
# only SK,EN available :). Determines what language the gateway names will be in storefront
LOCALE="SK"

View file

@ -1,4 +1,3 @@
[toolchain] [toolchain]
channel = "nightly" channel = "nightly"
#targets = ["x86_64-unknown-linux-musl"]
targets = ["x86_64-unknown-linux-gnu"] targets = ["x86_64-unknown-linux-gnu"]

View file

@ -78,7 +78,7 @@ impl APL for RedisApl {
impl RedisApl { impl RedisApl {
pub fn new(redis_url: &str, app_api_base_url: &str) -> Result<Self> { pub fn new(redis_url: &str, app_api_base_url: &str) -> Result<Self> {
debug!("creating redis apl..."); debug!("creating redis apl with {redis_url}...");
let client = redis::Client::open(redis_url)?; let client = redis::Client::open(redis_url)?;
let mut conn = client.get_connection_with_timeout(Duration::from_secs(3))?; let mut conn = client.get_connection_with_timeout(Duration::from_secs(3))?;
let val: Result<String, redis::RedisError> = let val: Result<String, redis::RedisError> =

View file

@ -38,11 +38,9 @@ impl std::fmt::Display for Config {
impl Config { impl Config {
pub fn load() -> Result<Self, envy::Error> { pub fn load() -> Result<Self, envy::Error> {
dotenvy::dotenv().unwrap(); _ = dotenvy::dotenv();
let env = envy::from_env::<Config>(); let env = envy::from_env::<Config>();
if let Ok(e) = &env { debug!("{:?}", &env);
debug!("{}", e);
}
env env
} }
} }

View file

@ -220,17 +220,17 @@ pub struct PaymentMethod<T: Serialize> {
#[derive(Serialize, Debug, Clone)] #[derive(Serialize, Debug, Clone)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CreditCardInfo { pub struct CreditCardInfo {
brand: String, pub brand: String,
last_digits: String, pub last_digits: String,
exp_month: String, pub exp_month: String,
exp_year: String, pub exp_year: String,
first_digits: Option<String>, pub first_digits: Option<String>,
} }
#[derive(Serialize, Debug, Clone)] #[derive(Serialize, Debug, Clone)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ListStoredPaymentMethodsResponse<T: Serialize, C: Serialize> { pub struct ListStoredPaymentMethodsResponse<T: Serialize, C: Serialize> {
payment_methods: Vec<PaymentMethod<C>>, pub payment_methods: Vec<PaymentMethod<C>>,
name: Option<String>, pub name: Option<String>,
data: Option<T>, pub data: Option<T>,
} }

View file

@ -3,12 +3,12 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use enum_iterator::{all, Sequence}; use enum_iterator::{all, Sequence};
use std::{sync::Arc}; use std::sync::Arc;
use tracing::level_filters::LevelFilter;
use tracing_subscriber::EnvFilter;
use saleor_app_sdk::{config::Config, locales::LocaleCode, manifest::AppManifest, SaleorApp}; use saleor_app_sdk::{config::Config, locales::LocaleCode, manifest::AppManifest, SaleorApp};
use serde::{ use serde::Serialize;
Serialize,
};
// Make our own error that wraps `anyhow::Error`. // Make our own error that wraps `anyhow::Error`.
pub struct AppError(anyhow::Error); pub struct AppError(anyhow::Error);
@ -35,9 +35,20 @@ where
} }
pub fn trace_to_std(config: &Config) { pub fn trace_to_std(config: &Config) {
let filter = EnvFilter::builder()
.with_default_directive(LevelFilter::DEBUG.into())
.from_env()
.unwrap()
.add_directive(
format!("{}={}", env!("CARGO_PKG_NAME"), config.log_level)
.parse()
.unwrap(),
);
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_max_level(config.log_level) .with_max_level(config.log_level)
.with_target(false) .with_env_filter(filter)
.with_target(true)
.compact()
.init(); .init();
} }
@ -64,7 +75,7 @@ pub struct AppState {
} }
pub fn get_active_gateways_from_env() -> anyhow::Result<Vec<ActiveGateway>> { pub fn get_active_gateways_from_env() -> anyhow::Result<Vec<ActiveGateway>> {
dotenvy::dotenv()?; _ = dotenvy::dotenv();
//eg: "accreditation,cod,other,transfer" //eg: "accreditation,cod,other,transfer"
let env_types = std::env::var("ACTIVE_GATEWAYS")?; let env_types = std::env::var("ACTIVE_GATEWAYS")?;
let locale = std::env::var("LOCALE")?; let locale = std::env::var("LOCALE")?;

View file

@ -19,8 +19,9 @@ use crate::{
app::{get_active_gateways_from_env, trace_to_std, AppState}, app::{get_active_gateways_from_env, trace_to_std, AppState},
queries::event_transactions::{ queries::event_transactions::{
sub_list_payment_gateways, sub_payment_gateway_initialize_session, sub_list_payment_gateways, sub_payment_gateway_initialize_session,
sub_transaction_charge_requested, sub_transaction_initialize_session, sub_transaction_cancelation_requested, sub_transaction_charge_requested,
sub_transaction_process_session, sub_transaction_refund_requested, sub_transaction_initialize_session, sub_transaction_process_session,
sub_transaction_refund_requested,
}, },
routes::create_routes, routes::create_routes,
}; };
@ -63,6 +64,12 @@ async fn main() -> anyhow::Result<()> {
.add_sync_event(SyncWebhookEventType::PaymentGatewayInitializeSession) .add_sync_event(SyncWebhookEventType::PaymentGatewayInitializeSession)
.build(), .build(),
) )
.add_webhook(
WebhookManifest::new(&config)
.set_query(sub_transaction_cancelation_requested)
.add_sync_event(SyncWebhookEventType::TransactionCancelationRequested)
.build(),
)
.add_webhook( .add_webhook(
WebhookManifest::new(&config) WebhookManifest::new(&config)
.set_query(sub_list_payment_gateways) .set_query(sub_list_payment_gateways)

View file

@ -25,7 +25,7 @@ use crate::{
app::{ActiveGateway, AppError, AppState, GatewayType}, app::{ActiveGateway, AppError, AppState, GatewayType},
queries::{ queries::{
event_transactions::{ event_transactions::{
TransactionCancelationRequested, TransactionChargeRequested2, TransactionCancelationRequested2, TransactionChargeRequested2,
TransactionFlowStrategyEnum, TransactionInitializeSession2, TransactionProcessSession2, TransactionFlowStrategyEnum, TransactionInitializeSession2, TransactionProcessSession2,
TransactionRefundRequested2, TransactionRefundRequested2,
}, },
@ -53,7 +53,7 @@ pub async fn webhooks(
let res: Json<Value> = match event_type { let res: Json<Value> = match event_type {
EitherWebhookType::Sync(a) => match a { EitherWebhookType::Sync(a) => match a {
SyncWebhookEventType::TransactionCancelationRequested => { SyncWebhookEventType::TransactionCancelationRequested => {
let data = serde_json::from_str::<TransactionChargeRequested2>(&body)?; let data = serde_json::from_str::<TransactionCancelationRequested2>(&body)?;
Json::from(serde_json::to_value( Json::from(serde_json::to_value(
TransactionCancelationRequestedResponse { TransactionCancelationRequestedResponse {
time: None, time: None,

View file

@ -41,6 +41,7 @@ tinytemplate = "1.2.1"
sitemap-rs = "0.2.1" sitemap-rs = "0.2.1"
chrono = { version = "0.4.34", features = ["serde"] } chrono = { version = "0.4.34", features = ["serde"] }
serde_cbor = "0.11.2" serde_cbor = "0.11.2"
pico-args = "0.5.0"
[build-dependencies] [build-dependencies]
cynic-codegen.workspace = true cynic-codegen.workspace = true

View file

@ -1,3 +1,10 @@
# Using sitemap-generator
To clear the cache, you can run the program with `./sitemap-generator --for-url https://my-saleor-api.com/graphql --cache-clear` or `docker compose --rm app-sitemap-generator sitemap-generator --for-url https://my-saleor-api.com/graphql --cache-clear`
To regenerate the cache, you can run the program with `./sitemap-generator --for-url https://my-saleor-api.com/graphql --cache-regenerate` or `docker compose --rm app-sitemap-generator sitemap-generator --for-url https://my-saleor-api.com/graphql --cache-regenerate`
You can also add both flags (do --cache-regenerate first), which will clear and then regenerate.
# Unofficial Saleor App Template # Unofficial Saleor App Template
To update the saleor schema, you can download it from [here](https://raw.githubusercontent.com/saleor/saleor/main/saleor/graphql/schema.graphql) and put into schema/schema.graphql To update the saleor schema, you can download it from [here](https://raw.githubusercontent.com/saleor/saleor/main/saleor/graphql/schema.graphql) and put into schema/schema.graphql

View file

@ -85,8 +85,7 @@ pub struct SitemapConfig {
impl SitemapConfig { impl SitemapConfig {
pub fn load() -> Result<Self, envy::Error> { pub fn load() -> Result<Self, envy::Error> {
dotenvy::dotenv().unwrap(); _ = dotenvy::dotenv();
envy::from_env::<SitemapConfig>() envy::from_env::<SitemapConfig>()
} }
} }

View file

@ -18,7 +18,7 @@ use tracing::{debug, info};
use crate::{ use crate::{
app::{trace_to_std, AppState, SitemapConfig, XmlCache}, app::{trace_to_std, AppState, SitemapConfig, XmlCache},
queries::event_subjects_updated::EVENTS_QUERY, queries::event_subjects_updated::EVENTS_QUERY,
routes::create_routes, routes::{create_routes, register::regenerate},
}; };
#[tokio::main] #[tokio::main]
@ -69,12 +69,23 @@ async fn main() -> anyhow::Result<()> {
saleor_app: Arc::new(Mutex::new(saleor_app)), saleor_app: Arc::new(Mutex::new(saleor_app)),
}; };
debug!("Created AppState..."); debug!("Created AppState...");
{ {
let xml_cache = app_state.xml_cache.lock().await; // either clear the cache, regenerate or both from command args
xml_cache let mut pargs = pico_args::Arguments::from_env();
.delete_all("http://localhost:8000/graphpl/")
.await?; if let Some(for_url) = pargs.opt_value_from_str::<_, String>("--for-url")? {
debug!("Cleared Xml Cache"); if pargs.contains("--cache-clear") {
let xml_cache = app_state.xml_cache.lock().await;
xml_cache.delete_all(&for_url).await?;
debug!("Cleared Xml Cache for {for_url}");
}
if pargs.contains("--cache-regenerate") {
regenerate(app_state.clone(), for_url).await?;
}
std::process::exit(0)
}
} }
let app = create_routes(app_state); let app = create_routes(app_state);

View file

@ -76,21 +76,21 @@ pub async fn register(
pub async fn regenerate(state: AppState, saleor_api_url: String) -> anyhow::Result<()> { pub async fn regenerate(state: AppState, saleor_api_url: String) -> anyhow::Result<()> {
info!("regeneration: fetching all categories, products, collections, pages"); info!("regeneration: fetching all categories, products, collections, pages");
let xml_cache = state.xml_cache.lock().await; let xml_cache = state.xml_cache.lock().await;
let apl = state.saleor_app.lock().await; let app = state.saleor_app.lock().await;
let token = token.apl.get(&saleor_api_url).await?; let auth_data = app.apl.get(&saleor_api_url).await?;
let mut categories: Vec<(Category3, Vec<Arc<CategorisedProduct>>)> = let mut categories: Vec<(Category3, Vec<Arc<CategorisedProduct>>)> =
get_all_categories(&saleor_api_url, token) get_all_categories(&saleor_api_url, &auth_data.token)
.await? .await?
.into_iter() .into_iter()
.map(|c| (c, vec![])) .map(|c| (c, vec![]))
.collect(); .collect();
let mut products = vec![]; let mut products = vec![];
for category in categories.iter_mut() { for category in categories.iter_mut() {
products.append(&mut get_all_products(&saleor_api_url, token, category).await?); products.append(&mut get_all_products(&saleor_api_url, &auth_data.token, category).await?);
} }
let pages = get_all_pages(&saleor_api_url, token).await?; let pages = get_all_pages(&saleor_api_url, &auth_data.token).await?;
let collections = get_all_collections(&saleor_api_url, token).await?; let collections = get_all_collections(&saleor_api_url, &auth_data.token).await?;
info!( info!(
"regeneration: found {} products, {} categories, {} pages, {} collections", "regeneration: found {} products, {} categories, {} pages, {} collections",
products.len(), products.len(),