Node js, function doesn't wait for response - javascript

I'm new to node js.
Logically, await should wait for the function to complete and then next line of code should run, but in my function, it doesn't wait for process (2) to finish even after using await. What am I missing?
const getDefaultTemplates = async (token) => {
const emailertoken = process.env.EMAILER_AUTH_TOKEN;
const key = 'defaultTemplates';
// This should run first
console.log('hi');
let defaultTemplatesVar = '';
let fromCache = 0;
// This should run second. Need response from redisClient to return parent function
const defaultTemplates = await redisClient.get(key, (err, data) => {
if (err) {
console.log(err);
}
// If data is found in cache
if (data != null) {
console.log('from cache');
defaultTemplatesVar = JSON.parse(data);
fromCache = 1;
console.log('defaultTemplatesVar1 = ', defaultTemplatesVar);
}
return defaultTemplatesVar;
});
console.log('defaultTemplatesVar2 = ', defaultTemplatesVar);
console.log('fromCache = ', fromCache);
if (fromCache === 0) {
// If data is not found in cache, call api
// console.log('from api');
try {
const response = await axios.get(`${process.env.EMAILER_API_URL}/get-system-templates`, {
headers: {
Authorization: `bearer ${emailertoken}`,
},
});
console.log('from data');
redisClient.setex(key, 3600, JSON.stringify(response.data._embedded.get_system_templates));
defaultTemplatesVar = response.data._embedded.get_system_templates;
console.log('defaultTemplatesVar3 = ', defaultTemplatesVar);
} catch (error) {
console.error(error);
}
}
// This should run at last, to return value to parent function getDefaultTemplates()
console.log('bye');
console.log('defaultTemplatesVar4 = ', defaultTemplatesVar);
return defaultTemplates;
};
OUTPUT ->
hi
defaultTemplatesVar2 =
fromCache = 0
from cache
defaultTemplatesVar1 = [ { name: 'versafix-1', description: 'The versatile template' },
{ name: 'givecentral', description: 'The Givecentral Template' } ]
from data
defaultTemplatesVar3 = [ { name: 'versafix-1', description: 'The versatile template' },
{ name: 'givecentral', description: 'The Givecentral Template' } ]
bye
defaultTemplatesVar4 = [ { name: 'versafix-1', description: 'The versatile template' },
{ name: 'givecentral', description: 'The Givecentral Template' } ]
Output should be in series of 1,2,3,4.

Callback function can not be await,
To turn callback fn to Promise fn, check out https://www.npmjs.com/package/redis#promises .
If you are using other packages, you actually can turn callback to promise by
const redisGet = (key) => new Promise((resolve, reject) => {
redisClient.get(key, (err, data) => {
if (err) return reject(err);
return resolve(data);
});
});
// So you can do
const data = await redisGet(key);
// or
redisGet(key).then((data) => { ... }).catch((err) => { ... });
so what you should do
try {
...
const data = await redisClient.get(key);
if (data != null) {
console.log('from cache');
defaultTemplatesVar = JSON.parse(data);
fromCache = 1;
console.log('defaultTemplatesVar1 = ', defaultTemplatesVar);
}
const defaultTemplates = defaultTemplatesVar;
...
} catch (err) {
console.error(err);
}
Simple tutorial about Callback vs Promise

