How to complete restful api query with rust and actix-web? - rest

I have some data, from the request's querystring, the structure like this:
Crates: serde_qs serde mongodb and its serde_helpers
filter: {
linkOperator: "and", // maybe 'or'
items: [
{
field: "parent",
operator: "is", // maybe 'isNot', 'isAnyOf'
value: "id"
},
{
field: "createdAt",
operator: "is", // maybe 'before', 'after', .etc
value: "RFC 3339 string"
}
]
}
I use the serde crate to handle it, the linkOperator as enum, items as its type. I use the mongodb as the database.
#[derive(Debug, Deserialize, Serialize, PartialEq)]
#[serde(tag = "linkOperator", content = "items", rename_all = "camelCase")]
pub enum Filter<T> {
And(Vec<T>),
Or(Vec<T>),
}
pub trait ToFilter {
fn to_filter(self) -> Document;
}
impl<T> Filter<T> {
pub fn to_filter(self) -> Document
where
T: ToFilter,
{
match self {
Filter::And(items) => {
let mut filter = vec![];
for item in items {
filter.push(item.to_filter());
}
doc! {
"$and": filter
}
}
Filter::Or(items) => {
let mut filter = vec![];
for item in items {
filter.push(item.to_filter());
}
doc! {
"$or": filter
}
}
}
}
}
Then, I further process it in the next progress. This enum is used to handle datetime. Use the content of the vec above. operator field as enum, value field as enum's inner type.
#[derive(Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase", tag = "operator", content = "value")]
pub enum DateTimeFilter {
#[serde(deserialize_with = "deserialize_bson_datetime_from_rfc3339_string")]
Is(DateTime),
#[serde(deserialize_with = "deserialize_bson_datetime_from_rfc3339_string")]
Not(DateTime),
#[serde(deserialize_with = "deserialize_bson_datetime_from_rfc3339_string")]
After(DateTime),
#[serde(deserialize_with = "deserialize_bson_datetime_from_rfc3339_string")]
OnOrAfter(DateTime),
#[serde(deserialize_with = "deserialize_bson_datetime_from_rfc3339_string")]
Before(DateTime),
#[serde(deserialize_with = "deserialize_bson_datetime_from_rfc3339_string")]
OnOrBefore(DateTime),
IsEmpty,
IsNotEmpty,
}
impl DateTimeFilter {
pub fn to_filter(self, field: &'static str) -> Document {
match self {
DateTimeFilter::Is(value) => doc! { field: value },
DateTimeFilter::Not(value) => doc! { field: { "$ne": value } },
DateTimeFilter::After(value) => doc! { field: { "$gt": value } },
DateTimeFilter::OnOrAfter(value) => doc! { field: { "$gte": value } },
DateTimeFilter::Before(value) => doc! { field: { "$lt": value } },
DateTimeFilter::OnOrBefore(value) => doc! { field: { "$lte": value } },
DateTimeFilter::IsEmpty => doc! { field: { "$exists": false } },
DateTimeFilter::IsNotEmpty => doc! { field: { "$exists": true } },
}
}
}
There are some other enums which have a similar effect.
#[derive(Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase", tag = "operator", content = "value")]
pub enum SingleSelectFilter<T> {
Is(T),
Not(T),
IsAnyOf(Vec<T>),
}
impl<T> SingleSelectFilter<T> {
pub fn to_filter(self, field: &'static str) -> Document
where
Bson: From<T> + From<Vec<T>>,
{
match self {
SingleSelectFilter::Is(value) => doc! { field: value },
SingleSelectFilter::Not(value) => doc! { field: { "$ne": value } },
SingleSelectFilter::IsAnyOf(values) => doc! { field: { "$in": values } },
}
}
}
I combine them.
#[derive(Debug, Deserialize, Serialize, PartialEq)]
pub struct PostQuery {
filter: Option<Filter<FilterField>>,
#[serde(flatten)]
sort: Option<Sort<SortField>>,
#[serde(flatten)]
pagination: Pagination,
}
/// add the new field here
#[derive(Debug, Deserialize, Serialize, PartialEq)]
#[serde(tag = "field", rename_all = "camelCase")]
pub enum FilterField {
Published(SingleSelectFilter<bool>),
PublishedAt(DateTimeFilter),
CreatedAt(DateTimeFilter),
Creator(SingleSelectFilter<ObjectId>),
}
impl ToFilter for FilterField {
fn to_filter(self) -> Document {
match self {
FilterField::Published(filter) => filter.to_filter("published"),
FilterField::PublishedAt(filter) => filter.to_filter("publishedAt"),
FilterField::CreatedAt(filter) => filter.to_filter("createdAt"),
FilterField::Creator(filter) => filter.to_filter("creator"),
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct AggregatedPost {
#[serde(rename = "_id")]
id: ObjectId,
title: String,
slug: String,
content: String,
categories: Vec<Category>,
tags: Vec<Tag>,
#[serde(rename = "publishedAt")]
published_at: DateTime,
published: bool,
#[serde(rename = "createdAt")]
created_at: DateTime,
pub creator: User,
}
pub async fn aggregate_posts(
query: PostQuery,
service: &Service,
) -> Result<(u64, Vec<Self>), AppError> {
let filter = match query.filter {
Some(filter) => filter.to_filter(),
None => doc! {},
};
let skip = query.pagination.skip();
let limit = query.pagination.limit();
let options = CountOptions::builder().skip(skip).limit(limit).build();
let count = service
.count::<Self>(filter.clone(), options, "posts")
.await?;
let mut pipeline = vec![
doc! {
"$match": filter
},
doc! {
"$skip": skip as u32
},
doc! {
"$limit": limit as u32
},
doc! {
"$lookup": {
"from": "categories",
"localField": "categories",
"foreignField": "_id",
"as": "categories"
}
},
doc! {
"$lookup": {
"from": "tags",
"localField": "tags",
"foreignField": "_id",
"as": "tags"
}
},
doc! {
"$lookup": {
"from": "users",
"localField": "creator",
"foreignField": "_id",
"as": "creator"
}
},
doc! {
"$unwind": {
"path": "$creator",
"preserveNullAndEmptyArrays": true
}
},
];
if query.sort.is_some() {
let sort = query.sort.unwrap().to_sort();
pipeline.insert(1, doc! { "$sort": sort });
}
match service
.aggregate_docs::<Self>(pipeline, None, "posts")
.await
{
Ok(posts) => Ok((count, posts)),
Err(error) => Err(error),
}
}
Use this is a router handler with serde_qs
pub async fn aggregate_posts(
session: Session,
service: Data<Service>,
query: QsQuery<PostQuery>,
) -> Result<HttpResponse, AppError> {
let user = protect(&session, &service).await?;
user.has_permission(
Permission::ReadAggregatedPosts,
"You don't have permission to get aggregated posts.",
)?;
let (count, posts) = AggregatedPost::aggregate_posts(query.into_inner(), &service).await?;
let posts_json = posts
.into_iter()
.map(|post| post.into_json(&user))
.collect::<Vec<Value>>();
Ok(HttpResponse::Ok().json(json!({ "count": count, "posts": posts_json })))
}
Finally, the problem arises. When I receive a request with a query like this.
filter: {
linkOperator: "and", // maybe 'or'
items: [
{
field: "published",
operator: "isAnyOf", // maybe 'before', 'after', .etc
value: [true]
}
]
}
My query error handler will generate the message like this. Custom("invalid type: string \"true\", expected a boolean")
pub fn query_error_handler(error: serde_qs::Error, _req: &HttpRequest) -> Error {
let app_error = match &error {
serde_qs::Error::Custom(message) => BadRequest(format!("Query params: {}.", message)),
_ => BadRequest("Invalid query params.".to_string()),
};
InternalError::from_response(error, app_error.error_response()).into()
}
My api test result
How can i convert the string to a bool value here? Or, have any other options to complete a query in restful api with actix-web?

Related

Mongoose find and update nested document

I am trying to find and update a sub document under another sub document. I am not getting the result as I expect. This is what I currently have setup:
const SiteSchema = new mongoose.Schema({
domain: { type: String, required: true },
keywords: [],
campaigns: [
{
campaign: {
type: mongoose.Schema.Types.ObjectId,
ref: "Campaign",
},
responses: [
{
message: { type: String },
asking_fee: { type: Number },
date: { type: Date },
},
],
}],
})
I would like to find and edit a particular response. Here is the code I have now. I am new to mongoose and MongoDB.
const site = await Site.findOneAndUpdate({
"campaigns.responses._id": responseId, // will it fetch the response ?
}, {
$set: { // I am struggling with the following
"campaigns.$.responses.message": message,
"campaigns.$.responses.asking_price": asking_price,
"campaigns.$.responses.date": date,
},
}
);
If you don't have campaigns.campaign id then you have to use update with aggregation pipeline starting from MongoDB 4.2,
$set to update campaigns field, $map to iterate loop of campaigns array, $map to iterate loop of campaigns.responses array and check condition if responseId match then return updateFields otherwise return old fields and merge with current object using $mergeObjects
let responseId = 1;
let updateFields = {
message: "new message",
asking_fee: 10,
date: new Date()
};
const site = await Site.findOneAndUpdate(
{ "campaigns.responses._id": responseId },
[{
$set: {
campaigns: {
$map: {
input: "$campaigns",
in: {
$mergeObjects: [
"$$this",
{
responses: {
$map: {
input: "$$this.responses",
in: {
$mergeObjects: [
"$$this",
{
$cond: [
{ $eq: ["$$this._id", responseId] },
updateFields,
"$$this"
]
}
]
}
}
}
}
]
}
}
}
}
}]
)
Playground
Second option if you have campaigns.campaign id then you can use $[<identifier>] arrayFilters,
let campaign = 1;
let responseId = 1;
let updateFields = {
message: "new message",
asking_fee: 10,
date: new Date()
};
const site = await Site.findOneAndUpdate({
"campaigns.campaign": campaign,
"campaigns.responses._id": responseId
},
{
$set: {
"campaigns.$[parent].responses.$[child]": updateFields
}
},
{
arrayFilters: [
{ "child._id": responseId },
{ "parent.campaign": campaign }
]
})
Playground

mongoose find and update removes the other fields

I have schema like this:
this.schema = new Schema({
userEmail: String
environments: [
{
envId: String,
appPreference: String,
language: String,
timeZone: String,
summaryNotificationSchedule: {
timeOfTheDay: String
}
}
]
});
Update request:
{
"envId": "u2",
"appPreference": "put2",
"timeZone": "gmt",
"summaryNotificationSchedule.timeOfTheDay": "32400",
}
As you can see, I am not sending "language": "abc", in the update request and in the result I see the language field is removed. I want to update the fields but not remove the other fields
Mongoose find and update call:
await this.model.findOneAndUpdate({ userEmail, 'environments.envId': envId }, { $set: { 'environments.$': setPreferenceFields } }, { new: true });
You can create update object from your request first:
let request = {
"envId": "u2",
"appPreference": "put2",
"timeZone": "gmt",
"summaryNotificationSchedule.timeOfTheDay": "32400",
};
let update = Object.keys(request).reduce((acc, cur) => {
acc[`environments.$.${cur}`] = request[cur];
return acc;
}, {})
console.log(update);
Then pass it to the update:
await this.model.findOneAndUpdate({ userEmail, 'environments.envId': envId }, { $set: update }, { new: true });
You have to specify property with parent key name of an array, it should be like this way,
await this.model.findOneAndUpdate(
{
userEmail,
'environments.envId': envId
},
{
$set: {
'environments.$.envId': "u2",
'environments.$.appPreference': "put2",
'environments.$.timeZone': "gmt",
'environments.$.summaryNotificationSchedule.timeOfTheDay': "32400"
}
},
{ new: true }
)
Another option, update with aggregation pipeline start from MongoDB v4.2, this little lengthy process then above method,
$map to iterate loop of environments array
$cond check condition if envId is equal to matching envId then merge objects update objects and current objects using $mergeObjects otherwise return current object
await this.model.findOneAndUpdate(
{ userEmail },
[
{
$set: {
environments: {
$map: {
input: "$environments",
in: {
$cond: [
{$eq: ["$$this.envId", envId]}, // add update id
{
$mergeObjects: [
"$$this",
setPreferenceFields // your update fields
]
},
"$$this"
]
}
}
}
}
}
],
{new: true}
)

Select a same field from a list of similar nested fields in mongoose

I have a schema.
const placeSchema = new Schema({
description: {
fr: String,
en: String,
},
comment: {
fr: String,
en: String,
},
...
...
});
const Place= mongoose.model('Place', placeSchema);
module.exports = Place;
If I want to get only 'en' value I am currently using
await Place.find({}, '-description.fr -comment.fr ...')
If the number of similar fields increases so does the length of the query. Is there a way to select all the similar fields like maybe $field.fr?
Technically yes there is a way. using $objectToArray and doing some structure manipulation.
It would look something like this:
db.collection.aggregate([
{
$match: {} //match your document.
},
{
$addFields: {
rootArr: {
$objectToArray: "$$ROOT"
}
}
},
{
$unwind: "$rootArr"
},
{
$match: {
"rootArr.v.en": {
$exists: true
}
}
},
{
$group: {
_id: "$_id",
data: {
$push: {
k: "$rootArr.k",
v: "$rootArr.v.en"
}
}
}
},
{
$replaceRoot: {
newRoot: {
$arrayToObject: "$data"
}
}
}
])
Mongo Playground
It's a little "hacky" thought, how strict are your schema needs?
Have you considered building it under the following structure?:
const placeSchema = new Schema({
data: [
{
lang: String,
description: String,
comment: String,
...
}
]
});
The following aggregation will check all the top level fields for a subfield en. If it's truthy (should work if you strictly have string values for the language properties), the subfield will be { field: { en: fieldValue.en } } otherwise it will be { field: fieldValue }
db.collection.aggregate([
{
$replaceRoot: {
newRoot: {
$arrayToObject: {
$map: {
input: { $objectToArray: "$$ROOT" },
in: {
k: "$$this.k",
v: {
$cond: [
"$$this.v.en", // works for string values, otherwise you will have to check more explicitly
{
en: "$$this.v.en"
},
"$$this.v"
]
}
}
}
}
}
}
}
])
Mongo Playground
Both the answers above are exactly what the question was looking for. This might be a more 'hacky' way of doing things.
First create a function that generates the query string '-description.fr -comment.fr ...'
let select = '';
const selectLanguage = (fields, lang) => {
switch (true) {
case lang === 'fr':
fields.forEach(field => {
select= `${select} -${field}.en `;
});
break;
case lang === 'en':
fields.forEach(field => {
select = `${select} -${field}.fr `;
});
break;
default:
break;
}
return select;
}
This generates a string like ' -fieldName1.fr -fieldName2.fr ..' for english and and ' -fieldName1.en ..' for french. Then we can use this statement in the query above.
const select = selectLanguage(['description', 'comment', ..], 'en')
await Place.find({}, select) //await Place.find({}, ' -description.fr -comment.fr ..')

Problem using aggregation in mongodb retrieving data from two collections

i am strugling with a query that i don't know how to perform... I have two collections,
Tarifas Collection
tarifaConfig = new Schema({
producto: { type: String },
titulo: { type: String },
bloqueo: { type: Boolean },
margen: { type: Number },
precioVenta: { type: Number },
precioVentaIva: { type: Number },
})
const tarifaSchema = new Schema({
codigo: { type: String },
titulo: { type: String },
margen: { type: Number },
estado: { type: Boolean },
bloqueo: { type: Boolean },
configs: [tarifaConfig]
})
Producto Collection
const productosSchema = new Schema({
ref: { type: String },
nombre: { type: String },
precioCompra: { type: Number },
precioCompraIva: { type: Number },
precioVenta: { type: Number },
precioVentaIva: { type: Number },
iva: { type: Number },
})
Now i am using an Aggregation method to retrieve both collection in a response
productosModel.aggregate([
{
$match: { _id: ObjectId(req.params.id) }
},
{
$lookup: {
from: "tarifas",
as: "tarifas",
pipeline: []
}
}
]).then((producto) => {
res.json(producto);
})
This is working and gives me both collections in the response... but..
In tarifa's collection i have a propertie called 'configs' that is an array with lot of sub collections... this sub collections are a config of each product that i have,
So what i need to do is, retrieve all tarifas that has a configs for the product, and if the configs does not contain retrieve the tarifa with a empty array.
Expected result
{
ref: 'rbe34',
nombre: 'bike',
precioCompra: 10,
precioCompraIva: 12.1,
precioVenta: "",
precioVentaIva: "",
iva: 21,
tarifas:[
{
codigo: 'NOR',
titulo: 'Normal tarifa',
margen: 33,
estado: true,
bloqueo: true,
configs: [], ///HERE I NEED A EMPTY ARRAY IF THERE IS NOT ANY CONFIG THAT MATCH WITH THE PRODUCT ID,
}
]
}
i tried to add $match in my aggregation pipeline.
productosModel.aggregate([
{
$match: { _id: ObjectId(req.params.id) }
},
{
$lookup: {
from: "tarifas",
as: "tarifas",
pipeline: [
{ $match: { 'configs.producto': req.params.id } }
]
}
}
])
But if there is not any config that match the product it doesn't retrieve the rest of Tarifa's collection
It seems you are trying to $filter the array after you retrieve it.
This pipeline will return only the configs for which the producto field from the config matches the ref field from the product.
[
{
$match: { _id: ObjectId(req.params.id) }
},
{
$lookup: {
from: "tarifas",
as: "tarifas",
pipeline: [
{
$addFields: {
"tarifas.configs":{ $filter:{
input: "$tarifas.configs",
cond: {$eq:["$$this.producto","$ref"]}
} }
}
}
]
}
},
]
Change the fields in the $eq array to the ones you need to match.

MongoDB - convert double to string with aggregation

I am new to MongoDB and I am trying to convert double to string. I am not sure why my results are not as needed.
export function StoreSettings(req, res) {
var id = req.params.id;
var id = mongoose.Types.ObjectId(id);
Setting.aggregate([
{
$match: { restaurantID: id }
},
{
$addFields: {
"appTheme.appBanner": {
$concat: [
"/App/Carousel/",
{ $toString: "$appTheme.appBanner" },
".png"
]
}
}
}
])
.exec()
.then(data => {
return res.json(data);
})
.catch(err => res.json({ data: "Data Not Found", err }));
}
==OUTPUT==
{
"_id": "5e3379be06558d0c40d035ee",
"appTheme": {
"appBanner": "/App/Carousel/1.58078e+12.png"
}}
=== i NEED it to be like this: ====
{
"_id": "5e3379be06558d0c40d035ee",
"appTheme": {
"appBanner": "/App/Carousel/1580782209156.png"
}}
what am i doing wrong?
Thanks!
As $appTheme.appBanner :1580782209156 is a double in database, then using $toString would result in 1.58078e+12. You need to convert it into NumberLong() using $toLong & then convert it to string, Try below :
Setting.aggregate([
{
$match: { restaurantID: id }
},
{
$addFields: {
"appTheme.appBanner": {
$concat: [
"/App/Carousel/",
{ $toString: { $toLong: "$appTheme.appBanner" } },
".png"
]
}
}
}
])
Test : MongoDB-Playground