From b8455488761fdd199f012325180929fc7346c988 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 6 Jan 2023 16:47:18 -0800 Subject: [PATCH] Documentation is now in Github first --- .github/workflows/main-ci.yaml | 35 +- apps/doc2crystallize/build/fs-utils.d.ts | 2 + apps/doc2crystallize/build/fs-utils.js | 21 + apps/doc2crystallize/build/index.d.ts | 1 + apps/doc2crystallize/build/index.js | 30 + apps/doc2crystallize/package.json | 30 + apps/doc2crystallize/src/fs-utils.ts | 21 + apps/doc2crystallize/src/index.ts | 33 + apps/doc2crystallize/tsconfig.json | 10 + components/js-api-client/README.md | 515 ++- .../README.md | 456 ++- components/node-service-api-router/README.md | 156 +- components/reactjs-components/README.md | 138 +- components/reactjs-hooks/README.md | 79 +- yarn.lock | 2808 +++++++++-------- 15 files changed, 2950 insertions(+), 1385 deletions(-) create mode 100644 apps/doc2crystallize/build/fs-utils.d.ts create mode 100644 apps/doc2crystallize/build/fs-utils.js create mode 100644 apps/doc2crystallize/build/index.d.ts create mode 100644 apps/doc2crystallize/build/index.js create mode 100644 apps/doc2crystallize/package.json create mode 100644 apps/doc2crystallize/src/fs-utils.ts create mode 100644 apps/doc2crystallize/src/index.ts create mode 100644 apps/doc2crystallize/tsconfig.json diff --git a/.github/workflows/main-ci.yaml b/.github/workflows/main-ci.yaml index 6e35b514..98012610 100644 --- a/.github/workflows/main-ci.yaml +++ b/.github/workflows/main-ci.yaml @@ -25,7 +25,7 @@ jobs: needs: [lint] strategy: matrix: - node: [14, 16, 18] + node: [16, 18] env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }} @@ -59,7 +59,7 @@ jobs: needs: [lint] strategy: matrix: - node: [14, 16, 18] + node: [16, 18] env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }} @@ -87,3 +87,34 @@ jobs: echo ::endgroup:: fi done + sync: + name: 🍿 Sync Doc to Crystallize + if: github.event_name == 'push' && github.ref_name == 'main' + runs-on: ubuntu-latest + needs: [lint, test, builds] + steps: + - name: 🛑 Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.10.0 + + - name: ⎔ Setup node + uses: actions/setup-node@v3 + with: + node-version: 16 + + - name: ⬇️ Checkout repo + uses: actions/checkout@v3 + + - name: 📥 Download deps + run: yarn install && yarn build + + - name: 🚀 Sync it! + env: + MARKDOWN_TO_CRYSTALLIZE_SHARED_KEY: ${{ secrets.MARKDOWN_TO_CRYSTALLIZE_SHARED_KEY }} + run: | + for COMPONENT in `ls components`; do + if [ -d "components/${COMPONENT}" ]; then + echo ::group::..:: ${COMPONENT} ::.. + node apps/doc2crystallize/build/index.js components/${COMPONENT}/README.md + echo ::endgroup:: + fi + done diff --git a/apps/doc2crystallize/build/fs-utils.d.ts b/apps/doc2crystallize/build/fs-utils.d.ts new file mode 100644 index 00000000..5c94e388 --- /dev/null +++ b/apps/doc2crystallize/build/fs-utils.d.ts @@ -0,0 +1,2 @@ +export declare function isFileExists(path: string): boolean; +export declare function loadFile(path: string): Promise; diff --git a/apps/doc2crystallize/build/fs-utils.js b/apps/doc2crystallize/build/fs-utils.js new file mode 100644 index 00000000..87ffe8cb --- /dev/null +++ b/apps/doc2crystallize/build/fs-utils.js @@ -0,0 +1,21 @@ +import fs from 'fs'; +export function isFileExists(path) { + return fs.existsSync(path); +} +export function loadFile(path) { + return new Promise((resolve, reject) => { + fs.readFile(path, 'utf8', (err, content) => { + if (err) { + reject(err); + } + else { + try { + resolve(content); + } + catch (err) { + reject(err); + } + } + }); + }); +} diff --git a/apps/doc2crystallize/build/index.d.ts b/apps/doc2crystallize/build/index.d.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/apps/doc2crystallize/build/index.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/apps/doc2crystallize/build/index.js b/apps/doc2crystallize/build/index.js new file mode 100644 index 00000000..d8fe9af2 --- /dev/null +++ b/apps/doc2crystallize/build/index.js @@ -0,0 +1,30 @@ +import { isFileExists, loadFile } from './fs-utils.js'; +import md5 from 'md5'; +import fetch from 'node-fetch'; +const bossmanEndpoint = 'https://bossman.crystallize.com/md2crystal'; +async function main(markdownFilePath) { + if (!isFileExists(markdownFilePath)) { + console.error(`File ${markdownFilePath} does not exist`); + process.exit(1); + } + const markdown = await loadFile(markdownFilePath); + const now = Math.floor(Date.now() / 1000); + const key = md5(`${now}-X-${process.env.MARKDOWN_TO_CRYSTALLIZE_SHARED_KEY}`); + const response = await fetch(bossmanEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + 'X-Secure-Value': now.toFixed(0), + 'X-Secure-Signature': key, + }, + body: markdown, + }); + const result = await response.json(); + if (result.status === 'ok') { + console.log(`Publish to Crystallize: https://app.crystallize.com/@${result.tenantIdentifier}/en/catalogue/${result.type}/${result.objectId}`); + } + else { + console.error(`File was not correctly published to Crystallize`); + } +} +main(process.argv[2]).catch(console.error); diff --git a/apps/doc2crystallize/package.json b/apps/doc2crystallize/package.json new file mode 100644 index 00000000..5d03cad3 --- /dev/null +++ b/apps/doc2crystallize/package.json @@ -0,0 +1,30 @@ +{ + "type": "module", + "name": "@crystallize/doc2crystallize", + "version": "0.0.1", + "engines": { + "node": ">=16.0" + }, + "scripts": { + "start": "node -r dotenv/config build/index.js", + "watch": "tsc -W", + "build": "tsc" + }, + "private": true, + "volta": { + "node": "16.14.2", + "yarn": "1.22.18" + }, + "dependencies": { + "dotenv": "^16.0.1", + "md5": "^2.3.0", + "node-fetch": "^3.3.0", + "path": "^0.12.7" + }, + "devDependencies": { + "@tsconfig/node16": "^1", + "@types/md5": "^2.3.2", + "@types/node": "^18.0.6", + "typescript": "^4.7.4" + } +} diff --git a/apps/doc2crystallize/src/fs-utils.ts b/apps/doc2crystallize/src/fs-utils.ts new file mode 100644 index 00000000..be8c3dc4 --- /dev/null +++ b/apps/doc2crystallize/src/fs-utils.ts @@ -0,0 +1,21 @@ +import fs from 'fs'; + +export function isFileExists(path: string): boolean { + return fs.existsSync(path); +} + +export function loadFile(path: string): Promise { + return new Promise((resolve, reject) => { + fs.readFile(path, 'utf8', (err, content) => { + if (err) { + reject(err); + } else { + try { + resolve(content); + } catch (err) { + reject(err); + } + } + }); + }); +} diff --git a/apps/doc2crystallize/src/index.ts b/apps/doc2crystallize/src/index.ts new file mode 100644 index 00000000..9ace7fbe --- /dev/null +++ b/apps/doc2crystallize/src/index.ts @@ -0,0 +1,33 @@ +import { isFileExists, loadFile } from './fs-utils.js'; +import md5 from 'md5'; +import fetch from 'node-fetch'; + +const bossmanEndpoint = 'https://bossman.crystallize.com/md2crystal'; + +async function main(markdownFilePath: string) { + if (!isFileExists(markdownFilePath)) { + console.error(`File ${markdownFilePath} does not exist`); + process.exit(1); + } + const markdown = await loadFile(markdownFilePath); + const now = Math.floor(Date.now() / 1000); + const key = md5(`${now}-X-${process.env.MARKDOWN_TO_CRYSTALLIZE_SHARED_KEY}`); + const response = await fetch(bossmanEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + 'X-Secure-Value': now.toFixed(0), + 'X-Secure-Signature': key, + }, + body: markdown, + }); + const result: any = await response.json(); + if (result.status === 'ok') { + console.log( + `Publish to Crystallize: https://app.crystallize.com/@${result.tenantIdentifier}/en/catalogue/${result.type}/${result.objectId}`, + ); + } else { + console.error(`File was not correctly published to Crystallize`); + } +} +main(process.argv[2]).catch(console.error); diff --git a/apps/doc2crystallize/tsconfig.json b/apps/doc2crystallize/tsconfig.json new file mode 100644 index 00000000..4c0fc839 --- /dev/null +++ b/apps/doc2crystallize/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@tsconfig/node16/tsconfig.json", + "compilerOptions": { + "outDir": "./build", + "declaration": true, + "module": "ES2020", + "moduleResolution": "node" + }, + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/components/js-api-client/README.md b/components/js-api-client/README.md index d408dfec..a4174b4e 100644 --- a/components/js-api-client/README.md +++ b/components/js-api-client/README.md @@ -1,11 +1,514 @@ -# Crystallize API Client +# JS API Client ---- +This library provides simplifications and helpers to easily fetch data from your tenant. -This repository is what we call a "subtree split": a read-only copy of one directory of the main repository. +## Description -If you want to report or contribute, you should do it on the main repository: https://github.com/CrystallizeAPI/libraries +So far, the available helpers are: ---- +- Client to query or mutate data from Crystallize +- Mass Call Client that relies on the Client for mass operations +- Catalogue Fetcher +- Searcher +- Order Payment Updater +- Order Pusher +- Product Hydrater + - Paths + - Skus +- Navigation Fetcher +- Topics +- Folders +- CustomerManager +- Subscription Contract Manager -Documentation: https://crystallize.com/learn/open-source/sdks-and-libraries/js-api-client +## Installation + +With NPM: + +```bash +npm install @crystallize/js-api-client +``` + +With Yarn: + +```bash +yarn add @crystallize/js-api-client +``` + +## Simple Client + +This is a simple client to communicate with the Crystallize APIs. + +You get access to different helpers for each API: + +- catalogueApi +- searchApi +- orderApi +- subscriptionApi +- pimApi + +First, you need to create the _Client_: + +```javascript +import { createClient } from '@crystallize/js-api-client'; + +export const CrystallizeClient = createClient({ + tenantIdentifier: 'furniture', +}); +``` + +Then you can use it: + +```typescript +export async function fetchSomething(): Promise { + const caller = CrystallizeClient.catalogueApi; + const response = await caller(graphQLQuery, variables); + return response.catalogue; +} +``` + +There is a live demo: https://crystallizeapi.github.io/libraries/js-api-client/call-api + +## Catalogue Fetcher + +You can pass objects that respect the logic of https://www.npmjs.com/package/json-to-graphql-query to the Client. + +And because we can use plain simple objects, it means we can provide you a query builder. + +The goal is to help you build queries that are more than “strings”: + +```javascript +const builder = catalogueFetcherGraphqlBuilder; +await CrystallizeCatalogueFetcher(query, variables); +``` + +Example Query 1: + +```javascript +{ + catalogue: { + children: { + __on: [ + builder.onItem({ + ...builder.onComponent('test', 'RichText', { + json: true, + }), + }), + builder.onProduct({ + defaultVariant: { + firstImage: { + url: true, + }, + }, + }), + builder.onDocument(), + builder.onFolder(), + ], + }, + }, +} +``` + +Example Query 2: + +```javascript +{ + catalogue: { + ...builder.onComponent('grid', 'GridRelations', { + grids: { + rows: { + columns: { + layout: { + rowspan: true, + colspan: true, + }, + item: { + __on: [ + builder.onProduct( + { + name: true, + }, + { + onVariant: { + images: { + url: true, + }, + price: true, + }, + }, + ), + ], + }, + }, + }, + }, + }), + }, +} +``` + +The best way to learn how use the Fetcher is to [check the builder itself](https://github.com/CrystallizeAPI/libraries/blob/main/components/js-api-client/src/core/catalogue.ts#L20). + +## Navigation Fetcher + +In Crystallize, your Items or Topics are organized like a tree or graph, i.e. hierarchically. It's very common that you will want to build the navigation of your website following the Content Tree or the Topic Tree. + +These fetchers do the heavy lifting for you. Behind the scenes, they will build a recursive GraphQL query for you. + +There are 2 helpers for it that you get via createNavigationFetcher. You get an object with `byFolders` or `byTopics` that are functions. The function signatures are: + +```typescript +function fetch(path:string, language:string, depth:number, extraQuery: any, (level:number) => any); +``` + +Note: These helpers use the children property and are therefore not paginated. You have to take this into account. + +Example of Usage: + +```javascript +const response = await CrystallizeNavigationFetcher('/', 'en', 3).byFolders; +const response = await CrystallizeNavigationFetcher('/', 'en', 2).byTopics; +``` + +### To go even further + +You might want to return more information from that function by extending the GraphQL query that is generated for you. You can do that thanks to the last parameters. + +Those last parameters MUST return an object that respects the logic of https://www.npmjs.com/package/json-to-graphql-query + +Example: + +```javascript +const fetch = createNavigationFetcher(CrystallizeClient).byFolders; +const response = await fetch( + '/', + 'en', + 3, + { + tenant: { + __args: { + language: 'en', + }, + name: true, + }, + }, + (level) => { + switch (level) { + case 0: + return { + shape: { + identifier: true, + }, + }; + case 1: + return { + createdAt: true, + }; + default: + return {}; + } + }, +); +``` + +Here you will get not only the navigation but also the tenant name, the shape identifier for items of _depth=1_, and the creation date for items of _depth=2_. + +## Product Hydrater + +Usually in the context of the Cart/Basket, you might want to keep the SKUs and/or the paths of the Variants in the basket locally and ask Crystallize to hydrate the data at some point. + +There are 2 helpers that you get via _createProductHydrater_. You get an object with `byPaths` or `bySkus` that are functions. The function signatures are: + +```typescript +function hydrater( + items: string[], + language: string, + extraQuery: any, + perProduct: (item: string, index: number) => any, + perVariant: (item: string, index: number) => any, +); +``` + +When called, both return an array of products based on the strings in the arguments (paths or SKUs) you provided. + +Note: when you hydrate by SKU, the helper fetches the paths from the Search API. + +There is a live demo for both: + +- https://crystallizeapi.github.io/libraries/js-api-client/hydrater/by/paths +- https://crystallizeapi.github.io/libraries/js-api-client/hydrater/by/skus + +### To go even further + +You might want to return more information from that function by extending the GraphQL query that is generated for you. You can do that thanks to the last parameters. + +Those last parameters MUST return an object that respects the logic of https://www.npmjs.com/package/json-to-graphql-query + +Example: + +```javascript +const CrystallizeClient = createClient({ + tenantIdentifier: 'furniture', +}); +const hydrater = createProductHydrater(CrystallizeClient).byPaths; +const response = await hydrater( + [ + '/shop/bathroom-fitting/large-mounted-cabinet-in-treated-wood', + '/shop/bathroom-fitting/mounted-bathroom-counter-with-shelf', + ], + 'en', + { + tenant: { + id: true, + }, + perVariant: { + id: true, + }, + perProduct: { + firstImage: { + variants: { + url: true, + }, + }, + }, + }, +); +``` + +With this code, you get the _Products_, the current tenant id, the _id_ for each Variant, and for each product the URL of the first transcoded product Image. + +## Order Fetcher + +It is also very common to fetch an Order from Crystallize. It usually requires [authentication](https://crystallize.com/learn/developer-guides/api-overview/authentication), and this helper is probably more suitable for your Service API. This fetcher does the heavy lifting to simplify fetching orders. + +There are 2 helpers that you get via _createOrderFetcher_. You get an object with `byId` or `byCustomerIdentifier` that are functions. + +- **byId**: takes an _orderId_ in argument and fetches the related Order for you. +- **byCustomerIdentifier**: takes a _customerIdentifier_ and fetches all the Orders (with pagination) of that customer. + +Function signatures respectively are: + +```typescript +function byId(orderId: string, onCustomer?: any, onOrderItem?: any, extraQuery?: any); +function byId(customerIdentifier: string, extraQueryArgs?: any, onCustomer?: any, onOrderItem?: any, extraQuery?: any); +``` + +### To go even further + +You might want to return more information from that function by extending the GraphQL query that is generated for you. You can do that thanks to the last parameters. + +## Order Pusher + +You can use the *CrystallizeOrderPusher* to push an order to Crystallize. This helper will validate the order and throw an exception if the input is incorrect. Also, all the Types (and the Zod JS types) are exported so you can work more efficiently. + +```javascript +const caller = CrystallizeOrderPusher; +await caller({ + customer: { + firstName: 'William', + lastName: 'Wallace', + }, + cart: [ + { + sku: '123', + name: 'Bamboo Chair', + quantity: 3, + }, + ], +}); +``` + +This is the minimum to create an Order. Of course, the Order can be much more complex. + +## Order Payment Updater + +You can use the *CrystallizeCreateOrderPaymentUpdater* to update an order with payment information in Crystallize. This helper will validate the payment and throw an exception if the input is incorrect. And all the Types (and Zod JS types) are exported so you can work more efficiently. + +```javascript +const caller = CrystallizeCreateOrderPaymentUpdater; +const result = await caller('xXxYyYZzZ', { + payment: [ + { + provider: 'custom', + custom: { + properties: [ + { + property: 'payment_method', + value: 'Crystal Coin', + }, + { + property: 'amount', + value: '112358', + }, + ], + }, + }, + ], +}); +``` + +## Searcher + +You can use the *CrystallizeSearcher* to search through the Search API in a more sophisticated way. + +The JS API Client exposes a type _CatalogueSearchFilter_ and a type _catalogueSearchOrderBy_ that you can use in combination with other parameters to experience a better search. + +The _search_ function is a generator that allows you to seamlessly loop into the results while the lib is taking care of pagination. + +```javascript +const CrystallizeClient = createClient({ + tenantIdentifier: 'furniture', +}); + +//note: you can use the catalogueFetcherGraphqlBuilder +const nodeQuery = { + name: true, + path: true, +}; +const filter = { + type: 'PRODUCT', +}; +const orderBy = undefined; +const pageInfo = { + /* customize here if needed */ +}; + +for await (const item of createSearcher(CrystallizeClient).search('en', nodeQuery, filter, orderBy, pageInfo, { + total: 15, + perPage: 5, +})) { + console.log(item); // what you have passed to nodeQuery +} +``` + +## Customer Manager + +This manages the creation and updating of Customers in Crystallize. + +This is just a simple wrapper using a Schema to validate the input before calling the API for you. + +Example of creation: + +```javascript +const intent: CreateCustomerInputRequest = valideCustomerObject; +await CrystallizeCustomerManager.create({ + ...intent, + meta: [ + { + key: 'type', + value: 'particle', + }, + ], +}); +``` + +Example of update: + +```javascript +const intent: UpdateCustomerInputRequest = { + ...rest, + meta: [ + { + key: 'type', + value: 'crystal', + }, + ], +}; +await CrystallizeCustomerManager.update(identifier, intent); +``` + +## Subscription Contract Manager + +The Crystallize Subscription system is really powerful. The [documentation](https://crystallize.com/learn/concepts/subscription) is clear, so you know that to create a Subscription Contract based on a Product Variant that has a Plan, you need: + +- the **Product**: what are we buying +- the **ProductVariant**: the real thing we are actually buying +- the **Subscription Plan**: it may exist different kind of Plan on a Variant. Plans include the Metered Variables, etc. +- the **Period**: Monthly? Yearly? +- the **PriceVariantIdentifier**: USD? EUR? +- the **language** as Crystallize is fully multilingual. + +That’s the information you can retrieve from the Catalogue, the information that your buyer would put in his/her cart. + +When the time comes, you will need to create a Subscription Contract. + +From the documentation: + +``` +Creating Subscription Contracts +Once you’ve got a valid customer, created a subscription plan, and added the subscription plan to a product variant as needed, you’re ready to create a subscription contract. You can design the flow that you want, but usually, it’d be very close to what you would do on paper. First, you create a contract with your customer (subscription contract) that sets up the rules (price, metered variables, etc.), including the payment information (payment field) and the different subscription periods (initial and recurring). After the contract is created comes the payment, prepaid or paid after. Finally, there will be an order in Crystallize with the subscription contract ID and a subscription OrderItem to describe what this charge is for. +``` + +The same way you can create an Order with your own price (discounts, B2B pricing etc.), the Subscription Contract can have specific prices that are completely customized to the buyer. + +Wouldn’t it be nice to get a Subscription Contract Template (based on buyer decision) that you could just tweak? + +That’s one of the methods of the Subscription Contract Manager: + +```javascript +CrystallizeSubscriptionContractManager.createSubscriptionContractTemplateBasedOnVariantIdentity( + productPath, + { sku: variantSku }, + planIdentifier, + periodId, + priceVariantIdentifier, + 'en', +); +``` + +This will return a Subscription Contract that you can alter in order to save it to Crystallize: + +```javascript +const data = await CrystallizeSubscriptionContractManager.create({ + ...tweakedContract, + customerIdentifier: customerIdentifier, + item: productItem, + // custom stuff +}); +``` + +An Update method exists as well: + +```javascript +await CrystallizeSubscriptionContractManager.update(contractId, cleanUpdateContract); +``` + +## Mass Call Client + +Sometimes, when you have many calls to do, whether they are queries or mutations, you want to be able to manage them asynchronously. This is the purpose of the Mass Call Client. It will let you be asynchronous, managing the heavy lifting of lifecycle, retry, incremental increase or decrease of the pace, etc. + +These are the main features: + +- Run *initialSpawn* requests asynchronously in a batch. *initialSpawn* is the size of the batch per default +- If there are more than 50% errors in the batch, it saves the errors and continues with a batch size of 1 +- If there are less than 50% errors in the batch, it saves the errors and continues with a batch size of [batch size - 1] +- If there are no errors, it increments (+1) the number of requests in a batch, capped to *maxSpawn* +- If the error rate is 100%, it waits based on **Fibonnaci** increment +- At the end of all batches, you can retry the failed requests +- Optional lifecycle function *onBatchDone* (async) +- Optional lifecycle function *onFailure* (sync) allowing you to do something and decide to let enqueue (return true: default) or return false and re-execute right away, or any other actions +- Optional lifecycle function *beforeRequest* (sync) to execute before each request. You can return an altered request/promise +- Optional lifecycle function *afterRequest* (sync) to execute after each request. You also get the result in there, if needed + +```javascript +async function run() { + for (let i = 1; i <= 54; i++) { + client.enqueue.catalogueApi(`query { catalogue { id, key${i}: name } }`); + } + + const successes = await client.execute(); + console.log('First pass done ', successes); + console.log('Failed Count: ' + client.failureCount()); + while (client.hasFailed()) { + console.log('Retrying...'); + const newSuccesses = await client.retry(); + console.log('Retry pass done ', newSuccesses); + } + console.log('ALL DONE!'); +} +run(); +``` + +A full example is here: https://github.com/CrystallizeAPI/libraries/blob/main/components/js-api-client/src/examples/dump-tenant.ts + +[crystallizeobject]: crystallize_marketing|folder|625619f6615e162541535959 diff --git a/components/node-service-api-request-handlers/README.md b/components/node-service-api-request-handlers/README.md index 010a1438..face2cdd 100644 --- a/components/node-service-api-request-handlers/README.md +++ b/components/node-service-api-request-handlers/README.md @@ -1,11 +1,457 @@ # Node Service API Request Handlers ---- +This is a Node library that enables plug and play routing for your Service API when it is using the **Node Service API Router**. -This repository is what we call a "subtree split": a read-only copy of one directory of the main repository. +It provides schemas and handlers that take care of 90% of the work while being highly customizable and totally agnostic of any frameworks. -If you want to report or contribute, you should do it on the main repository: https://github.com/CrystallizeAPI/libraries +## Installation ---- +With NPM: -Documentation: https://crystallize.com/learn/open-source/sdks-and-libraries/node-service-api-request-handlers +```bash +npm install @crystallize/node-service-api-request-handlers +``` + +With Yarn: + +```bash +yarn add @crystallize/node-service-api-request-handlers +``` + +## Agnostic Handlers + +The schemas and handlers can be used with any framework and without Node Service API Router. + +A handler signature is always the same: + +```typescript +const handler = async (payload: Payload, args: Arguments): Promise +``` + +Using Node Service API Router, there is a simple integration: + +```typescript +const bodyConvertedRoutes: ValidatingRequestRouting = { + '/endpoint': { + post: { + schema: Payload, + handler: handler, + args: (context: Koa.Context): Arguments => { + return {}; + }, + }, + }, +}; +``` + +Outside of the Node Service API Router: + +```typescript +await handler(validatePayload(body, payloadSchema), {}); +``` + +Note: As you see, in this context, it’s your responsibility to validate the body with the schema (see a full example below, with Cart Management). + +## Cart Management + +The JS API Client already helps you to hydrate products from SKUs or Paths. This handler performs the next step: [it hydrates the products and more](https://github.com/CrystallizeAPI/libraries/blob/main/components/node-service-api-request-handlers/src/cart/handlers.ts#L6). + +First and as usual, it lets you extend the GraphQL hydration query. Second, it does the price calculation for you. + +To use it: + +```typescript +const bodyConvertedRoutes: ValidatingRequestRouting = { + '/cart': { + post: { + schema: cartPayload, + handler: handleCartRequestPayload, + args: (context: Koa.Context): CartHydraterArguments => { + return { + perVariant: () => { + return { + id: true, + }; + }, + }; + }, + }, + }, +}; +``` + +That’s it! The heavy lifting is done for you! + +If you are using the Handler without Node Service API Router, for example with Remix Run: + +```typescript +export const action: ActionFunction = async ({ request }) => { + const body = await request.json(); + await handleCartRequestPayload(validatePayload(body, cartPayload), { + currency, + perVariant: () => { + return { + firstImage: { + url: true, + }, + }; + }, +}); +``` + +### Available Arguments + +- currency (required): the Hydrater MUST know the currency to pick a valid PriceVariant +- hydraterBySkus (optional): your own Hydrater +- extraQuery (optional): if you want more information in the response +- perProduct (optional): if you want more information in the response per product hydrated +- perVariant (optional): if you want more information in the response per variant hydrated +- pricesHaveTaxesIncludedInCrystallize (optional): informs the handler if the prices in Crystallize include taxes or not to adapt the calculations. (default is FALSE) +- selectPriceVariant (optional): if you want to pick the PriceVariant (default is the first PriceVariant) +- basePriceVariant (optional): if you want to pick the PriceVariant used to calculated strike price (Discount) (default is selectPriceVariant) + +## Magick Link Authentication + +It comes with 2 handlers: + +- handleMagickLinkRegisterPayload +- handleMagickLinkConfirmationRequestPayload + +You can use them in the following way. + +Handling the registration / request for a link: + +```typescript +'/register/email/magicklink': { + post: { + schema: magickLinkUserInfosPayload, + handler: handleMagickLinkRegisterPayload, + args: (context: Koa.Context): MagickLinkRegisterArguments => { + return { + mailer: createMailer(`${process.env.MAILER_DSN}`), + jwtSecret: `${process.env.JWT_SECRET}`, + confirmLinkUrl: `http${context.secure ? 's' : ''}://${context.request.host}/confirm/email/magicklink/:token`, + subject: "[Crystallize - Boilerplate] - Magic link login", + from: "hello@crystallize.com", + buildHtml: (request: MagickLinkUserInfosPayload, link: string) => mjml2html( + ` + + + + Hi there ${request.email}! Simply follow the link below to login. + Click here to login + + + + ` + ).html, + host: context.request.host + } + } + } +}, +``` + +As you can see, the _MagickLinkRegisterArguments_ type lets you inject many things: + +- a `mailer` to send the link as well as all the email information: subject, from, and the HTML + +- the `jwtSecret` to generate and sign the JTW token + +the link to confirm the Magick link: `confirmLinkPath` + +You have control over everything while the handler does the heavy lifting. + +Then you can leverage the other handler associated with it: + +```typescript +"/confirm/email/magicklink/:token": { + get: { + schema: null, + handler: handleMagickLinkConfirmationRequestPayload, + args: (context: Koa.Context): MagickLinkConfirmArguments => { + return { + token: context.params.token, + host: context.request.host, + jwtSecret: `${process.env.JWT_SECRET}`, + backLinkPath: 'https://frontend.app.crystal/checkout?token=:token', + setCookie: (name: string, value: string) => { + context.cookies.set(name, value, { httpOnly: false, secure: context.secure }); + } + } + } + }, +}, +``` + +Of course, it matches the _confirmLinkPath_ passed in the first handler. It is also interesting to note that there is no Schema because there is no body for those requests. + +You also need to pass: + +- the `jwtSecret` to decode and verify the token +- provide a link `backLinkPath` to inform the handler where to redirect the user (most likely to your frontend) + +Once the token is checked and valid, the handler will generate 2 other tokens: + +- a first JWT token that will be saved in the Cookie. This token can then be used to authenticate requests on your service API. +- a second JWT token that will be passed to the `backLinkPath`. This token SHOULD NOT be used for authentication, but it is actually a nice format (JWT) to transport non-sensitive information to your frontend. + +## Orders + +These 2 handlers are very simple ones that will check that one or more Orders actually match the authenticated user after it has fetched the Order(s): + +```typescript +"/orders": { + get: { + schema: null, + authenticated: true, + handler: handleOrdersRequestPayload, + args: (context: Koa.Context): OrdersArguments => { + return { + user: context.user + } + } + } +}, +"/order/:id": { + get: { + schema: null, + authenticated: true, + handler: handleOrderRequestPayload, + args: (context: Koa.Context): OrderArguments => { + return { + user: context.user, + orderId: context.params.id + }; + } + } +}, +``` + +This is a useful endpoint to display the Order(s) to the customer and enforce that this customer is logged in. + +## Stripe Payment + +There are 2 handlers to handle payment with Stripe. + +The first handler is to manage the creation of the Stripe Payment Intent: + +```typescript +const body = await request.json(); +const data = await handleStripeCreatePaymentIntentRequestPayload(validatePayload(body, stripePaymentIntentPayload), { + secret_key: process.env.STRIPE_SECRET_KEY, + fetchCart: async () => { + const cartId = body.cartId as string; + const cartWrapper = await cartWrapperRepository.find(cartId); + if (!cartWrapper) { + throw { + message: `Cart '${cartId}' does not exist.`, + status: 404, + }; + } + return cartWrapper.cart; + }, + createIntentArguments: (cart: Cart) => { + return { + amount: cart.total.net * 100, + currency: cart.total.currency, + }; + }, +}); +``` + +Arguments are: + +- secret_key (required): to communicate with Stripe +- fetchCart (required): provide the hander a way to fetch the Cart +- createIntentArguments (required): using the Cart as input, return the parameters to put in the Stripe Intent + +The second handler is to handle the Webhook that Stripe will call to inform about the Payment Intent: + +```typescript +const body = await request.json(); +const data = await handleStripePaymentIntentWebhookRequestPayload(body, { + secret_key: process.env.STRIPE_SECRET_KEY, + endpointSecret: process.env.STRIPE_SECRET_PAYMENT_INTENT_WEBHOOK_ENDPOINT_SECRET, + signature: request.headers.get('stripe-signature') as string, + rawBody: body, + handleEvent: async (eventName: string, event: any) => { + const cartId = event.data.object.metadata.cartId; + switch (eventName) { + case 'payment_intent.succeeded': + const cartWrapper = await cartWrapperRepository.find(cartId); + if (!cartWrapper) { + throw { + message: `Cart '${cartId}' does not exist.`, + status: 404, + }; + } + // your own logic + } + }, +}); +``` + +Arguments are: + +- secret_key (required): to communicate with Stripe +- endpointSecret (required): to verify the Signature from Stripe +- signature (required): receive in the Request to enforce validation that is coming from Stripe +- rawBody (required): needed to validate the Request Signature +- handleEvent (required): your custom logic + +## QuickPay Payment + +There are 2 handlers to handle payment with QuickPay. + +The first handler is to manage the creation of the Quick Payment and the Link: + +```typescript +const body = await httpRequest.json(); +const data = await handleQuickPayCreatePaymentLinkRequestPayload( + validatePayload(body, quickPayCreatePaymentLinkPayload), + { + api_key: process.env.QUICKPAY_API_KEY, + fetchCart: async () => { + const cartId = body.cartId as string; + const cartWrapper = await cartWrapperRepository.find(cartId); + if (!cartWrapper) { + throw { + message: `Cart '${cartId}' does not exist.`, + status: 404, + }; + } + return cartWrapper.cart; + }, + createPaymentArguments: (cart: Cart) => { + const cartId = body.cartId as string; + return { + amount: cart.total.net * 100, // in cents + currency: cart.total.currency, + urls: { + continue: `${baseUrl}/order/cart/${cartId}`, + cancel: `${baseUrl}/order/cart/${cartId}`, + callback: `${baseUrl}/api/webhook/payment/quickpay`, + }, + }; + }, + }, +); +``` + +Arguments are: + +- api_key (required): to communicate with QuickPay +- fetchCart (required): provide the hander a way to fetch the Cart +- createPaymentArguments (required): using the Cart as input, return the parameters to put in the QuickPay Payment. This is also where you will pass the Return URLs. + +The second handler is to handle the Webhook that QuickPay will call to inform about the Payment: + +```typescript +const body = await httpRequest.json(); +const data = await handleQuickPayPaymentUpdateWebhookRequestPayload(body, { + private_key: process.env.QUICKPAY_PRIVATE_KEY, + signature: httpRequest.headers.get('Quickpay-Checksum-Sha256') as string, + rawBody: body, + handleEvent: async (event: any) => { + const cartId = event.variables.cartId; + switch (event.type?.toLowerCase()) { + case 'payment': + const cartWrapper = await cartWrapperRepository.find(cartId); + if (!cartWrapper) { + throw { + message: `Cart '${cartId}' does not exist.`, + status: 404, + }; + } + // your own logic + } + }, +}); +``` + +Arguments are: + +- private_key (required): to verify the Signature from QuickPay +- signature (required): receive in the Request to enforce validation that is coming from QuickPay +- rawBody (required): needed to validate the Request Signature +- handleEvent (required): your custom logic + +## Montonio Payment + +There are 2 handlers to handle payment with Montonio. + +The first handler is to manage the creation of the the Link: + +```typescript +await handleMontonioCreatePaymentLinkRequestPayload(validatePayload(payload, montonioCreatePaymentLinkPayload), { + origin: process.env.MONTONIO_ORIGIN, + access_key: process.env.MONTONIO_ACCESS_KEY, + secret_key: process.env.MONTONIO_SECRET_KEY, + fetchCart: async () => { + return cartWrapper.cart; + }, + createPaymentArguments: (cart: Cart) => { + const orderCartLink = buildLanguageMarketAwareLink( + `/order/cart/${cartWrapper.cartId}`, + context.language, + context.market, + ); + return { + amount: cart.total.gross, + currency: cart.total.currency, + urls: { + return: `${context.baseUrl}${orderCartLink}`, + notification: `${context.baseUrl}/api/webhook/payment/montonio`, + }, + customer: { + email: cartWrapper.customer.email, + firstName: cartWrapper.customer.firstName, + lastName: cartWrapper.customer.lastName, + }, + }; + }, +}); +``` + +Arguments are: + +- origin(required): to tell the SDK if that’s toward the sandbox or any other Montonio endpoint. +- access_key(required): to identify the call to Montonio +- secret_key(required): to sign the JWT +- fetchCart (required): provide the hander a way to fetch the Cart +- createPaymentArguments (required): using the Cart as input, return the parameters to put in the Montonio Payment. This is also where you will pass the Return URLs. + +The second handler is to handle the Webhook that Montonio will call to inform about the Payment: + +```typescript +await handleMontonioPaymentUpdateWebhookRequestPayload( + {}, + { + secret_key: process.env.MONTONIO_SECRET_KEY, + token, + handleEvent: async (event: any) => { + const cartId = event.merchant_reference; + switch (event.status) { + case 'finalized': + const cartWrapper = await cartWrapperRepository.find(cartId); + if (!cartWrapper) { + throw { + message: `Cart '${cartId}' does not exist.`, + status: 404, + }; + } + // your own logic + } + }, + }, +); +``` + +Arguments are: + +- secret_key (required): to verify the Signature from Montonio +- token (required): the token provided by Montonio +- handleEvent (required): your custom logic + +[crystallizeobject]: crystallize_marketing|folder|62561a2ab30ff82a1f664932 diff --git a/components/node-service-api-router/README.md b/components/node-service-api-router/README.md index 22246ab4..bfb236e9 100644 --- a/components/node-service-api-router/README.md +++ b/components/node-service-api-router/README.md @@ -1,11 +1,157 @@ # Node Service API Router ---- +This is the entry point of your Service API when you decide to use this library. -This repository is what we call a "subtree split": a read-only copy of one directory of the main repository. +## Installation -If you want to report or contribute, you should do it on the main repository: https://github.com/CrystallizeAPI/libraries +With NPM: ---- +```bash +npm install @crystallize/node-service-api-router +``` -Documentation: https://crystallize.com/learn/open-source/sdks-and-libraries/node-service-api-router +With Yarn: + +```bash +yarn add @crystallize/node-service-api-router +``` + +## Usage + +Here is what a valid _index.ts_ would look like with only one endpoint. + +```typescript +import { + createServiceApiApp, + ValidatingRequestRouting, + StandardRouting, + authenticatedMiddleware, +} from '@crystallize/node-service-api-router'; +import Koa from 'koa'; +const routes: StandardRouting = { + '/': { + get: { + handler: async (ctx: Koa.Context) => { + ctx.body = { msg: `Crystallize Service API - Tenant ${process.env.CRYSTALLIZE_TENANT_IDENTIFIER}` }; + }, + }, + }, +}; +const bodyConvertedRoutes: ValidatingRequestRouting = {}; +const { run } = createServiceApiApp(bodyConvertedRoutes, routes, authenticatedMiddleware(`${process.env.JWT_SECRET}`)); +run(process.env.PORT ? parseInt(process.env.PORT) : 3000); +``` + +This is using [Koa JS](https://koajs.com/) for the Middleware management. + +Note: CORS is managed in this library. + +## StandardRouting + +Standard routes are standard by definition, nothing specific to understand. You get access to the request (and more) through the Koa.Context and you need to set the Body. (alias of _ctx.response.body_) + +## ValidatingRequestRouting + +This is where this library takes all its meaning. It does not provide any features by itself, but it enables a concept. + +```typescript +const bodyConvertedRoutes: ValidatingRequestRouting = { + '/my/endpoint': { + post: { + schema: requestInputSchema, + handler: requestInputHandler, + args: (context: any) => {}; + } + } +} +``` + +It means on the `'/my/endpoint'` endpoint, the API Router will intercept the request, check and validate the entry against requestInputSchema, run the `args` function to get handler’s arguments to finally execute the handler when it validates. + +But the response is not returned yet, so you can still extend it in the standard routes: + +```typescript +const routes: StandardRouting = { + '/cart': { + post: { + handler: async (ctx: Koa.Context) => { + const cart = ctx.body as Cart; + ctx.body = { + ...cart, + hello: 'world', + }; + }, + }, + }, +}; +const bodyConvertedRoutes: ValidatingRequestRouting = { + '/cart': { + post: { + schema: cartPayload, + handler: handleCartRequestPayload, + args: (context: Koa.Context): any => {}, + }, + }, +}; +``` + +With this code, if the Request validates against _cartRequest_, the body of the request will be converted to a Cart. And the standard route can still do whatever it wants, knowing the _ctx.body_ is a Cart. In this example, we add a property to the response. + +## Why + +This enables the next library: [Node Service API Request Handlers](https://crystallize.com/learn/open-source/sdks-and-libraries/node-service-api-request-handlers) which provides many highly customizable schemas and handlers (like the Cart mentioned above) so you can get started in minutes and you can extend them to make your Service API yours! + +## Authentification + +Library comes with a default Authentication middleware. For each route that you describe, you have a boolean attribute named `authenticated` which is by default set to `false`. + +```typescript +'/echo': { + post: { + handler: async (ctx: Koa.Context) => { + ctx.body = { + echoed: ctx.request.body + } + } + } +}, +'/authenticated/echo': { + post: { + authenticated: true, + handler: async (ctx: Koa.Context) => { + ctx.body = { + echoed: ctx.request.body, + user: ctx.user + } + } + } +}, +``` + +In this example `/authenticated/echo` will be accessible with a valid authentication. + +### How does that work? + +First, the middleware is passed to the router via Dependency Injection, therefore you can also provide your own. + +```javascript +const { run, router } = createServiceApiApp( + bodyConvertedRoutes, + routes, + authenticatedMiddleware(`${process.env.JWT_SECRET}`), +); +``` + +The [authenticatedMiddleware provided by the library is simple](https://github.com/CrystallizeAPI/libraries/blob/main/components/node-service-api-router/src/core/middlewares.ts#L14): + +- it will check the existence of a cookie name: **jwt** +- if that cookie exists, then it will decode it and check its signature. (That’s why you pass the _JWT_SECRET_) +- and it will check the `sub` property of the payload to be: _isLoggedInOnServiceApiToken_ + +Note: The last check is to be consistent with the provided [MagickLink feature](https://github.com/CrystallizeAPI/libraries/blob/main/components/node-service-api-request-handlers/src/magicklink/handlers.ts#L31). + +If one of the checks fails, the router will return a 401 error. + +Once again, all is extendable. You may or may not use the **authenticatedMiddleware** and you can certainly pass your own to do your own logic. + +[crystallizeobject]: crystallize_marketing|folder|62561a1ab30ff82a1f664931 diff --git a/components/reactjs-components/README.md b/components/reactjs-components/README.md index 6a224a66..cae78045 100644 --- a/components/reactjs-components/README.md +++ b/components/reactjs-components/README.md @@ -1,11 +1,137 @@ -# React JS - Components +# React JS Components ---- +This brings Image, Grid and Content Transformer component to ease your rendering when using React JS. -This repository is what we call a "subtree split": a read-only copy of one directory of the main repository. +## Installation -If you want to report or contribute, you should do it on the main repository: https://github.com/CrystallizeAPI/libraries +With NPM: ---- +```bash +npm install @crystallize/reactjs-components +``` -Documentation: https://crystallize.com/learn/open-source/sdks-and-libraries/react-js-components +With Yarn: + +```bash +yarn add @crystallize/reactjs-components +``` + +## Image + +This output an `img` tag with different source variations from Crystallize using _srcset_. Use this to easily build responsive images powered by Crystallize. + +```javascript +import { Image } from '@crystallize/reactjs-components/dist/image'; +const imageFromCrystallize = { + url: '...', + variants: [...] +} + + +``` + +There is a live demo: https://crystallizeapi.github.io/libraries/reactjs-components/image + +## Video + +This output videos from Crystallize using the native video element. + +```javascript +import { Video } from '@crystallize/reactjs-components/dist/video'; +import '@crystallize/reactjs-components/assets/video/styles.css'; +const videoFromCrystallize = { + playlists: [...], + thumbnails: [...] +} + +