My issue was solved from Allen's Answer.
Only thing I changed in Allen's suggestion was
const data = await redisGet(key);
Instead of
const data = await redisClient.get(key);
because the later one returned 'true' instead of actual values.
const redisGet = (key) => new Promise((resolve, reject) => {
redisClient.get(key, (err, data) => {
if (err) return reject(err);
return resolve(data);
});
});
// Api,Auth Done
const getDefaultTemplates = async (token) => {
const emailertoken = process.env.EMAILER_AUTH_TOKEN;
const key = 'defaultTemplates';
// This should run first
console.log('hi');
let defaultTemplatesVar = '';
let fromCache = 0;
// This should run second. Need response from redisClient to return parent function
try {
const data = await redisGet(key);
if (data != null) {
console.log('from cache');
defaultTemplatesVar = JSON.parse(data);
fromCache = 1;
console.log('defaultTemplatesVar1 = ', defaultTemplatesVar);
}
const defaultTemplates = defaultTemplatesVar;
} catch (err) {
console.error(err);
}
console.log('defaultTemplatesVar2 = ', defaultTemplatesVar);
console.log('fromCache = ', fromCache);
if (fromCache === 0) {
// If data is not found in cache, call api
// console.log('from api');
try {
const response = await axios.get(`${process.env.EMAILER_API_URL}/get-system-templates`, {
headers: {
Authorization: `bearer ${emailertoken}`,
},
});
console.log('from data');
redisClient.setex(key, 3600, JSON.stringify(response.data._embedded.get_system_templates));
defaultTemplatesVar = response.data._embedded.get_system_templates;
console.log('defaultTemplatesVar3 = ', defaultTemplatesVar);
} catch (error) {
console.error(error);
}
}
// This should run at last, to return value to parent function getDefaultTemplates()
console.log('bye');
console.log('defaultTemplatesVar4 = ', defaultTemplatesVar);
return defaultTemplatesVar;
};
OUTPUT 1 (When data comes from the API)
hi
defaultTemplatesVar2 =
fromCache = 0
from data
defaultTemplatesVar3 = [ { name: 'versafix-1', description: 'The versatile template' },
{ name: 'givecentral', description: 'The Givecentral Template' } ]
bye
defaultTemplatesVar4 = [ { name: 'versafix-1', description: 'The versatile template' },
{ name: 'givecentral', description: 'The Givecentral Template' } ]
OUTPUT 2 (When Data comes from Cache)
hi
from cache
defaultTemplatesVar1 = [ { name: 'versafix-1', description: 'The versatile template' },
{ name: 'givecentral', description: 'The Givecentral Template' } ]
defaultTemplatesVar2 = [ { name: 'versafix-1', description: 'The versatile template' },
{ name: 'givecentral', description: 'The Givecentral Template' } ]
fromCache = 1
bye
defaultTemplatesVar4 = [ { name: 'versafix-1', description: 'The versatile template' },
{ name: 'givecentral', description: 'The Givecentral Template' } ]

Related

How to solve in JEST integrations tests an issue in testing subcriptions WebSocket was closed before the connection was established

