Gathering detailed insights and metrics for redux-shapeshifter-middleware
Gathering detailed insights and metrics for redux-shapeshifter-middleware
Gathering detailed insights and metrics for redux-shapeshifter-middleware
Gathering detailed insights and metrics for redux-shapeshifter-middleware
Redux middleware for empowering your actions using axios and qs libraries combined
npm install redux-shapeshifter-middleware
Typescript
Module System
Node Version
NPM Version
JavaScript (100%)
Total Downloads
0
Last Day
0
Last Week
0
Last Month
0
Last Year
0
246 Commits
1 Watchers
23 Branches
1 Contributors
Updated on Dec 28, 2020
Latest Version
1.3.5
Package Id
redux-shapeshifter-middleware@1.3.5
Unpacked Size
89.46 kB
Size
20.50 kB
File Count
35
NPM Version
6.4.1
Node Version
10.13.0
Cumulative downloads
Total Downloads
Last Day
0%
NaN
Compared to previous day
Last Week
0%
NaN
Compared to previous week
Last Month
0%
NaN
Compared to previous month
Last Year
0%
NaN
Compared to previous year
2
1
23
Redux middleware that will empower your actions to become your go-to guy whenever there is a need for ajax calls ... and have you say, ...!
1$ npm install redux-shapeshifter-middleware 2# or 3$ yarn add redux-shapeshifter-middleware
1import { createStore, applyMiddleware } from 'redux'; 2import shapeshifter from 'redux-shapeshifter-middleware'; 3 4const apiMiddleware = shapeshifter({ 5 base: 'http://api.url/v1/', 6 /** 7 * If ACTION.payload.auth is set to `true` this will kick in and add the 8 * properties added here to the API request. 9 * 10 * Note: These values will be taken from Redux store 11 * e.g. below would result in: 12 * Store { 13 * user: { 14 * sessionid: '1234abcd' 15 * } 16 * } 17 */ 18 auth: { 19 user: 'sessionid', 20 }, 21 fallbackToAxiosStatusResponse: true, // default is: true 22 // Above tells the middleware to fallback to Axios status response if 23 // the data response from the API call is missing the property `status`. 24 // 25 // If you however would like to deal with the status responses yourself you might 26 // want to set this to false and then in the response object from your back-end 27 // always provide a `status` property. 28 useOnlyAxiosStatusResponse: true, // default is: false 29 // Above would ignore `fallbackToAxiosStatusResponse` and 30 // `customSuccessResponses` if set to true. This means that we will use 31 // Axios response object and its status code instead of relying on one 32 // passed to the response.data object, or fallbacking to response.status 33 // if response.data.status is missing. 34 useETags: false, // default is: false 35}) 36 37const store = createStore( 38 reducers, 39 applyMiddleware( 40 // ... other middlewares 41 someMiddleware, 42 apiMiddleware, 43 ), 44)
1import { createStore, applyMiddleware } from 'redux'; 2import shapeshifter from 'redux-shapeshifter-middleware'; 3 4const shapeshifterOpts = { 5 base: 'http://api.url/v1/', 6 7 8 auth: { 9 user: { 10 sessionid: true, 11 // If you wish to make sure a property is NOT present 12 // you may pass `false` instead.. note that this means 13 // the back-end would have to deal with the incoming data. 14 }, 15 }, 16 17 /** 18 * constants.API 19 * Tells the middleware what action type it should act on 20 * 21 * constants.API_ERROR 22 * If back-end responds with an error or call didn't go through, 23 * middleware will emit 'API_ERROR'.. Unless you specified your own 24 * custom 'failure'-method within the 'payload'-key in your action. 25 * ``` 26 * return { 27 * type: API_ERROR, 28 * message: "API/FETCH_ALL_USERS failed.. lol", 29 * error: error // error from back-end 30 * } 31 * ``` 32 * 33 * constants.API_VOID 34 * Mainly used within generator functions, if we don't end 35 * the generator function with a `return { type: SOME_ACTION }`. 36 * Then the middleware will emit the following: 37 * ``` 38 * return { 39 * type: API_VOID, 40 * LAST_ACTION: 'API/FETCH_ALL_USERS' // e.g... 41 * } 42 * ``` 43 */ 44 constants: { 45 API : 'API_CONSTANT', // default: 'API' 46 API_ERROR : 'API_ERROR_RESPONSE', // default: 'API_ERROR' 47 API_VOID : 'API_NO_RESPONSE', // default: 'API_VOID' 48 } 49} 50const apiMiddleware = shapeshifter( shapeshifterOpts ) 51 52const store = createStore( 53 reducers, 54 applyMiddleware( 55 // ... other middlewares 56 someMiddleware, 57 apiMiddleware, 58 ), 59)
1import { createStore, applyMiddleware } from 'redux'; 2import shapeshifter from 'redux-shapeshifter-middleware'; 3 4const shapeshifterOpts = { 5 base: 'http://api.url/v1/', 6 auth: { 7 headers: { 8 'Authorization': 'Bearer #user.token', 9 // Above will append the key ("Authorization") to each http request being made 10 // that has the `ACTION.payload.auth` set to true. 11 // The value of the key has something weird in it, "#user.token". What this means is 12 // that when the request is made this weird part will be replaced with the actual 13 // value from the Redux store. 14 // 15 // e.g. this could be used more than once, or it could also be just for deeper values 16 // 'Bearer #user.data.private.token'. 17 }, 18 }, 19 // .. retracted code, because it's the same as above. 20} 21// .. retracted code, because it's the same as above.
All options that the middleware can take.
base <string>
default: ''
This sets the base url for all API calls being made through this middleware. Could be overwritten by using the ACTION.axios.baseURL
property on the Action.
constants <object>
API <string>
default: 'API'
This is the type this middleware will look for when actions are being dispatched.
API_ERROR <string>
default: 'API_ERROR'
When an http request fails, this is the type that will be dispatched and could be used to return a visual response to the end-user e.g. on a failed login attempt.
API_VOID <string>
default: undefined
Upon success of a generator function we have the choice to pass a type of our own, if the return statement is omitted or if there is no returned object with a key type
then this will be dispatched as the type
inside an object, along with another key LAST_ACTION
which references the type that initiated the process.
auth <object>
default: undefined
When making a request you can pass the ACTION.payload.auth <boolean>
property to ACTION.payload <object>
, doing this will activate this object which in return will pass the value as a parameter to the request being made.
Note that any properties or values passed within the auth {} object are connected to the Store.
It is not possible to mix Example 1 and 2 with Example 3
Example 1 with a shallow value to check:
1const apiMiddleware = shapeshifter({ 2 // .. retracted code 3 auth: { 4 user: 'sessionid', 5 }, 6})
Looking at Example 1 it would on any HTTP request being made with ACTION.payload.auth = true
would check the Store for the properties user
and within that sessionid
and pass the value found to the request as a parameter.
Example 2 with a nested value to disallow:
1const apiMiddleware = shapeshifter({ 2 // .. retracted code 3 auth: { 4 user: 'sessionid', 5 profile: { 6 account: { 7 freeMember: false, 8 }, 9 }, 10 }, 11})
Passing a boolean
as the value will check that the property does not exist on the current Store, if it does a warning will be emitted and the request will not be made. Could be done the other way around, if you pass true
it would be required to have that property in the Store.. although it would be up to the back-end to evaluate the value coming from the Store in that case.
Example 3 with a nested property and headers authorization:
1const apiMiddleware = shapeshifter({ 2 // .. retracted code 3 auth: { 4 headers: { 5 'Authorization': 'Bearer #user.token', 6 7 // or even deeper 8 'Authorization': 'Bearer #user.data.private.token', 9 10 // or even multiple values 11 'custom-header': 'id=#user.id name=#user.private.name email=#user.private.data.email', 12 }, 13 }, 14})
Example 3 allows us to pass headers for authorization on requests having the ACTION.payload.auth
set to true.
useETags <boolean>
default: false
This will enable the middleware to store ETag(s) if they exist in the response with the URI segments as the key.
dispatchETagCreationType <string>
default: undefined
Requires useETags
to be set to true.
When the middleware handles a call it will check if the response has an ETag header set, if it does, we store it. Though as we store it we will also emit the given value set to dispatchETagCreationType
so that it's possible to react when the middleware stores the call and its ETag value.
Example of action dispatched upon storing of ETag:
{
type: valuePassedTo_dispatchETagCreationType,
ETag: 'randomETagValue',
key: '/fetch/users/',
}
matchingETagHeaders <function>
default: undefined
obj <object>
ETag <string>
dispatch <function>
state <object>
getState <function>
Requires useETags
to be set to true.
Takes a function which is called when any endpoint has an ETag stored (which is done by the middleware if the response holds an ETag property). The function receives normal store operations as well as the matching ETag
identifier for you to append to the headers you wish to pass.
If nothing passed to this property the following will be the default headers passed if the call already has stored an ETag:
{
'If-None-Match': 'some-etag-value',
'Cache-Control': 'private, must-revalidate',
}
handleStatusResponses <function>
default: null
<object>
The Axios response object.<object>
#dispatch() <function>
#getState <function>
#state <object>
NOTE that this method must return either Promise.resolve()
or Promise.reject()
depending on your own conditions..
Defining this method means that any customSuccessResponses
defined or any error handling done by the middleware will be ignored.. It's now up to you to deal with that however you like. So by returning a Promise.reject()
the *_FAILURE
Action would be dispatched or vice versa if you would return Promise.resolve()
..
Example
1const apiMiddleware = shapeshifter({ 2 // .. retracted code 3 handleStatusResponses(response, store) { 4 if ( response.data && response.data.errors ) { 5 // Pass the error message or something similar along with the failed Action. 6 return Promise.reject( response.data.errors ) 7 } 8 9 // No need to pass anything here since the execution will continue as per usual. 10 return Promise.resolve() 11 } 12})
fallbackToAxiosStatusResponse <boolean>
default: true
If you've built your own REST API and want to determine yourself what's right or wrong then setting this value to false would help you with that. Otherwise this would check the response object for a status
key and if none exists it falls back to what Axios could tell from the request made.
customSuccessResponses <array>
default: null
In case you are more "wordy" in your responses and your response object might look like:
1{ 2 user: { 3 name: 'DAwaa' 4 }, 5 status: 'success' 6}
Then you might want to consider adding 'success' to the array when initializing the middleware to let it know about your custom success response.
useOnlyAxiosStatusResponse <boolean>
default: false
This ignores fallbackToAxiosStatusResponse
and customSuccessResponses
, this means it only looks at the status code from the Axios response object.
emitRequestType <boolean>
default: false
By default redux-shapeshifter-middleware
doesn't emit the neutral action type. It returns either the *_SUCCESS
or *_FAILED
depending on what the result of the API call was.
By setting emitRequestType
to true
the middleware will also emit YOUR_ACTION
along with its respective types, YOUR_ACTION_SUCCESS
and YOUR_ACTION_FAILED
based on the situation.
useFullResponseObject <boolean>
default: false
By default redux-shapeshifter-middleware
actions will upon success return response.data
for you to act upon, however sometimes it's wanted to actually have the entire response
object at hand. This option allows to define in one place if all shapeshifter actions should return the response
object.
However if you're only interested in some actions returning the full response
object you could have a look at ACTION.payload.useFullResponseObject
to define it per action instead.
warnOnCancellation <boolean>
default: false
By default when cancelling axios
calls the dependency itself will throw an error with a user-defined reason to why it was canceled. This behavior could be unwanted if let's say you're using an error-catching framework that records and logs all client errors that occurs in production for users. It's not likely that everyone would consider a canceled call "serious" enough to be an error. In this case configuring this option to true
then only a console.warn(reason)
will be emitted to the console.
throwOnError <boolean>
default: false
By default shapeshifter
will not bubble up errors but instead swallow them and dispatch *_FAILURE
(or what you decide to call your failure actions) actions along with logging the error to the console. Setting this option to true
will no longer log errors but instead throw them, which requires a .catch()
method to be implemented to avoid unhandled promise rejection
errors.
This can also be done on ACTION level in the case you don't want all actions to throw but only one or few ones.
axios <object>
default: undefined
Note
Any property defined under axios
will be overridden by ACTION.axios
if the same property appears in both.
In the case you want to have a global configuration for all of your ACTIONs handled by shapeshifter this is the right place to look at. What you'll be able to access through this can be seen under Axios documentation.
We will explore what properties there are to be used for our new actions..
A valid shapeshifter action returns a Promise
.
ACTION.type <string>
Nothing unusual here, just what type we send out to the system.. For the middleware to pick it up, a classy 'API' would do, unless you specified otherwise in the set up of shapeshifter.
1const anActionFn = () => ({ 2 type: 'API', // or API (without quotation marks) if you're using a constant 3 ... 4})
ACTION.types <array>
An array containing your actions
1const anActionFn = () => ({ 2 type: 'API', 3 types: [ 4 WHATEVER_ACTION, 5 WHATEVER_ACTION_SUCCESS, 6 WHATEVER_ACTION_FAILED, 7 ], 8 ... 9})
ACTION.method <string>
default: 'get'
1const anActionFn = () => ({ 2 type: 'API', 3 types: [ 4 WHATEVER_ACTION, 5 WHATEVER_ACTION_SUCCESS, 6 WHATEVER_ACTION_FAILED, 7 ], 8 method: 'post', // default is: get 9 ... 10})
ACTION.payload <function>
<object>
#dispatch() <function>
#state <object>
This property and its value is what actually defines the API call we want to make.
Note Payload must return an object. Easiest done using a fat-arrow function like below.
1const anActionFn = () => ({ 2 type: 'API', 3 types: [ 4 WHATEVER_ACTION, 5 WHATEVER_ACTION_SUCCESS, 6 WHATEVER_ACTION_FAILED, 7 ], 8 payload: store => ({ 9 }), 10 // or if you fancy destructuring 11 // payload: ({ dispatch, state }) => ({})
Acceptable properties to be used by the returned object from ACTION.payload
1const anActionFn = () => ({ 2 type: 'API', 3 types: [ 4 WHATEVER_ACTION, 5 WHATEVER_ACTION_SUCCESS, 6 WHATEVER_ACTION_FAILED, 7 ], 8 payload: store => ({ 9 // THE BELOW PROPERTIES GO IN HERE <<<<<< 10 }),
ACTION.payload.url <string>
ACTION.payload.params <object>
ACTION.payload.tapBeforeCall <function>
obj <object>
params <object>
dispatch <function>
state <object>
getState <function>
Is called before the API request is made, also the function receives an object argument.
ACTION.payload.success <function>
type <string>
payload <object>
response.data
. If you want the full response
object, have a look at middleware.useFullResponseObject
or per action ACTION.payload.useFullResponseObject
meta|store <object>
meta
key is missing from the first level of the API action, then this 3rd argument will be replaced with store
.store <object>
-- Will be 'null' if no meta
key was defined in the first level of the API action.This method is run if the API call went through successfully with no errors.
ACTION.payload.failure <function>
type <string>
error <mixed>
This method is run if the API call responds with an error from the back-end.
ACTION.payload.repeat <function>
response <object>
The Axios response objectresolve <function>
reject <function>
Inside the repeat
-function you will have the Axios response object at hand to determine yourself when you want to pass either the *_SUCCESS
or *_FAILED
action.
There are two primary ways to denote an action from this state, either returning a boolean
or calling one of the two other function arguments passed to repeat()
, namely resolve
and reject
.
Returning a boolean from ACTION.payload.repeat
will send the Axios response object to either the ACTION.payload.success
or ACTION.payload.failure
method of your API action as the payload.
However if you denote your action using either resolve
or reject
, whatever passed to either of these two will be the payload sent to ACTION.payload.success
or ACTION.payload.failure
.
1// Returning a boolean 2const success = () => { /* retracted code */} 3const failure = () => { /* retracted code */} 4 5export const fetchUser = () => ({ 6 type: API, 7 types: [ 8 FETCH_USER, 9 FETCH_USER_SUCCESS, 10 FETCH_USER_FAILED, 11 ], 12 payload: () => ({ 13 url: '/users/user/fetch', 14 success, 15 failure, 16 interval: 100, 17 repeat: (response) => { 18 const { data } = response 19 20 if (data && data.user && data.user.isOnline) { 21 return true // This tells the middleware to call 22 // the `success`-method defined above 23 // with the Axios response object. 24 // 25 // Same thing would've happened if one 26 // were to return `false`, however the 27 // `failure`-method would be called instead. 28 } 29 } 30 }) 31})
1// Returning custom payload 2const success = () => { /* retracted code */} 3const failure = () => { /* retracted code */} 4 5export const fetchUser = () => ({ 6 type: API, 7 types: [ 8 FETCH_USER, 9 FETCH_USER_SUCCESS, 10 FETCH_USER_FAILED, 11 ], 12 payload: () => ({ 13 url: '/users/user/fetch', 14 success, 15 failure, 16 interval: 100, 17 repeat: (response, resolve, reject) => { 18 const { data } = response 19 20 if (data && data.user && data.user.isOnline) { 21 return resolve({ userIsOnline: true }) // Here we return and call 22 // `resolve`-method with a 23 // custom payload. This will 24 // like above example call the 25 // `success`-method with the given 26 // value passed to `resolve` as the 27 // payload for `success`. 28 // 29 // Vice versa if one were to call 30 // `reject`-method instead with a 31 // custom payload, the `failure`- 32 // method would be called and the 33 // passed value would be the payload. 34 } 35 } 36 }) 37})
ACTION.payload.interval <integer>
default: 5000
This is used in combination with the ACTION.payload.repeat
function. How often we should be calling the given endpoint.
ACTION.payload.tapAfterCall <function>
obj <object>
params <object>
dispatch <function>
state <object>
getState <function>
Same as ACTION.payload.tapBeforeCall <function>
but is called after the API request was made however not finished.
ACTION.payload.auth <boolean>
default: false
If the API call is constructed with auth: true
and the middleware set up was initialized with an auth
key pointing to the part of the store you want to use for authorization in your API calls. Then what you set up in the initialization will be added to the requests parameters automatically for you.
ACTION.payload.ETagCallback <object|function>
default: undefined
Requires useETags
to be set to true.
When a call is made and the response has already been cached as the resource hasn't changed since last time. We will emit either an object if passed to ETagCallback
or run a function if provided.
If a function is provided the fuction will receive following arguments:
obj <object>
type <string>
The neutral type is return, e.g. FETCH_USER
and not any of the ones that has suffix _SUCCESS
or _FAILED
.
path <string>
The path called, e.g. /fetch/users
.
ETag <string>
The ETag used resulting in a 304 response.
dispatch <function>
state <object>
getState <function>
ACTION.payload.useFullResponseObject <boolean>
default: false
In the case you still want the middleware to return response.data
for your other actions but only one or few should return the full response
object you could set this property to true
and the action will in it's success
-method return the full response
object.
ACTION.payload.throwOnError <boolean>
default: false
By default shapeshifter
will not bubble up errors but instead swallow them and dispatch *_FAILURE
(or what you decide to call your failure actions) actions along with logging the error to the console. Setting this option to true
will no longer log errors but instead throw them, which requires a .catch()
method to be implemented to avoid unhandled promise rejection
errors.
ACTION.meta <object>
This is our jack-in-the-box prop, you can probably think of lots of cool stuff to do with this, but below I will showcase what I've used it for.
Basically this allows to bridge stuff between the action and the ACTION.payload.success()
method.
Note
Check ACTION.payload.success
above to understand where these meta tags will be available.
1const success = (type, payload, meta, store) => ({ 2 // We can from here reach anything put inside `meta` property 3 // inside the action definition. 4 type: type, 5 heeliesAreCool: meta.randomKeyHere.heeliesAreCool, 6}) 7 8const fetchHeelies = () => ({ 9 type: 'API', 10 types: [ 11 FETCH_HEELIES, 12 FETCH_HEELIES_SUCCESS, 13 FETCH_HEELIES_FAILED, 14 ], 15 payload: store => ({ 16 url: '/fetch/heelies/', 17 params: { 18 color: 'pink', 19 }, 20 success: success, 21 }), 22 meta: { 23 randomKeyHere: { 24 heeliesAreCool: true, 25 }, 26 },
ACTION.meta.mergeParams <boolean>
default: false
Just like this property states, it will pass anything you have under the property ACTION.payload.params
to the ACTION.meta
parameter passed to ACTION.payload.success()
method.
ACTION.axios <object>
This parameter allows us to use any Axios Request Config property that you can find under their docs.. here.
Anything added under the ACTION.axios<object>
will have higher priority, meaning
that it will override anything set before in the payload object that has the
same property name.
A normal case where we have both dispatch and our current state for our usage.
1// internal 2import { API } from '__actions__/consts' 3 4export const FETCH_ALL_USERS = 'API/FETCH_ALL_USERS' 5export const FETCH_ALL_USERS_SUCCESS = 'API/FETCH_ALL_USERS_SUCCESS' 6export const FETCH_ALL_USERS_FAILED = 'API/FETCH_ALL_USERS_FAILED' 7 8// @param {string} type This is our _SUCCESS constant 9// @param {object} payload The response from our back-end 10const success = (type, payload) => ({ 11 type : type, 12 users : payload.items 13}) 14 15// @param {string} type This is our _FAILED constant 16// @param {object} error The error response from our back-end 17const failure = (type, error) => ({ 18 type : type, 19 message : 'Failed to fetch all users.', 20 error : error 21}) 22 23export const fetchAllUsers = () => ({ 24 type: API, 25 types: [ 26 FETCH_ALL_USERS, 27 FETCH_ALL_USERS_SUCCESS, 28 FETCH_ALL_USERS_FAILED 29 ], 30 method: 'get', // default is 'get' - this could be omitted in this case 31 payload: ({ dispatch, state }) => ({ 32 url: '/users/all', 33 success: success, 34 failure: failure 35 }) 36})
A case where we make us of a generator function.
1// internal 2import { API } from '__actions__/consts' 3 4export const FETCH_USER = 'API/FETCH_USER' 5export const FETCH_USER_SUCCESS = 'API/FETCH_USER_SUCCESS' 6export const FETCH_USER_FAILED = 'API/FETCH_USER_FAILED' 7 8// @param {string} type This is our _SUCCESS constant 9// @param {object} payload The response from our back-end 10// @param {object} store - { dispatch, state, getState } 11const success = function* (type, payload, { dispatch, state }) { 12 // Get the USER id 13 const userId = payload.user.id 14 15 // Fetch name of user 16 const myName = yield new Promise((resolve, reject) => { 17 axios.get('some-weird-url', { id: userId }) 18 .then((response) => { 19 // Pretend all is fine and we get our name... 20 resolve( response.name ); 21 }) 22 }) 23 24 dispatch({ type: 'MY_NAME_IS_WHAT', name: myName }) 25 26 // Conditionally if we want to emit to the 27 // system that the call is done. 28 return { 29 type, 30 } 31 // Otherwise the middleware itself would emit 32 return { 33 type: 'API_VOID', 34 LAST_ACTION: 'FETCH_USER', 35 } 36} 37 38// @param {string} type This is our _FAILED constant 39// @param {object} error The error response from our back-end 40const failure = (type, error) => ({ 41 type : type, 42 message : 'Failed to fetch all users.', 43 error : error, 44}) 45 46export const fetchAllUsers = userId => ({ 47 type: API, 48 types: [ 49 FETCH_USER 50 FETCH_USER_SUCCESS, 51 FETCH_USER_FAILED 52 ], 53 method: 'get', // default is 'get' - this could be omitted in this case 54 payload: ({ dispatch, state }) => ({ 55 url: '/fetch-user-without-their-name', 56 params: { 57 id: userId 58 }, 59 success: success, 60 failure: failure 61 }), 62})
Just like the normal example
but this illustrates it can be chained.
1// ... same code as the normal example 2 3export const fetchAllUsers = userId => ({ 4 ... // same code as the normal example 5}) 6 7// another-file.js 8import { fetchAllUsers } from './somewhere.js'; 9 10fetchAllUsers() 11 .then(response => { 12 // this .then() happens after the dispatch of `*_SUCCESS` has happened. 13 // here you have access to the full axios `response` object 14 }) 15 .catch(error => { 16 // this .catch() happens after the dispatch of `*_FAILED` has happened. 17 // here you have access to the `error` that was thrown 18 })
No vulnerabilities found.
Reason
no binaries found in the repo
Reason
Found 0/23 approved changesets -- score normalized to 0
Reason
0 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 0
Reason
no effort to earn an OpenSSF best practices badge detected
Reason
security policy file not detected
Details
Reason
license file not detected
Details
Reason
project is not fuzzed
Details
Reason
branch protection not enabled on development/release branches
Details
Reason
SAST tool is not run on all commits -- score normalized to 0
Details
Reason
35 existing vulnerabilities detected
Details
Score
Last Scanned on 2025-07-14
The Open Source Security Foundation is a cross-industry collaboration to improve the security of open source software (OSS). The Scorecard provides security health metrics for open source projects.
Learn More