I have a JEST test file that is not running correctly throwing out this error
WebSocket was closed before the connection was established
The test is as follows failing immediately without running any other tests
import { api, gql, ws } from './client';
import { createCandidate } from './utils';
const query = gql`
subscription onStatusChanged(
$studyId: String!
$types: [CandidateStatusType!]
) {
candidateStatusUpdated(studyId: $studyId, types: $types) {
id
studyId
siteId
name
createdAt
status {
type
}
}
}
`;
const mutation = gql`
mutation updateStatus($id: ID!, $type: CandidateStatusType!) {
updateCandidateStatus(status: { candidateId: $id, type: $type }) {
id
}
}
`;
const createHeaders = ({
actions = ['candidate:get', 'candidate.pii:show'],
studyId = 'WST2',
siteId = 'London',
status = 'INCOMPLETE',
userId = '6bx9GyxXP4xv2kReGQmX4',
} = {}) => ({
'x-userid': userId,
'x-permissions': JSON.stringify([
{
actions,
statuses: [status],
context: {
studies: ['WST1', studyId],
sites: ['Madrid', siteId],
},
},
]),
});
let client; // The web-socket client
let candidate1; // study id: WST1
let candidate2; // study id: WST2
beforeEach(async () => {
candidate1 = await createCandidate({
studyId: 'WST1',
siteId: 'Madrid',
name: 'Test Cand 1',
});
candidate2 = await createCandidate({
studyId: 'WST2',
siteId: 'London',
name: 'Test Cand 2',
});
client = ws();
client.onConnected((x) => x);
});
afterEach(async () => {
await client.onDisconnected((x) => x);
await client.close(true);
});
test('subscribe for study id', (done) => {
client = ws(createHeaders());
client.onConnected(() => {
client.request({ query, variables: { studyId: 'WST1' } }).subscribe({
next({ data: { candidateStatusUpdated } }) {
expect(candidateStatusUpdated.id).toEqual(candidate1.id);
expect(candidateStatusUpdated.name).toEqual(candidate1.name);
expect(candidateStatusUpdated.createdAt).toBeTruthy();
expect(candidateStatusUpdated.status.type).toEqual(
'PENDING_CALLCENTER'
);
done();
},
});
api(mutation, { id: candidate1.id, type: 'PENDING_CALLCENTER' });
});
});
test('filter by study id', (done) => {
client = ws(createHeaders());
client.onConnected(async () => {
client.request({ query, variables: { studyId: 'WST2' } }).subscribe({
next() {
done(`Should not happen because the subscription is for studyId WST2
and candidate which changed status is from study WST1`);
},
});
await api(mutation, { id: candidate1.id, type: 'PENDING_CALLCENTER' });
setTimeout(() => done(), 150);
});
});
test('subscribe for study id and status types', (done) => {
let step = 0; // 0: INITIAL -> 1: PENDING_CALLCENTER -> 2: PENDING_SITE -> 3: REJECTED_SITE -> 4: ANONYMISED
const setStatus = (type) => {
step++;
return api(mutation, { id: candidate2.id, type });
};
async function callback({ data: { candidateStatusUpdated } }) {
expect(candidateStatusUpdated.id).toEqual(candidate2.id);
const statusType = candidateStatusUpdated.status.type;
switch (step) {
case 1:
expect(statusType).toEqual('PENDING_CALLCENTER');
// This should be ignored due to filter study id
await api(mutation, { id: candidate1.id, type: 'PENDING_CALLCENTER' });
// This should be ignored due to filter status types
await setStatus('PENDING_SITE');
// Because status type PENDING_SITE is ignored I don't have any callback
// to trigger the next status update so I have to do it via timer.
setTimeout(() => setStatus('REJECTED_SITE'), 100);
break;
case 3:
expect(statusType).toEqual('REJECTED_SITE');
await setStatus('ANONYMISED');
// Waiting for nothing to happen and then finish the test.
setTimeout(() => done(), 100);
break;
default:
done(`${step}: ${statusType} should be filtered out`);
}
}
client = ws(createHeaders());
client.onConnected(() => {
client
.request({
query,
variables: {
studyId: 'WST2',
types: ['PENDING_CALLCENTER', 'REJECTED_SITE'],
},
})
.subscribe({ next: callback });
setStatus('PENDING_CALLCENTER');
});
});
describe('#authorization', () => {
test('should not trigger when not allowed by action', (done) => {
client = ws(createHeaders({ actions: ['candidate.pii:show'] }));
client.onConnected(async () => {
client.request({ query, variables: { studyId: 'WST1' } }).subscribe({
next: () => done('Should not be triggered'),
});
await api(mutation, { id: candidate2.id, type: 'PENDING_CALLCENTER' });
setTimeout(() => done(), 150);
});
});
test('should not trigger when not allowed by studyId', (done) => {
client = ws(createHeaders({ studyId: 'FOO' }));
client.onConnected(async () => {
client.request({ query, variables: { studyId: 'WST1' } }).subscribe({
next: () => done('Should not be triggered'),
});
await api(mutation, { id: candidate2.id, type: 'PENDING_CALLCENTER' });
setTimeout(() => done(), 150);
});
});
test('should not trigger when not allowed by siteId', (done) => {
client = ws(createHeaders({ siteId: 'foo' }));
client.onConnected(async () => {
client.request({ query, variables: { studyId: 'WST1' } }).subscribe({
next: () => done('Should not be triggered'),
});
await api(mutation, { id: candidate2.id, type: 'PENDING_CALLCENTER' });
setTimeout(() => done(), 150);
});
});
test('should mask candidate data when not allowed by status', () => {
client = ws(createHeaders({ status: 'PENDING_SITE' }));
client.onConnected(() => {
client.request({ query, variables: { studyId: 'WST1' } }).subscribe({
next({ data: { candidateStatusUpdated } }) {
expect(candidateStatusUpdated.id).toEqual(candidate2.id);
expect(candidateStatusUpdated.studyId).toEqual(candidate2.studyId);
expect(candidateStatusUpdated.siteId).toEqual(candidate2.siteId);
expect(candidateStatusUpdated.name).not.toEqual(candidate2.name);
expect(candidateStatusUpdated.name).toBeFalsy();
expect(candidateStatusUpdated.status.type).toEqual(
'PENDING_CALLCENTER'
);
done();
},
});
api(mutation, { id: candidate2.id, type: 'PENDING_CALLCENTER' });
});
});
});
I have no clue what causing it and how to solve it and I believe the issue is when the beforEach or afterEach happens but don't know what to do.
The beforeEch uses a utility
const createCandidate = async (
{
studyId,
siteId,
name,
translationId = 101,
candidate = {},
statuses = [],
notes = [],
comments = [],
activities = [],
},
headers = defaultHeaders
) => {
const candidateId = (
await api(
addCandidate,
{ candidate: { studyId, siteId, name, translationId, ...candidate } },
headers
)
).addCandidate.id;
// Good old for loop: make sure setStatus transaction is completed before the next setStatus execution otherwise
for (let i = 0; i < statuses.length; ++i) {
await api(setStatus, { status: { candidateId, ...statuses[i] } }, headers);
}
await Promise.all([
...notes.map((note) =>
api(addNote, { note: { candidateId, ...note } }, headers)
),
...comments.map((comment) =>
api(addComment, { input: { candidateId, content: comment } }, headers)
),
...activities.map((activity) =>
api(addActivity, { activity: { candidateId, ...activity } }, headers)
),
]);
const {
listCandidates: { nodes },
} = await api(getCandidate, { id: candidateId }, headers);
return nodes[0];
};
Maybe the issue is from the utility

Asynchronous Callback to Array.map()

I've just started learning Node.js a few weeks ago.... I am not able to understand why the 'products' array contains null instead of the desired objects....
On Line 13, when I am console logging the object, I get the desired object but I don't understand why they are null when I console log them on Line 40 after the map function has completed it's execution....
If the array length is 2 (which should imply successful pushing) why are the objects stored inside still null instead of the objects that I wanted to store ?
Console Output
Order Schema
exports.getOrders = async (req, res, next) => {
const userOrders = [];
Order.find({ 'user.userId': req.user._id }).then((orders) => {
console.log(orders); // Line 4
async.eachSeries(
orders,
function (order, callback) {
const products = order.products.map((p) => {
const seriesId = p.product.seriesId;
const volumeId = p.product.volumeId;
Series.findById(seriesId).then((series) => {
const volume = series.volumes.id(volumeId);
console.log(volume, p.quantity); // Line 13
return {
seriesTitle: volume.parent().title,
volume: volume,
quantity: p.quantity
};
});
});
console.log('Product Array Length: ', products.length); // Line 21
if (products.length !== 0) {
const data = {
productData: products,
orderData: {
date: order.date,
totalPrice: order.totalPrice
}
};
userOrders.push(data);
callback(null);
} else {
callback('Failed');
}
},
function (err) {
if (err) {
console.log('Could not retrieve orders');
} else {
console.log(userOrders); // Line 40
res.render('shop/orders', {
docTitle: 'My Orders',
path: 'orders',
orders: userOrders,
user: req.user
});
}
}
);
});
};
In line 8, order.products.map returns an array of null. Because this is an asynchronous mapping. For each product, you are calling Series.findById which is a promise and it only returns the value when it resolves. As you are not waiting for the promise to resolve, so it returns null on each iteration.
You have to map all the promises first then call Promise.all to resolve them and after that, you will get the expected value.
exports.getOrders = async (req, res, next) => {
const userOrders = [];
Order.find({ 'user.userId': req.user._id }).then((orders) => {
console.log(orders); // Line 4
async.eachSeries(
orders,
function (order, callback) {
const productPromise = order.products.map((p) => {
const seriesId = p.product.seriesId;
const volumeId = p.product.volumeId;
//ANSWER: Return the following promise
return Series.findById(seriesId).then((series) => {
const volume = series.volumes.id(volumeId);
console.log(volume, p.quantity); // Line 13
return {
seriesTitle: volume.parent().title,
volume: volume,
quantity: p.quantity
};
});
});
// ANSWER: call all the promises
Promise.all(productPromise)
.then(function(products) {
console.log('Product Array Length: ', products.length);
if (products.length !== 0) {
const data = {
productData: products,
orderData: {
date: order.date,
totalPrice: order.totalPrice
}
};
userOrders.push(data);
callback(null);
} else {
callback('Failed');
}
});
},
function (err) {
if (err) {
console.log('Could not retrieve orders');
} else {
console.log(userOrders); // Line 40
res.render('shop/orders', {
docTitle: 'My Orders',
path: 'orders',
orders: userOrders,
user: req.user
});
}
}
);
});
};

Called two functions on route watch change in vuejs

As I am new in Vuejs, It will be very easy for others, but for me I cannot get it right, I search a lot but cannot get the expected answer
Below is my code
watch: {
$route () {
this.newsData = []
this.loadNewsByCategory()
this.category = {}
this.getCategoryData()
}
},
created () {
this.getCategoryData()
this.loadNewsByCategory()
},
methods () {
async getCategoryData() {
// console.log('called category data')
try {
await firebase.firestore().collection('categories').doc(this.$route.params.id).get().then((doc) => {
if (doc.exists) {
this.category = doc.data()
}
})
} catch (error) {
console.log(error) // this line show the error I've post below
this.$q.notify({ type: 'negative', message: error.toString() })
}
},
async loadNewsByCategory() {
// console.log('load news')
try {
var db = firebase.firestore()
var first = db.collection('posts')
.where('publishedAt', '<=', new Date())
.where('categories', 'array-contains', this.$route.params.id)
.orderBy('publishedAt', 'desc')
.limit(12)
return await first.get().then((documentSnapshots) => {
documentSnapshots.forEach((doc) => {
const news = {
id: doc.id,
slug: doc.data().slug,
title: doc.data().title,
image: doc.data().image.originalUrl,
publishedAt: dateFormat(doc.data().publishedAt.toDate(), 'dS mmm, yyyy')
}
this.newsData.push(news)
})
this.lastVisible = documentSnapshots.docs[documentSnapshots.docs.length - 1]
})
} catch (error) {
this.$q.notify({ type: 'negative', message: error.toString() })
}
}
}
In my code, when initialize I call two functions to query data from firebase and it's working as expected. but when the route change for example: from getdata/1 to getdata/2 one function i.e., loadNewsByCategory() is working but others throw error like below
TypeError: u.indexOf is not a function
at VueComponent.getCategoryData (getdata.vue?3b03:102)
at VueComponent.created (getdata.vue?3b03:92)
Thank you in advance

Node.js JIMP restarting app after file was saved

I have such a small app where I am using JIMP. My goal here is to add a watermark on a jpeg file. The main part of my app is working fine, but I want to add a "Retry" function for users. Now, this part is not working. Retry part is called before my edited file was saved
const Jimp = require('jimp');
const inquirer = require('inquirer');
const fs = require('fs');
const addTextWatermarkToImage = async function(inputFile, outputFile, text) {
try {
const image = await Jimp.read(inputFile);
const font = await Jimp.loadFont(Jimp.FONT_SANS_32_BLACK);
const textData = {
text: text,
alignmentX: Jimp.HORIZONTAL_ALIGN_CENTER,
alignmentY: Jimp.VERTICAL_ALIGN_MIDDLE,
};
image.print(font, 10, 10, textData, image.getWidth(), image.getHeight());
image.quality(100).write(outputFile);
} catch (error) {
console.log('Error. Something went wrong');
}
};
const addImageWatermarkToImage = async function(
inputFile,
outputFile,
watermarkFile
) {
try {
const image = await Jimp.read(inputFile);
const watermark = await Jimp.read(watermarkFile);
const x = image.getWidth() / 2 - watermark.getWidth() / 2;
const y = image.getHeight() / 2 - watermark.getHeight() / 2;
image.composite(watermark, x, y, {
mode: Jimp.BLEND_SOURCE_OVER,
opacitySource: 0.5,
});
image.quality(100).write(outputFile);
} catch (error) {
console.log('Error. Something went wrong');
}
};
const prepareOutputFilename = filename => {
const [name, ext] = filename.split('.');
return `${name}-with-watermark.${ext}`;
};
const startApp = async () => {
//check if user is ready
const answer = await inquirer.prompt([
{
name: 'start',
message:
'Hi! This is "Watermar manager". Move your files to `/img` folder. Then you\'ll be able to use them in the app. Are you ready?',
type: 'confirm',
},
]);
// if no - exit app
if (!answer.start) process.exit();
// ask about input file and watermark type
const options = await inquirer.prompt([
{
name: 'inputImage',
type: 'input',
message: 'What file do you want to mark?',
default: 'test.jpg',
},
{
name: 'watermarkType',
type: 'list',
choices: ['Text watermark', 'Image watermark'],
},
]);
if (options.watermarkType === 'Text watermark') {
const text = await inquirer.prompt([
{
name: 'value',
type: 'input',
message: 'Type your watermark text:',
},
]);
options.watermarkText = text.value;
if (fs.existsSync('./img/' + options.inputImage) === true) {
addTextWatermarkToImage(
'./img/' + options.inputImage,
'./img/' + prepareOutputFilename(options.inputImage),
options.watermarkText
);
} else {
console.log('O_o Error!!!! No such file');
}
} else {
const image = await inquirer.prompt([
{
name: 'filename',
type: 'input',
message: 'Type your watermark name:',
default: 'logo.png',
},
]);
options.watermarkImage = image.filename;
if (
fs.existsSync('./img/' + options.inputImage) === true &&
fs.existsSync('./img/' + options.watermarkImage) === true
) {
addImageWatermarkToImage(
'./img/' + options.inputImage,
'./img/' + prepareOutputFilename(options.inputImage),
'./img/' + options.watermarkImage
);
} else {
console.log('O_o Error!!!! No such file');
}
}
console.log('File was saved - now');
const retry = await inquirer.prompt([
{
name: 'retry',
message: 'Do you want to try again',
type: 'confirm',
},
]);
if (!retry.retry) process.exit();
startApp();
};
startApp();
I want to re-run this app after my file was saved. Right now
const retry = await inquirer.prompt([
{
name: 'retry',
message: 'Do you want to try again',
type: 'confirm',
},
]);
if (!retry.retry) process.exit();
startApp();
this part of my code runs before my file was saved and this can casue some problems.
Any suggestions on how to deal with this issue?
You are calling few async method with out using await statement (addImageWatermarkToImage, addImageWatermarkToImage). use await and check the same.
//Consider the following example. Execute the test method with and with out awit, it may help you to understood
function timeout(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function sleep(fn, ...args) {
await timeout(3000);
return fn(...args);
}
async function test() {
//add and remove the below await and execute this function
await sleep(()=>{console.log("Run first")},{});
console.log("Run Second");
}
test();

Node js asynchrounous call

Everytime adding a new data or updating existing data, new_data and updated_data variables will get increment. But when I try to console log the total count's (new_data and updated_data) at the bottom of the code the result is 0. How do we address this in Node using async ?
Code
let new_data = 0
let updated_data = 0
let vehicles = _.each(results.data, function (value, key) {
let condition = { VIN: value.VIN }
Vehicle.model.findOne(condition, function (err, doc) {
if (doc) {
let condition2 = { VIN: doc.VIN }
Vehicle.model.update(condition2, value, function (err, doc1) {
updated_data += 1
if (doc && typeof doc.log === 'function') {
const data = {
action: 'Update',
category: 'Inventory Import',
message: 'Import Successful',
status: '200'
}
return doc.log(data);
}
})
} else {
Vehicle.model.create(value, function (err, doc2) {
new_data += 1
if (doc2 && typeof doc2.log === 'function') {
const data = {
action: 'Create',
category: 'Inventory Import',
message: 'Import Successful',
status: '200'
}
return doc2.log(data);
}
})
}
})
})
console.log("new datad : ", new_data)
console.log("updated_data : ", updated_data)
You can Use ASYNC/AWAIT for that put that code in an async function and then wait for each query for that.I used for of loop because it is synchronous.
const func = async() => {
let new_data = 0
let updated_data = 0
for(value of results.data){
try{
let condition = { VIN: value.VIN }
let doc = await Vehicle.model.findOne(condition);
if (doc) {
let condition2 = { VIN: doc.VIN }
let doc1 = await Vehicle.model.update(condition2)
updated_data += 1
if (doc && typeof doc.log === 'function') {
const data = {
action: 'Update',
category: 'Inventory Import',
message: 'Import Successful',
status: '200'
}
doc.log(data);
}
} else {
let doc2 = await Vehicle.model.create(value);
new_data += 1
if (doc2 && typeof doc2.log === 'function') {
const data = {
action: 'Create',
category: 'Inventory Import',
message: 'Import Successful',
status: '200'
}
doc2.log(data);
}
}
}catch(err){
console.log(err);
}
}
console.log("new datad : ", new_data)
console.log("updated_data : ", updated_data)
}
func();

Categories

Resources