diff --git a/.github/actions/cache/action.yml b/.github/actions/cache/action.yml index 8672628a1e..1d4c85e8f7 100644 --- a/.github/actions/cache/action.yml +++ b/.github/actions/cache/action.yml @@ -25,6 +25,13 @@ runs: path: /home/runner/work/api-clients-automation/api-clients-automation/clients/algoliasearch-client-javascript/recommend/dist key: ${{ runner.os }}-js-client-recommend-${{ hashFiles('clients/algoliasearch-client-javascript/recommend/**') }} + - name: Restore built JavaScript query-suggestions client + if: ${{ inputs.job == 'cts' }} + uses: actions/cache@v2 + with: + path: /home/runner/work/api-clients-automation/api-clients-automation/clients/algoliasearch-client-javascript/client-query-suggestions/dist + key: ${{ runner.os }}-js-client-query-suggestions-${{ hashFiles('clients/algoliasearch-client-javascript/client-query-suggestions/**') }} + - name: Restore built JavaScript personalization client if: ${{ inputs.job == 'cts' }} uses: actions/cache@v2 diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index cc7afff473..fd7dd5a0bf 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -31,7 +31,7 @@ runs: echo "::set-output name=COMMON_SPECS_CHANGED::$(git diff --shortstat origin/${{ github.base_ref }}..HEAD -- specs/common | wc -l)" echo "::set-output name=SEARCH_SPECS_CHANGED::$(git diff --shortstat origin/${{ github.base_ref }}..HEAD -- specs/search | wc -l)" echo "::set-output name=RECOMMEND_SPECS_CHANGED::$(git diff --shortstat origin/${{ github.base_ref }}..HEAD -- specs/recommend | wc -l)" - echo "::set-output name=QS_SPECS_CHANGED::$(git diff --shortstat origin/${{ github.base_ref }}..HEAD -- specs/query_suggestions | wc -l)" + echo "::set-output name=QUERY_SUGGESTIONS_SPECS_CHANGED::$(git diff --shortstat origin/${{ github.base_ref }}..HEAD -- specs/query-suggestions | wc -l)" echo "::set-output name=PERSO_SPECS_CHANGED::$(git diff --shortstat origin/${{ github.base_ref }}..HEAD -- specs/personalization | wc -l)" echo "::set-output name=INSIGHTS_SPECS_CHANGED::$(git diff --shortstat origin/${{ github.base_ref }}..HEAD -- specs/insights | wc -l)" echo "::set-output name=ANALYTICS_SPECS_CHANGED::$(git diff --shortstat origin/${{ github.base_ref }}..HEAD -- specs/analytics | wc -l)" @@ -44,6 +44,7 @@ runs: echo "::set-output name=JS_CLIENT_CHANGED::$(git diff --shortstat origin/${{ github.base_ref }}..HEAD -- clients/algoliasearch-client-javascript | wc -l)" echo "::set-output name=JS_SEARCH_CLIENT_CHANGED::$(git diff --shortstat origin/${{ github.base_ref }}..HEAD -- clients/algoliasearch-client-javascript/client-search | wc -l)" echo "::set-output name=JS_RECOMMEND_CLIENT_CHANGED::$(git diff --shortstat origin/${{ github.base_ref }}..HEAD -- clients/algoliasearch-client-javascript/recommend | wc -l)" + echo "::set-output name=JS_QUERY_SUGGESTIONS_CLIENT_CHANGED::$(git diff --shortstat origin/${{ github.base_ref }}..HEAD -- clients/algoliasearch-client-javascript/client-query-suggestions | wc -l)" echo "::set-output name=JS_PERSO_CLIENT_CHANGED::$(git diff --shortstat origin/${{ github.base_ref }}..HEAD -- clients/algoliasearch-client-javascript/client-personalization | wc -l)" echo "::set-output name=JS_ANALYTICS_CLIENT_CHANGED::$(git diff --shortstat origin/${{ github.base_ref }}..HEAD -- clients/algoliasearch-client-javascript/client-analytics | wc -l)" echo "::set-output name=JS_INSIGHTS_CLIENT_CHANGED::$(git diff --shortstat origin/${{ github.base_ref }}..HEAD -- clients/algoliasearch-client-javascript/client-insights | wc -l)" @@ -60,9 +61,9 @@ outputs: RUN_SPECS_RECOMMEND: description: Determine if the `specs_recommend` job should run value: ${{ github.ref == 'refs/heads/main' || steps.diff.outputs.GITHUB_ACTIONS_CHANGED > 0 || steps.diff.outputs.SCRIPTS_CHANGED > 0 || steps.diff.outputs.COMMON_SPECS_CHANGED > 0 || steps.diff.outputs.RECOMMEND_SPECS_CHANGED > 0 }} - RUN_SPECS_QS: - description: Determine if the `specs_qs` job should run - value: ${{ github.ref == 'refs/heads/main' || steps.diff.outputs.GITHUB_ACTIONS_CHANGED > 0 || steps.diff.outputs.SCRIPTS_CHANGED > 0 || steps.diff.outputs.COMMON_SPECS_CHANGED > 0 || steps.diff.outputs.QS_SPECS_CHANGED > 0 }} + RUN_SPECS_QUERY_SUGGESTIONS: + description: Determine if the `specs_query_suggestions` job should run + value: ${{ github.ref == 'refs/heads/main' || steps.diff.outputs.GITHUB_ACTIONS_CHANGED > 0 || steps.diff.outputs.SCRIPTS_CHANGED > 0 || steps.diff.outputs.COMMON_SPECS_CHANGED > 0 || steps.diff.outputs.QUERY_SUGGESTIONS_SPECS_CHANGED > 0 }} RUN_SPECS_PERSO: description: Determine if the `specs_perso` job should run value: ${{ github.ref == 'refs/heads/main' || steps.diff.outputs.GITHUB_ACTIONS_CHANGED > 0 || steps.diff.outputs.SCRIPTS_CHANGED > 0 || steps.diff.outputs.COMMON_SPECS_CHANGED > 0 || steps.diff.outputs.PERSO_SPECS_CHANGED > 0 }} @@ -83,6 +84,9 @@ outputs: RUN_JS_CLIENT_RECOMMEND: description: Determine if the `client_javascript_recommend` job should run value: ${{ github.ref == 'refs/heads/main' || steps.diff.outputs.GITHUB_ACTIONS_CHANGED > 0 || steps.diff.outputs.COMMON_SPECS_CHANGED > 0 || steps.diff.outputs.RECOMMEND_SPECS_CHANGED > 0 || steps.diff.outputs.SCRIPTS_CHANGED > 0 || steps.diff.outputs.JS_RECOMMEND_CLIENT_CHANGED > 0 || steps.diff.outputs.JS_TEMPLATE_CHANGED > 0 }} + RUN_JS_CLIENT_QUERY_SUGGESTIONS: + description: Determine if the `client_javascript_query_suggestions` job should run + value: ${{ github.ref == 'refs/heads/main' || steps.diff.outputs.GITHUB_ACTIONS_CHANGED > 0 || steps.diff.outputs.COMMON_SPECS_CHANGED > 0 || steps.diff.outputs.QUERY_SUGGESTIONS_SPECS_CHANGED > 0 || steps.diff.outputs.SCRIPTS_CHANGED > 0 || steps.diff.outputs.JS_QUERY_SUGGESTIONS_CLIENT_CHANGED > 0 || steps.diff.outputs.JS_TEMPLATE_CHANGED > 0 }} RUN_JS_CLIENT_PERSO: description: Determine if the `client_javascript_perso` job should run value: ${{ github.ref == 'refs/heads/main' || steps.diff.outputs.GITHUB_ACTIONS_CHANGED > 0 || steps.diff.outputs.COMMON_SPECS_CHANGED > 0 || steps.diff.outputs.PERSO_SPECS_CHANGED > 0 || steps.diff.outputs.SCRIPTS_CHANGED > 0 || steps.diff.outputs.JS_PERSO_CLIENT_CHANGED > 0 || steps.diff.outputs.JS_TEMPLATE_CHANGED > 0 }} diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index bda25d1b96..2765c4543b 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -28,7 +28,7 @@ jobs: outputs: RUN_SPECS_SEARCH: ${{ steps.setup.outputs.RUN_SPECS_SEARCH }} RUN_SPECS_RECOMMEND: ${{ steps.setup.outputs.RUN_SPECS_RECOMMEND }} - RUN_SPECS_QS: ${{ steps.setup.outputs.RUN_SPECS_QS }} + RUN_SPECS_QUERY_SUGGESTIONS: ${{ steps.setup.outputs.RUN_SPECS_QUERY_SUGGESTIONS }} RUN_SPECS_PERSO: ${{ steps.setup.outputs.RUN_SPECS_PERSO }} RUN_SPECS_INSIGHTS: ${{ steps.setup.outputs.RUN_SPECS_INSIGHTS }} RUN_SPECS_ANALYTICS: ${{ steps.setup.outputs.RUN_SPECS_ANALYTICS }} @@ -36,6 +36,7 @@ jobs: RUN_JS_CLIENT_SEARCH: ${{ steps.setup.outputs.RUN_JS_CLIENT_SEARCH }} RUN_JS_CLIENT_RECOMMEND: ${{ steps.setup.outputs.RUN_JS_CLIENT_RECOMMEND }} + RUN_JS_CLIENT_QUERY_SUGGESTIONS: ${{ steps.setup.outputs.RUN_JS_CLIENT_QUERY_SUGGESTIONS }} RUN_JS_CLIENT_PERSO: ${{ steps.setup.outputs.RUN_JS_CLIENT_PERSO }} RUN_JS_CLIENT_ANALYTICS: ${{ steps.setup.outputs.RUN_JS_CLIENT_ANALYTICS }} RUN_JS_CLIENT_INSIGHTS: ${{ steps.setup.outputs.RUN_JS_CLIENT_INSIGHTS }} @@ -76,6 +77,22 @@ jobs: - name: Lint recommend specs run: yarn eslint --ext=yml specs/recommend + specs_query_suggestions: + runs-on: ubuntu-20.04 + needs: setup + if: ${{ always() && needs.setup.outputs.RUN_SPECS_QUERY_SUGGESTIONS == 'true' }} + steps: + - uses: actions/checkout@v2 + + - name: Restore cache + uses: ./.github/actions/cache + + - name: Checking query-suggestions specs + run: yarn build:specs query-suggestions + + - name: Lint query-suggestions specs + run: yarn eslint --ext=yml specs/query-suggestions + specs_perso: runs-on: ubuntu-20.04 needs: setup @@ -174,6 +191,31 @@ jobs: if: steps.cache.outputs.cache-hit != 'true' run: yarn build:clients javascript recommend + client_javascript_query_suggestions: + runs-on: ubuntu-20.04 + needs: [specs_query_suggestions] + if: ${{ always() && needs.setup.outputs.RUN_JS_CLIENT_QUERY_SUGGESTIONS == 'true' }} + steps: + - uses: actions/checkout@v2 + + - name: Restore cache + uses: ./.github/actions/cache + + - name: Cache query-suggestions client + id: cache + uses: actions/cache@v2 + with: + path: /home/runner/work/api-clients-automation/api-clients-automation/clients/algoliasearch-client-javascript/client-query-suggestions/dist + key: ${{ runner.os }}-js-client-query-suggestions-${{ hashFiles('clients/algoliasearch-client-javascript/client-query-suggestions/**') }} + + - name: Generate query-suggestions client + if: steps.cache.outputs.cache-hit != 'true' + run: yarn generate javascript query-suggestions + + - name: Build query-suggestions client + if: steps.cache.outputs.cache-hit != 'true' + run: yarn build:clients javascript query-suggestions + client_javascript_perso: runs-on: ubuntu-20.04 needs: [specs_perso] @@ -281,6 +323,7 @@ jobs: needs: - client_javascript_search - client_javascript_recommend + - client_javascript_query_suggestions - client_javascript_perso - client_javascript_analytics - client_javascript_insights diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/.gitignore b/clients/algoliasearch-client-javascript/client-query-suggestions/.gitignore new file mode 100644 index 0000000000..8aafcdc3fd --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.openapi-generator +.env diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/.openapi-generator-ignore b/clients/algoliasearch-client-javascript/client-query-suggestions/.openapi-generator-ignore new file mode 100644 index 0000000000..8a9707d102 --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/.openapi-generator-ignore @@ -0,0 +1,4 @@ +# OpenAPI Generator Ignore +# Generated by openapi-generator https://github.com/openapitools/openapi-generator + +git_push.sh diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/api.ts b/clients/algoliasearch-client-javascript/client-query-suggestions/api.ts new file mode 100644 index 0000000000..59b02c4607 --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/api.ts @@ -0,0 +1,3 @@ +// This is the entrypoint for the package +export * from './src/apis'; +export * from './model/models'; diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/model/errorBase.ts b/clients/algoliasearch-client-javascript/client-query-suggestions/model/errorBase.ts new file mode 100644 index 0000000000..a533aa7a15 --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/model/errorBase.ts @@ -0,0 +1,6 @@ +/** + * Error. + */ +export type ErrorBase = { + message?: string; +}; diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/model/indexName.ts b/clients/algoliasearch-client-javascript/client-query-suggestions/model/indexName.ts new file mode 100644 index 0000000000..37c585d5b4 --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/model/indexName.ts @@ -0,0 +1,6 @@ +export type IndexName = { + /** + * Index name to target. + */ + indexName: string; +}; diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/model/logFile.ts b/clients/algoliasearch-client-javascript/client-query-suggestions/model/logFile.ts new file mode 100644 index 0000000000..b59bcd3cb9 --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/model/logFile.ts @@ -0,0 +1,20 @@ +export type LogFile = { + /** + * Date and time of creation of the record. + */ + timestamp: Date; + /** + * Type of the record, can be one of three values (INFO, SKIP or ERROR). + */ + level: LogFileLevel; + /** + * Detailed description of what happened. + */ + message: string; + /** + * Indicates the hierarchy of the records. For example, a record with contextLevel=1 belongs to a preceding record with contextLevel=0. + */ + contextLevel: number; +}; + +export type LogFileLevel = 'ERROR' | 'INFO' | 'SKIP'; diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/model/models.ts b/clients/algoliasearch-client-javascript/client-query-suggestions/model/models.ts new file mode 100644 index 0000000000..9ad1549d1d --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/model/models.ts @@ -0,0 +1,53 @@ +/* eslint-disable no-param-reassign */ +import type { RequestOptions } from '../utils/types'; + +export * from './errorBase'; +export * from './indexName'; +export * from './logFile'; +export * from './querySuggestionsIndex'; +export * from './querySuggestionsIndexParam'; +export * from './querySuggestionsIndexWithIndexParam'; +export * from './sourceIndex'; +export * from './sourceIndexExternal'; +export * from './sourceIndexWithReplicas'; +export * from './status'; +export * from './sucessResponse'; + +export interface Authentication { + /** + * Apply authentication settings to header and query params. + */ + applyToRequest: (requestOptions: RequestOptions) => Promise | void; +} + +export class ApiKeyAuth implements Authentication { + apiKey: string = ''; + + constructor(private location: string, private paramName: string) {} + + applyToRequest(requestOptions: RequestOptions): void { + if (this.location === 'query') { + requestOptions.queryParameters[this.paramName] = this.apiKey; + } else if ( + this.location === 'header' && + requestOptions && + requestOptions.headers + ) { + requestOptions.headers[this.paramName] = this.apiKey; + } else if ( + this.location === 'cookie' && + requestOptions && + requestOptions.headers + ) { + if (requestOptions.headers.Cookie) { + requestOptions.headers.Cookie += `; ${ + this.paramName + }=${encodeURIComponent(this.apiKey)}`; + } else { + requestOptions.headers.Cookie = `${this.paramName}=${encodeURIComponent( + this.apiKey + )}`; + } + } + } +} diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/model/querySuggestionsIndex.ts b/clients/algoliasearch-client-javascript/client-query-suggestions/model/querySuggestionsIndex.ts new file mode 100644 index 0000000000..7169be6b83 --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/model/querySuggestionsIndex.ts @@ -0,0 +1,20 @@ +import type { SourceIndexWithReplicas } from './sourceIndexWithReplicas'; + +export type QuerySuggestionsIndex = { + /** + * Index name to target. + */ + indexName: string; + /** + * List of source indices used to generate a Query Suggestions index. + */ + sourceIndices: SourceIndexWithReplicas[]; + /** + * De-duplicate singular and plural suggestions. For example, let\'s say your index contains English content, and that two suggestions “shoe” and “shoes” end up in your Query Suggestions index. If the English language is configured, only the most popular of those two suggestions would remain. + */ + languages: string[]; + /** + * List of words and patterns to exclude from the Query Suggestions index. + */ + exclude: string[]; +}; diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/model/querySuggestionsIndexParam.ts b/clients/algoliasearch-client-javascript/client-query-suggestions/model/querySuggestionsIndexParam.ts new file mode 100644 index 0000000000..dfb47b2070 --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/model/querySuggestionsIndexParam.ts @@ -0,0 +1,16 @@ +import type { SourceIndex } from './sourceIndex'; + +export type QuerySuggestionsIndexParam = { + /** + * List of source indices used to generate a Query Suggestions index. + */ + sourceIndices: SourceIndex[]; + /** + * De-duplicate singular and plural suggestions. For example, let\'s say your index contains English content, and that two suggestions “shoe” and “shoes” end up in your Query Suggestions index. If the English language is configured, only the most popular of those two suggestions would remain. + */ + languages?: string[]; + /** + * List of words and patterns to exclude from the Query Suggestions index. + */ + exclude?: string[]; +}; diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/model/querySuggestionsIndexWithIndexParam.ts b/clients/algoliasearch-client-javascript/client-query-suggestions/model/querySuggestionsIndexWithIndexParam.ts new file mode 100644 index 0000000000..cd9ba12854 --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/model/querySuggestionsIndexWithIndexParam.ts @@ -0,0 +1,5 @@ +import type { IndexName } from './indexName'; +import type { QuerySuggestionsIndexParam } from './querySuggestionsIndexParam'; + +export type QuerySuggestionsIndexWithIndexParam = IndexName & + QuerySuggestionsIndexParam; diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/model/sourceIndex.ts b/clients/algoliasearch-client-javascript/client-query-suggestions/model/sourceIndex.ts new file mode 100644 index 0000000000..7a2450b90c --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/model/sourceIndex.ts @@ -0,0 +1,32 @@ +import type { SourceIndexExternal } from './sourceIndexExternal'; + +export type SourceIndex = { + /** + * Source index name. + */ + indexName: string; + /** + * List of analytics tags to filter the popular searches per tag. + */ + analyticsTags?: string[]; + /** + * List of facets to define as categories for the query suggestions. + */ + facets?: Array>; + /** + * Minimum number of hits (e.g., matching records in the source index) to generate a suggestions. + */ + minHits?: number; + /** + * Minimum number of required letters for a suggestion to remain. + */ + minLetters?: number; + /** + * List of facet attributes used to generate Query Suggestions. The resulting suggestions are every combination of the facets in the nested list (e.g., (facetA and facetB) and facetC). + */ + generate?: string[][]; + /** + * List of external indices to use to generate custom Query Suggestions. + */ + external?: SourceIndexExternal[]; +}; diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/model/sourceIndexExternal.ts b/clients/algoliasearch-client-javascript/client-query-suggestions/model/sourceIndexExternal.ts new file mode 100644 index 0000000000..d334668c52 --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/model/sourceIndexExternal.ts @@ -0,0 +1,10 @@ +export type SourceIndexExternal = { + /** + * The suggestion you would like to add. + */ + query: string; + /** + * The measure of the suggestion relative popularity. + */ + count: number; +}; diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/model/sourceIndexWithReplicas.ts b/clients/algoliasearch-client-javascript/client-query-suggestions/model/sourceIndexWithReplicas.ts new file mode 100644 index 0000000000..fae244578d --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/model/sourceIndexWithReplicas.ts @@ -0,0 +1,36 @@ +import type { SourceIndexExternal } from './sourceIndexExternal'; + +export type SourceIndexWithReplicas = { + /** + * Source index name. + */ + indexName: string; + /** + * True if the Query Suggestions index is a replicas. + */ + replicas: boolean; + /** + * List of analytics tags to filter the popular searches per tag. + */ + analyticsTags: string[]; + /** + * List of facets to define as categories for the query suggestions. + */ + facets: Array>; + /** + * Minimum number of hits (e.g., matching records in the source index) to generate a suggestions. + */ + minHits: number; + /** + * Minimum number of required letters for a suggestion to remain. + */ + minLetters: number; + /** + * List of facet attributes used to generate Query Suggestions. The resulting suggestions are every combination of the facets in the nested list (e.g., (facetA and facetB) and facetC). + */ + generate: string[][]; + /** + * List of external indices to use to generate custom Query Suggestions. + */ + external: SourceIndexExternal[]; +}; diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/model/status.ts b/clients/algoliasearch-client-javascript/client-query-suggestions/model/status.ts new file mode 100644 index 0000000000..1e2048b9d0 --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/model/status.ts @@ -0,0 +1,14 @@ +export type Status = { + /** + * The targeted index name. + */ + indexName: string; + /** + * True if the Query Suggestions index is running. + */ + isRunning: boolean; + /** + * Date and time of the last build. + */ + lastBuiltAt: string; +}; diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/model/sucessResponse.ts b/clients/algoliasearch-client-javascript/client-query-suggestions/model/sucessResponse.ts new file mode 100644 index 0000000000..841e28fdb3 --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/model/sucessResponse.ts @@ -0,0 +1,10 @@ +export type SucessResponse = { + /** + * The status code. + */ + status: number; + /** + * Message of the response. + */ + message: string; +}; diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/package.json b/clients/algoliasearch-client-javascript/client-query-suggestions/package.json new file mode 100644 index 0000000000..680f226100 --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/package.json @@ -0,0 +1,24 @@ +{ + "name": "@algolia/client-query-suggestions", + "version": "5.0.0", + "description": "JavaScript client for @algolia/client-query-suggestions", + "repository": "algolia/algoliasearch-client-javascript", + "author": "Algolia", + "private": true, + "license": "MIT", + "main": "dist/api.js", + "types": "dist/api.d.ts", + "scripts": { + "clean": "rm -Rf node_modules/ *.js", + "build": "tsc", + "test": "yarn build && node dist/client.js" + }, + "engines": { + "node": "^16.0.0", + "yarn": "^3.0.0" + }, + "devDependencies": { + "@types/node": "16.11.11", + "typescript": "4.5.4" + } +} diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/src/apis.ts b/clients/algoliasearch-client-javascript/client-query-suggestions/src/apis.ts new file mode 100644 index 0000000000..6356f1fc76 --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/src/apis.ts @@ -0,0 +1,7 @@ +import { QuerySuggestionsApi } from './querySuggestionsApi'; + +export * from './querySuggestionsApi'; +export * from '../utils/errors'; +export { EchoRequester } from '../utils/requester/EchoRequester'; + +export const APIS = [QuerySuggestionsApi]; diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/src/querySuggestionsApi.ts b/clients/algoliasearch-client-javascript/client-query-suggestions/src/querySuggestionsApi.ts new file mode 100644 index 0000000000..4be2399c16 --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/src/querySuggestionsApi.ts @@ -0,0 +1,372 @@ +import type { LogFile } from '../model/logFile'; +import { ApiKeyAuth } from '../model/models'; +import type { QuerySuggestionsIndex } from '../model/querySuggestionsIndex'; +import type { QuerySuggestionsIndexParam } from '../model/querySuggestionsIndexParam'; +import type { QuerySuggestionsIndexWithIndexParam } from '../model/querySuggestionsIndexWithIndexParam'; +import type { Status } from '../model/status'; +import type { SucessResponse } from '../model/sucessResponse'; +import { Transporter } from '../utils/Transporter'; +import type { Requester } from '../utils/requester/Requester'; +import type { Headers, Host, Request, RequestOptions } from '../utils/types'; + +export enum QuerySuggestionsApiKeys { + apiKey, + appId, +} + +export class QuerySuggestionsApi { + protected authentications = { + apiKey: new ApiKeyAuth('header', 'X-Algolia-API-Key'), + appId: new ApiKeyAuth('header', 'X-Algolia-Application-Id'), + }; + + private transporter: Transporter; + + private sendRequest( + request: Request, + requestOptions: RequestOptions + ): Promise { + if (this.authentications.apiKey.apiKey) { + this.authentications.apiKey.applyToRequest(requestOptions); + } + + if (this.authentications.appId.apiKey) { + this.authentications.appId.applyToRequest(requestOptions); + } + + return this.transporter.request(request, requestOptions); + } + + constructor( + appId: string, + apiKey: string, + region: 'eu' | 'us', + options?: { requester?: Requester; hosts?: Host[] } + ) { + this.setApiKey(QuerySuggestionsApiKeys.appId, appId); + this.setApiKey(QuerySuggestionsApiKeys.apiKey, apiKey); + + this.transporter = new Transporter({ + hosts: options?.hosts ?? this.getDefaultHosts(region), + baseHeaders: { + 'content-type': 'application/x-www-form-urlencoded', + }, + userAgent: 'Algolia for Javascript', + timeouts: { + connect: 2, + read: 5, + write: 30, + }, + requester: options?.requester, + }); + } + + getDefaultHosts(region: string = 'us'): Host[] { + return [ + { + url: `query-suggestions.${region}.algolia.com`, + accept: 'readWrite', + protocol: 'https', + }, + ]; + } + + setRequest(requester: Requester): void { + this.transporter.setRequester(requester); + } + + setHosts(hosts: Host[]): void { + this.transporter.setHosts(hosts); + } + + setApiKey(key: QuerySuggestionsApiKeys, value: string): void { + this.authentications[QuerySuggestionsApiKeys[key]].apiKey = value; + } + + /** + * Create a configuration of a Query Suggestions index. There\'s a limit of 100 configurations per application. + * + * @param createConfig - The createConfig parameters. + * @param createConfig.querySuggestionsIndexWithIndexParam - The querySuggestionsIndexWithIndexParam. + */ + createConfig({ + querySuggestionsIndexWithIndexParam, + }: CreateConfigProps): Promise { + const path = '/1/configs'; + const headers: Headers = { Accept: 'application/json' }; + const queryParameters: Record = {}; + + if ( + querySuggestionsIndexWithIndexParam === null || + querySuggestionsIndexWithIndexParam === undefined + ) { + throw new Error( + 'Required parameter querySuggestionsIndexWithIndexParam was null or undefined when calling createConfig.' + ); + } + + const request: Request = { + method: 'POST', + path, + data: querySuggestionsIndexWithIndexParam, + }; + + const requestOptions: RequestOptions = { + headers, + queryParameters, + }; + + return this.sendRequest(request, requestOptions); + } + /** + * Delete a configuration of a Query Suggestion\'s index. By deleting a configuraton, you stop all updates to the underlying query suggestion index. Note that when doing this, the underlying index does not change - existing suggestions remain untouched. + * + * @param deleteConfig - The deleteConfig parameters. + * @param deleteConfig.indexName - The index in which to perform the request. + */ + deleteConfig({ indexName }: DeleteConfigProps): Promise { + const path = '/1/configs/{indexName}'.replace( + '{indexName}', + encodeURIComponent(String(indexName)) + ); + const headers: Headers = { Accept: 'application/json' }; + const queryParameters: Record = {}; + + if (indexName === null || indexName === undefined) { + throw new Error( + 'Required parameter indexName was null or undefined when calling deleteConfig.' + ); + } + + const request: Request = { + method: 'DELETE', + path, + }; + + const requestOptions: RequestOptions = { + headers, + queryParameters, + }; + + return this.sendRequest(request, requestOptions); + } + /** + * Get all the configurations of Query Suggestions. For each index, you get a block of JSON with a list of its configuration settings. + * + * @param getAllConfigs - The getAllConfigs parameters. + */ + getAllConfigs(): Promise { + const path = '/1/configs'; + const headers: Headers = { Accept: 'application/json' }; + const queryParameters: Record = {}; + + const request: Request = { + method: 'GET', + path, + }; + + const requestOptions: RequestOptions = { + headers, + queryParameters, + }; + + return this.sendRequest(request, requestOptions); + } + /** + * Get the configuration of a single Query Suggestions index. + * + * @param getConfig - The getConfig parameters. + * @param getConfig.indexName - The index in which to perform the request. + */ + getConfig({ indexName }: GetConfigProps): Promise { + const path = '/1/configs/{indexName}'.replace( + '{indexName}', + encodeURIComponent(String(indexName)) + ); + const headers: Headers = { Accept: 'application/json' }; + const queryParameters: Record = {}; + + if (indexName === null || indexName === undefined) { + throw new Error( + 'Required parameter indexName was null or undefined when calling getConfig.' + ); + } + + const request: Request = { + method: 'GET', + path, + }; + + const requestOptions: RequestOptions = { + headers, + queryParameters, + }; + + return this.sendRequest(request, requestOptions); + } + /** + * Get the status of a Query Suggestion\'s index. The status includes whether the Query Suggestions index is currently in the process of being built, and the last build time. + * + * @param getConfigStatus - The getConfigStatus parameters. + * @param getConfigStatus.indexName - The index in which to perform the request. + */ + getConfigStatus({ indexName }: GetConfigStatusProps): Promise { + const path = '/1/configs/{indexName}/status'.replace( + '{indexName}', + encodeURIComponent(String(indexName)) + ); + const headers: Headers = { Accept: 'application/json' }; + const queryParameters: Record = {}; + + if (indexName === null || indexName === undefined) { + throw new Error( + 'Required parameter indexName was null or undefined when calling getConfigStatus.' + ); + } + + const request: Request = { + method: 'GET', + path, + }; + + const requestOptions: RequestOptions = { + headers, + queryParameters, + }; + + return this.sendRequest(request, requestOptions); + } + /** + * Get the log file of the last build of a single Query Suggestion index. + * + * @param getLogFile - The getLogFile parameters. + * @param getLogFile.indexName - The index in which to perform the request. + */ + getLogFile({ indexName }: GetLogFileProps): Promise { + const path = '/1/logs/{indexName}'.replace( + '{indexName}', + encodeURIComponent(String(indexName)) + ); + const headers: Headers = { Accept: 'application/json' }; + const queryParameters: Record = {}; + + if (indexName === null || indexName === undefined) { + throw new Error( + 'Required parameter indexName was null or undefined when calling getLogFile.' + ); + } + + const request: Request = { + method: 'GET', + path, + }; + + const requestOptions: RequestOptions = { + headers, + queryParameters, + }; + + return this.sendRequest(request, requestOptions); + } + /** + * Update the configuration of a Query Suggestions index. + * + * @param updateConfig - The updateConfig parameters. + * @param updateConfig.indexName - The index in which to perform the request. + * @param updateConfig.querySuggestionsIndexParam - The querySuggestionsIndexParam. + */ + updateConfig({ + indexName, + querySuggestionsIndexParam, + }: UpdateConfigProps): Promise { + const path = '/1/configs/{indexName}'.replace( + '{indexName}', + encodeURIComponent(String(indexName)) + ); + const headers: Headers = { Accept: 'application/json' }; + const queryParameters: Record = {}; + + if (indexName === null || indexName === undefined) { + throw new Error( + 'Required parameter indexName was null or undefined when calling updateConfig.' + ); + } + + if ( + querySuggestionsIndexParam === null || + querySuggestionsIndexParam === undefined + ) { + throw new Error( + 'Required parameter querySuggestionsIndexParam was null or undefined when calling updateConfig.' + ); + } + + if ( + querySuggestionsIndexParam.sourceIndices === null || + querySuggestionsIndexParam.sourceIndices === undefined + ) { + throw new Error( + 'Required parameter querySuggestionsIndexParam.sourceIndices was null or undefined when calling updateConfig.' + ); + } + + const request: Request = { + method: 'PUT', + path, + data: querySuggestionsIndexParam, + }; + + const requestOptions: RequestOptions = { + headers, + queryParameters, + }; + + return this.sendRequest(request, requestOptions); + } +} + +export type CreateConfigProps = { + /** + * The querySuggestionsIndexWithIndexParam. + */ + querySuggestionsIndexWithIndexParam: QuerySuggestionsIndexWithIndexParam; +}; + +export type DeleteConfigProps = { + /** + * The index in which to perform the request. + */ + indexName: string; +}; + +export type GetConfigProps = { + /** + * The index in which to perform the request. + */ + indexName: string; +}; + +export type GetConfigStatusProps = { + /** + * The index in which to perform the request. + */ + indexName: string; +}; + +export type GetLogFileProps = { + /** + * The index in which to perform the request. + */ + indexName: string; +}; + +export type UpdateConfigProps = { + /** + * The index in which to perform the request. + */ + indexName: string; + /** + * The querySuggestionsIndexParam. + */ + querySuggestionsIndexParam: QuerySuggestionsIndexParam; +}; diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/tsconfig.json b/clients/algoliasearch-client-javascript/client-query-suggestions/tsconfig.json new file mode 100644 index 0000000000..2f72c93ccb --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "noImplicitAny": false, + "suppressImplicitAnyIndexErrors": true, + "target": "ES6", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "strict": true, + "moduleResolution": "node", + "removeComments": true, + "sourceMap": true, + "noLib": false, + "declaration": true, + "lib": ["dom", "es6", "es5", "dom.iterable", "scripthost"], + "outDir": "dist", + "typeRoots": ["node_modules/@types"], + "types": ["node"] + }, + "include": ["src", "model", "api.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/utils/Response.ts b/clients/algoliasearch-client-javascript/client-query-suggestions/utils/Response.ts new file mode 100644 index 0000000000..bd22de7df9 --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/utils/Response.ts @@ -0,0 +1,23 @@ +import type { Response } from './types'; + +export function isNetworkError({ + isTimedOut, + status, +}: Omit): boolean { + return !isTimedOut && ~~status === 0; +} + +export function isRetryable({ + isTimedOut, + status, +}: Omit): boolean { + return ( + isTimedOut || + isNetworkError({ isTimedOut, status }) || + (~~(status / 100) !== 2 && ~~(status / 100) !== 4) + ); +} + +export function isSuccess({ status }: Pick): boolean { + return ~~(status / 100) === 2; +} diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/utils/StatefulHost.ts b/clients/algoliasearch-client-javascript/client-query-suggestions/utils/StatefulHost.ts new file mode 100644 index 0000000000..162c4ed1c6 --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/utils/StatefulHost.ts @@ -0,0 +1,34 @@ +import type { Host } from './types'; + +const EXPIRATION_DELAY = 2 * 60 * 1000; + +export class StatefulHost implements Host { + url: string; + accept: 'read' | 'readWrite' | 'write'; + protocol: 'http' | 'https'; + + private lastUpdate: number; + private status: 'down' | 'timedout' | 'up'; + + constructor(host: Host, status: StatefulHost['status'] = 'up') { + this.url = host.url; + this.accept = host.accept; + this.protocol = host.protocol; + + this.status = status; + this.lastUpdate = Date.now(); + } + + isUp(): boolean { + return ( + this.status === 'up' || Date.now() - this.lastUpdate > EXPIRATION_DELAY + ); + } + + isTimedout(): boolean { + return ( + this.status === 'timedout' && + Date.now() - this.lastUpdate <= EXPIRATION_DELAY + ); + } +} diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/utils/Transporter.ts b/clients/algoliasearch-client-javascript/client-query-suggestions/utils/Transporter.ts new file mode 100644 index 0000000000..48b4edebfd --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/utils/Transporter.ts @@ -0,0 +1,243 @@ +import { isRetryable, isSuccess } from './Response'; +import { StatefulHost } from './StatefulHost'; +import type { Cache } from './cache/Cache'; +import { MemoryCache } from './cache/MemoryCache'; +import { RetryError } from './errors'; +import { + deserializeFailure, + deserializeSuccess, + serializeData, + serializeHeaders, + serializeUrl, +} from './helpers'; +import { HttpRequester } from './requester/HttpRequester'; +import type { Requester } from './requester/Requester'; +import { + stackTraceWithoutCredentials, + stackFrameWithoutCredentials, +} from './stackTrace'; +import type { + Headers, + Host, + Request, + RequestOptions, + StackFrame, + Timeouts, + Response, + EndRequest, +} from './types'; + +export class Transporter { + private hosts: Host[]; + private baseHeaders: Headers; + private hostsCache: Cache; + private userAgent: string; + private timeouts: Timeouts; + private requester: Requester; + + constructor({ + hosts, + baseHeaders, + userAgent, + timeouts, + requester = new HttpRequester(), + }: { + hosts: Host[]; + baseHeaders: Headers; + userAgent: string; + timeouts: Timeouts; + requester?: Requester; + }) { + this.hosts = hosts; + this.hostsCache = new MemoryCache(); + this.baseHeaders = baseHeaders; + this.userAgent = userAgent; + this.timeouts = timeouts; + this.requester = requester; + } + + setHosts(hosts: Host[]): void { + this.hosts = hosts; + this.hostsCache.clear(); + } + + setRequester(requester: Requester): void { + this.requester = requester; + } + + async createRetryableOptions(compatibleHosts: Host[]): Promise<{ + hosts: Host[]; + getTimeout: (retryCount: number, timeout: number) => number; + }> { + const statefulHosts = await Promise.all( + compatibleHosts.map((statelessHost) => { + return this.hostsCache.get(statelessHost, () => { + return Promise.resolve(new StatefulHost(statelessHost)); + }); + }) + ); + const hostsUp = statefulHosts.filter((host) => host.isUp()); + const hostsTimeouted = statefulHosts.filter((host) => host.isTimedout()); + + /** + * Note, we put the hosts that previously timeouted on the end of the list. + */ + const hostsAvailable = [...hostsUp, ...hostsTimeouted]; + + const hosts = hostsAvailable.length > 0 ? hostsAvailable : compatibleHosts; + + return { + hosts, + getTimeout(timeoutsCount: number, baseTimeout: number): number { + /** + * Imagine that you have 4 hosts, if timeouts will increase + * on the following way: 1 (timeouted) > 4 (timeouted) > 5 (200). + * + * Note that, the very next request, we start from the previous timeout. + * + * 5 (timeouted) > 6 (timeouted) > 7 ... + * + * This strategy may need to be reviewed, but is the strategy on the our + * current v3 version. + */ + const timeoutMultiplier = + hostsTimeouted.length === 0 && timeoutsCount === 0 + ? 1 + : hostsTimeouted.length + 3 + timeoutsCount; + + return timeoutMultiplier * baseTimeout; + }, + }; + } + + async request( + request: Request, + requestOptions: RequestOptions + ): Promise { + const stackTrace: StackFrame[] = []; + + const isRead = request.method === 'GET'; + + /** + * First we prepare the payload that do not depend from hosts. + */ + const data = serializeData(request, requestOptions); + const headers = serializeHeaders(this.baseHeaders, requestOptions); + const method = request.method; + + // On `GET`, the data is proxied to query parameters. + const dataQueryParameters: Record = isRead + ? { + ...request.data, + ...requestOptions.data, + } + : {}; + + const queryParameters = { + 'x-algolia-agent': this.userAgent, + ...dataQueryParameters, + ...requestOptions.queryParameters, + }; + + let timeoutsCount = 0; + + const retry = async ( + hosts: Host[], + getTimeout: (timeoutsCount: number, timeout: number) => number + ): Promise => { + /** + * We iterate on each host, until there is no host left. + */ + const host = hosts.pop(); + if (host === undefined) { + throw new RetryError(stackTraceWithoutCredentials(stackTrace)); + } + + let responseTimeout = requestOptions.timeout; + if (responseTimeout === undefined) { + responseTimeout = isRead ? this.timeouts.read : this.timeouts.write; + } + + const payload: EndRequest = { + data, + headers, + method, + url: serializeUrl(host, request.path, queryParameters), + connectTimeout: getTimeout(timeoutsCount, this.timeouts.connect), + responseTimeout: getTimeout(timeoutsCount, responseTimeout), + }; + + /** + * The stackFrame is pushed to the stackTrace so we + * can have information about onRetry and onFailure + * decisions. + */ + const pushToStackTrace = (response: Response): StackFrame => { + const stackFrame: StackFrame = { + request: payload, + response, + host, + triesLeft: hosts.length, + }; + + stackTrace.push(stackFrame); + + return stackFrame; + }; + + const response = await this.requester.send(payload, request); + + if (isRetryable(response)) { + const stackFrame = pushToStackTrace(response); + + // If response is a timeout, we increase the number of timeouts so we can increase the timeout later. + if (response.isTimedOut) { + timeoutsCount++; + } + /** + * Failures are individually sent to the logger, allowing + * the end user to debug / store stack frames even + * when a retry error does not happen. + */ + // eslint-disable-next-line no-console -- this will be fixed with the new `Logger` + console.log( + 'Retryable failure', + stackFrameWithoutCredentials(stackFrame) + ); + + /** + * We also store the state of the host in failure cases. If the host, is + * down it will remain down for the next 2 minutes. In a timeout situation, + * this host will be added end of the list of hosts on the next request. + */ + await this.hostsCache.set( + host, + new StatefulHost(host, response.isTimedOut ? 'timedout' : 'down') + ); + return retry(hosts, getTimeout); + } + if (isSuccess(response)) { + return deserializeSuccess(response); + } + + pushToStackTrace(response); + throw deserializeFailure(response, stackTrace); + }; + + /** + * Finally, for each retryable host perform request until we got a non + * retryable response. Some notes here: + * + * 1. The reverse here is applied so we can apply a `pop` later on => more performant. + * 2. We also get from the retryable options a timeout multiplier that is tailored + * for the current context. + */ + const compatibleHosts = this.hosts.filter( + (host) => + host.accept === 'readWrite' || + (isRead ? host.accept === 'read' : host.accept === 'write') + ); + const options = await this.createRetryableOptions(compatibleHosts); + return retry([...options.hosts].reverse(), options.getTimeout); + } +} diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/utils/cache/Cache.ts b/clients/algoliasearch-client-javascript/client-query-suggestions/utils/cache/Cache.ts new file mode 100644 index 0000000000..625862660c --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/utils/cache/Cache.ts @@ -0,0 +1,27 @@ +export interface Cache { + /** + * Gets the value of the given `key`. + */ + get: ( + key: Record | string, + defaultValue: () => Promise + ) => Promise; + + /** + * Sets the given value with the given `key`. + */ + set: ( + key: Record | string, + value: TValue + ) => Promise; + + /** + * Deletes the given `key`. + */ + delete: (key: Record | string) => Promise; + + /** + * Clears the cache. + */ + clear: () => Promise; +} diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/utils/cache/MemoryCache.ts b/clients/algoliasearch-client-javascript/client-query-suggestions/utils/cache/MemoryCache.ts new file mode 100644 index 0000000000..f7853f39bc --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/utils/cache/MemoryCache.ts @@ -0,0 +1,39 @@ +import type { Cache } from './Cache'; + +export class MemoryCache implements Cache { + private cache: Record = {}; + + async get( + key: Record | string, + defaultValue: () => Promise + ): Promise { + const keyAsString = JSON.stringify(key); + + if (keyAsString in this.cache) { + return Promise.resolve(this.cache[keyAsString]); + } + + return await defaultValue(); + } + + set( + key: Record | string, + value: TValue + ): Promise { + this.cache[JSON.stringify(key)] = value; + + return Promise.resolve(value); + } + + delete(key: Record | string): Promise { + delete this.cache[JSON.stringify(key)]; + + return Promise.resolve(); + } + + clear(): Promise { + this.cache = {}; + + return Promise.resolve(); + } +} diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/utils/errors.ts b/clients/algoliasearch-client-javascript/client-query-suggestions/utils/errors.ts new file mode 100644 index 0000000000..a02f3004ad --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/utils/errors.ts @@ -0,0 +1,38 @@ +import type { Response, StackFrame } from './types'; + +class ErrorWithStackTrace extends Error { + stackTrace: StackFrame[]; + + constructor(message: string, stackTrace: StackFrame[]) { + super(message); + // the array and object should be frozen to reflect the stackTrace at the time of the error + this.stackTrace = stackTrace; + } +} + +export class RetryError extends ErrorWithStackTrace { + constructor(stackTrace: StackFrame[]) { + super( + 'Unreachable hosts - your application id may be incorrect. If the error persists, contact support@algolia.com.', + stackTrace + ); + } +} + +export class ApiError extends ErrorWithStackTrace { + status: number; + + constructor(message: string, status: number, stackTrace: StackFrame[]) { + super(message, stackTrace); + this.status = status; + } +} + +export class DeserializationError extends Error { + response: Response; + + constructor(message: string, response: Response) { + super(message); + this.response = response; + } +} diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/utils/helpers.ts b/clients/algoliasearch-client-javascript/client-query-suggestions/utils/helpers.ts new file mode 100644 index 0000000000..5d64ac8868 --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/utils/helpers.ts @@ -0,0 +1,117 @@ +import { ApiError, DeserializationError } from './errors'; +import type { + Headers, + Host, + Request, + RequestOptions, + Response, + StackFrame, +} from './types'; + +export function shuffle(array: TData[]): TData[] { + const shuffledArray = array; + + for (let c = array.length - 1; c > 0; c--) { + const b = Math.floor(Math.random() * (c + 1)); + const a = array[c]; + + shuffledArray[c] = array[b]; + shuffledArray[b] = a; + } + + return shuffledArray; +} + +export function serializeUrl( + host: Host, + path: string, + queryParameters: Readonly> +): string { + const queryParametersAsString = serializeQueryParameters(queryParameters); + let url = `${host.protocol}://${host.url}/${ + path.charAt(0) === '/' ? path.substr(1) : path + }`; + + if (queryParametersAsString.length) { + url += `?${queryParametersAsString}`; + } + + return url; +} + +export function serializeQueryParameters( + parameters: Readonly> +): string { + const isObjectOrArray = (value: any): boolean => + Object.prototype.toString.call(value) === '[object Object]' || + Object.prototype.toString.call(value) === '[object Array]'; + + return Object.keys(parameters) + .map( + (key) => + `${key}=${ + isObjectOrArray(parameters[key]) + ? JSON.stringify(parameters[key]) + : parameters[key] + }` + ) + .join('&'); +} + +export function serializeData( + request: Request, + requestOptions: RequestOptions +): string | undefined { + if ( + request.method === 'GET' || + (request.data === undefined && requestOptions.data === undefined) + ) { + return undefined; + } + + const data = Array.isArray(request.data) + ? request.data + : { ...request.data, ...requestOptions.data }; + + return JSON.stringify(data); +} + +export function serializeHeaders( + baseHeaders: Headers, + requestOptions: RequestOptions +): Headers { + const headers: Headers = { + ...baseHeaders, + ...requestOptions.headers, + }; + const serializedHeaders: Headers = {}; + + Object.keys(headers).forEach((header) => { + const value = headers[header]; + serializedHeaders[header.toLowerCase()] = value; + }); + + return serializedHeaders; +} + +export function deserializeSuccess(response: Response): TObject { + try { + return JSON.parse(response.content); + } catch (e) { + throw new DeserializationError((e as Error).message, response); + } +} + +export function deserializeFailure( + { content, status }: Response, + stackFrame: StackFrame[] +): Error { + let message = content; + try { + message = JSON.parse(content).message; + } catch (e) { + // .. + } + + return new ApiError(message, status, stackFrame); +} diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/utils/requester/EchoRequester.ts b/clients/algoliasearch-client-javascript/client-query-suggestions/utils/requester/EchoRequester.ts new file mode 100644 index 0000000000..ec75b37a41 --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/utils/requester/EchoRequester.ts @@ -0,0 +1,61 @@ +import type { EndRequest, Request, Response } from '../types'; + +import { Requester } from './Requester'; + +type AdditionalContent = { + headers: Record; + connectTimeout: number; + responseTimeout: number; + userAgent?: string; + searchParams?: Record; +}; + +function searchParamsWithoutUA( + params: URLSearchParams +): AdditionalContent['searchParams'] { + const searchParams = {}; + + for (const [k, v] of params) { + if (k === 'x-algolia-agent') { + continue; + } + + searchParams[k] = v; + } + + return Object.entries(searchParams).length === 0 ? undefined : searchParams; +} + +export class EchoRequester extends Requester { + constructor(private status = 200) { + super(); + } + + send( + { headers, url, connectTimeout, responseTimeout }: EndRequest, + { data, ...originalRequest }: Request + ): Promise { + const urlSearchParams = new URL(url).searchParams; + const userAgent = urlSearchParams.get('x-algolia-agent') || undefined; + const searchParams = searchParamsWithoutUA(urlSearchParams); + const additionalContent: AdditionalContent = { + headers, + connectTimeout, + responseTimeout, + userAgent: userAgent ? encodeURI(userAgent) : undefined, + searchParams, + }; + const originalData = + data && Object.entries(data).length > 0 ? data : undefined; + + return Promise.resolve({ + content: JSON.stringify({ + ...originalRequest, + ...additionalContent, + data: originalData, + }), + isTimedOut: false, + status: this.status, + }); + } +} diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/utils/requester/HttpRequester.ts b/clients/algoliasearch-client-javascript/client-query-suggestions/utils/requester/HttpRequester.ts new file mode 100644 index 0000000000..3697d290fb --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/utils/requester/HttpRequester.ts @@ -0,0 +1,94 @@ +import http from 'http'; +import https from 'https'; + +import type { EndRequest, Response } from '../types'; + +import { Requester } from './Requester'; + +// Global agents allow us to reuse the TCP protocol with multiple clients +const agentOptions = { keepAlive: true }; +const httpAgent = new http.Agent(agentOptions); +const httpsAgent = new https.Agent(agentOptions); + +export class HttpRequester extends Requester { + send(request: EndRequest): Promise { + return new Promise((resolve) => { + let responseTimeout: NodeJS.Timeout | undefined; + // eslint-disable-next-line prefer-const -- linter thinks this is not reassigned + let connectTimeout: NodeJS.Timeout | undefined; + const url = new URL(request.url); + const path = + url.search === null ? url.pathname : `${url.pathname}${url.search}`; + const options: https.RequestOptions = { + agent: url.protocol === 'https:' ? httpsAgent : httpAgent, + hostname: url.hostname, + path, + method: request.method, + headers: request.headers, + ...(url.port !== undefined ? { port: url.port || '' } : {}), + }; + + const req = (url.protocol === 'https:' ? https : http).request( + options, + (response) => { + let contentBuffers: Buffer[] = []; + + response.on('data', (chunk) => { + contentBuffers = contentBuffers.concat(chunk); + }); + + response.on('end', () => { + clearTimeout(connectTimeout as NodeJS.Timeout); + clearTimeout(responseTimeout as NodeJS.Timeout); + + resolve({ + status: response.statusCode || 0, + content: Buffer.concat(contentBuffers).toString(), + isTimedOut: false, + }); + }); + } + ); + + const createTimeout = ( + timeout: number, + content: string + ): NodeJS.Timeout => { + return setTimeout(() => { + req.destroy(); + + resolve({ + status: 0, + content, + isTimedOut: true, + }); + }, timeout * 1000); + }; + + connectTimeout = createTimeout( + request.connectTimeout, + 'Connection timeout' + ); + + req.on('error', (error) => { + clearTimeout(connectTimeout as NodeJS.Timeout); + clearTimeout(responseTimeout!); + resolve({ status: 0, content: error.message, isTimedOut: false }); + }); + + req.once('response', () => { + clearTimeout(connectTimeout as NodeJS.Timeout); + responseTimeout = createTimeout( + request.responseTimeout, + 'Socket timeout' + ); + }); + + if (request.data !== undefined) { + req.write(request.data); + } + + req.end(); + }); + } +} diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/utils/requester/Requester.ts b/clients/algoliasearch-client-javascript/client-query-suggestions/utils/requester/Requester.ts new file mode 100644 index 0000000000..41c0606575 --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/utils/requester/Requester.ts @@ -0,0 +1,8 @@ +import type { EndRequest, Request, Response } from '../types'; + +export abstract class Requester { + abstract send( + request: EndRequest, + originalRequest: Request + ): Promise; +} diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/utils/stackTrace.ts b/clients/algoliasearch-client-javascript/client-query-suggestions/utils/stackTrace.ts new file mode 100644 index 0000000000..14750a54f2 --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/utils/stackTrace.ts @@ -0,0 +1,30 @@ +import type { StackFrame } from './types'; + +export function stackTraceWithoutCredentials( + stackTrace: StackFrame[] +): StackFrame[] { + return stackTrace.map((stackFrame) => + stackFrameWithoutCredentials(stackFrame) + ); +} + +export function stackFrameWithoutCredentials( + stackFrame: StackFrame +): StackFrame { + const modifiedHeaders: Record = stackFrame.request.headers[ + 'x-algolia-api-key' + ] + ? { 'x-algolia-api-key': '*****' } + : {}; + + return { + ...stackFrame, + request: { + ...stackFrame.request, + headers: { + ...stackFrame.request.headers, + ...modifiedHeaders, + }, + }, + }; +} diff --git a/clients/algoliasearch-client-javascript/client-query-suggestions/utils/types.ts b/clients/algoliasearch-client-javascript/client-query-suggestions/utils/types.ts new file mode 100644 index 0000000000..d2a478c1a0 --- /dev/null +++ b/clients/algoliasearch-client-javascript/client-query-suggestions/utils/types.ts @@ -0,0 +1,65 @@ +export type Method = 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT'; + +export type Request = { + method: Method; + path: string; + data?: Record; +}; + +export type RequestOptions = { + /** + * Custom timeout for the request. Note that, in normal situacions + * the given timeout will be applied. But the transporter layer may + * increase this timeout if there is need for it. + */ + timeout?: number; + + /** + * Custom headers for the request. This headers are + * going to be merged the transporter headers. + */ + headers?: Record; + + /** + * Custom query parameters for the request. This query parameters are + * going to be merged the transporter query parameters. + */ + queryParameters: Record; + data?: Record; +}; + +export type EndRequest = { + method: Method; + url: string; + connectTimeout: number; + responseTimeout: number; + headers: Headers; + data?: string; +}; + +export type Response = { + content: string; + isTimedOut: boolean; + status: number; +}; + +export type Headers = Record; + +export type Host = { + url: string; + accept: 'read' | 'readWrite' | 'write'; + protocol: 'http' | 'https'; +}; + +export type StackFrame = { + request: EndRequest; + response: Response; + host: Host; + triesLeft: number; +}; + +export type Timeouts = { + readonly connect: number; + readonly read: number; + readonly write: number; +}; diff --git a/openapitools.json b/openapitools.json index 8cf8fe32b6..4c3aace476 100644 --- a/openapitools.json +++ b/openapitools.json @@ -107,6 +107,27 @@ "isInsightsHost": true } }, + "javascript-query-suggestions": { + "generatorName": "typescript-node", + "templateDir": "#{cwd}/templates/javascript/", + "config": "#{cwd}/openapitools.json", + "apiPackage": "src", + "output": "#{cwd}/clients/algoliasearch-client-javascript/client-query-suggestions", + "glob": "specs/query-suggestions/spec.yml", + "gitHost": "algolia", + "gitUserId": "algolia", + "gitRepoId": "algoliasearch-client-javascript", + "additionalProperties": { + "modelPropertyNaming": "original", + "supportsES6": true, + "npmName": "@algolia/client-query-suggestions", + "npmVersion": "5.0.0", + + "packageName": "@algolia/client-query-suggestions", + "hasRegionalHost": true, + "isQuerySuggestionsHost": true + } + }, "java-search": { "generatorName": "java", "templateDir": "#{cwd}/templates/java/", diff --git a/scripts/multiplexer.sh b/scripts/multiplexer.sh index fdff747776..fdcbff16bf 100755 --- a/scripts/multiplexer.sh +++ b/scripts/multiplexer.sh @@ -36,7 +36,7 @@ find_clients_and_languages() { GENERATORS=( $(cat openapitools.json | jq '."generator-cli".generators' | jq -r 'keys[]') ) for generator in "${GENERATORS[@]}"; do - local lang=${generator%-*} + local lang=${generator%%-*} local client=${generator#*-} if [[ ! ${LANGUAGES[*]} =~ $lang ]]; then @@ -73,6 +73,7 @@ for lang in "${LANGUAGE[@]}"; do for client in "${CLIENT[@]}"; do if [[ " ${GENERATORS[*]} " =~ " ${lang}-${client} " ]]; then $CMD $lang $client + echo "" fi done done diff --git a/specs/common/responses/InternalError.yml b/specs/common/responses/InternalError.yml new file mode 100644 index 0000000000..d13873a8aa --- /dev/null +++ b/specs/common/responses/InternalError.yml @@ -0,0 +1,5 @@ +description: Internal error +content: + application/json: + schema: + $ref: ../schemas/ErrorBase.yml diff --git a/specs/common/responses/StatusUnprocessableEntity.yml b/specs/common/responses/StatusUnprocessableEntity.yml new file mode 100644 index 0000000000..7025ba204d --- /dev/null +++ b/specs/common/responses/StatusUnprocessableEntity.yml @@ -0,0 +1,5 @@ +description: Status unprocessable entity +content: + application/json: + schema: + $ref: ../schemas/ErrorBase.yml diff --git a/specs/common/responses/Success.yml b/specs/common/responses/Success.yml new file mode 100644 index 0000000000..06d5bbde78 --- /dev/null +++ b/specs/common/responses/Success.yml @@ -0,0 +1,19 @@ +description: Success +content: + application/json: + schema: + type: object + title: SucessResponse + additionalProperties: false + required: + - status + - message + properties: + status: + type: integer + example: 200 + description: The status code. + message: + type: string + example: Success + description: Message of the response. diff --git a/specs/common/responses/Unauthorized.yml b/specs/common/responses/Unauthorized.yml new file mode 100644 index 0000000000..292fe38b4c --- /dev/null +++ b/specs/common/responses/Unauthorized.yml @@ -0,0 +1,5 @@ +description: Unauthorized +content: + application/json: + schema: + $ref: ../schemas/ErrorBase.yml diff --git a/specs/query-suggestions/common/parameters.yml b/specs/query-suggestions/common/parameters.yml new file mode 100644 index 0000000000..eed470b9b3 --- /dev/null +++ b/specs/query-suggestions/common/parameters.yml @@ -0,0 +1,93 @@ +SourceIndex: + type: object + additionalProperties: false + required: + - indexName + properties: + indexName: + type: string + description: Source index name. + analyticsTags: + type: array + items: + type: string + default: [] + description: List of analytics tags to filter the popular searches per tag. + facets: + type: array + items: + type: object + default: [] + description: List of facets to define as categories for the query suggestions. + minHits: + type: integer + description: Minimum number of hits (e.g., matching records in the source index) to generate a suggestions. + minLetters: + type: integer + description: Minimum number of required letters for a suggestion to remain. + generate: + type: array + items: + type: array + items: + type: string + default: [] + description: List of facet attributes used to generate Query Suggestions. The resulting suggestions are every combination of the facets in the nested list (e.g., (facetA and facetB) and facetC). + example: [[facetA, facetB], [facetC]] + external: + type: array + items: + $ref: '#/SourceIndexExternal' + description: List of external indices to use to generate custom Query Suggestions. + +SourceIndexExternal: + type: object + additionalProperties: false + required: + - query + - count + properties: + query: + type: string + description: The suggestion you would like to add + count: + type: integer + description: The measure of the suggestion relative popularity + +SourceIndices: + type: array + items: + $ref: '#/SourceIndex' + description: List of source indices used to generate a Query Suggestions index. + +QuerySuggestionsIndexParam: + type: object + additionalProperties: false + required: + - sourceIndices + properties: + sourceIndices: + $ref: ../common/parameters.yml#/SourceIndices + languages: + type: array + items: + type: string + description: De-duplicate singular and plural suggestions. For example, let's say your index contains English content, and that two suggestions “shoe” and “shoes” end up in your Query Suggestions index. If the English language is configured, only the most popular of those two suggestions would remain. + exclude: + type: array + items: + type: string + description: List of words and patterns to exclude from the Query Suggestions index. + +QuerySuggestionsIndexWithIndexParam: + allOf: + - $ref: '#/QuerySuggestionsIndexParam' + - type: object + title: IndexName + additionalProperties: false + required: + - indexName + properties: + indexName: + type: string + description: Index name to target. diff --git a/specs/query-suggestions/common/schemas/QuerySuggestionsIndex.yml b/specs/query-suggestions/common/schemas/QuerySuggestionsIndex.yml new file mode 100644 index 0000000000..07ca544d67 --- /dev/null +++ b/specs/query-suggestions/common/schemas/QuerySuggestionsIndex.yml @@ -0,0 +1,61 @@ +QuerySuggestionsIndex: + type: object + additionalProperties: false + required: + - indexName + - sourceIndices + - languages + - exclude + properties: + indexName: + type: string + description: Index name to target. + sourceIndices: + $ref: '#/SourceIndicesWithReplicas' + languages: + type: array + items: + type: string + description: De-duplicate singular and plural suggestions. For example, let's say your index contains English content, and that two suggestions “shoe” and “shoes” end up in your Query Suggestions index. If the English language is configured, only the most popular of those two suggestions would remain. + exclude: + type: array + items: + type: string + description: List of words and patterns to exclude from the Query Suggestions index. + +SourceIndexWithReplicas: + type: object + additionalProperties: false + required: + - indexName + - replicas + - analyticsTags + - facets + - minHits + - minLetters + - generate + - external + properties: + indexName: + $ref: ../parameters.yml#/SourceIndex/properties/indexName + replicas: + type: boolean + description: true if the Query Suggestions index is a replicas. + analyticsTags: + $ref: ../parameters.yml#/SourceIndex/properties/analyticsTags + facets: + $ref: ../parameters.yml#/SourceIndex/properties/facets + minHits: + $ref: ../parameters.yml#/SourceIndex/properties/minHits + minLetters: + $ref: ../parameters.yml#/SourceIndex/properties/minLetters + generate: + $ref: ../parameters.yml#/SourceIndex/properties/generate + external: + $ref: ../parameters.yml#/SourceIndex/properties/external + +SourceIndicesWithReplicas: + type: array + items: + $ref: '#/SourceIndexWithReplicas' + description: List of source indices used to generate a Query Suggestions index. diff --git a/specs/query-suggestions/paths/getConfigurationStatus.yml b/specs/query-suggestions/paths/getConfigurationStatus.yml new file mode 100644 index 0000000000..227613aec4 --- /dev/null +++ b/specs/query-suggestions/paths/getConfigurationStatus.yml @@ -0,0 +1,40 @@ +get: + tags: + - query-suggestions + operationId: getConfigStatus + description: > + Get the status of a Query Suggestion's index. + + The status includes whether the Query Suggestions index is currently in the process of being built, and the last build time. + parameters: + - $ref: ../../common/parameters.yml#/IndexName + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + title: Status + additionalProperties: false + required: + - indexName + - isRunning + - lastBuiltAt + properties: + indexName: + type: string + description: The targeted index name. + isRunning: + type: boolean + description: true if the Query Suggestions index is running. + lastBuiltAt: + type: string + format: data-time + description: Date and time of the last build. + '401': + $ref: ../../common/responses/Unauthorized.yml + '403': + $ref: ../../common/responses/MethodNotAllowed.yml + '500': + $ref: ../../common/responses/InternalError.yml diff --git a/specs/query-suggestions/paths/getLogFile.yml b/specs/query-suggestions/paths/getLogFile.yml new file mode 100644 index 0000000000..5c6f630417 --- /dev/null +++ b/specs/query-suggestions/paths/getLogFile.yml @@ -0,0 +1,46 @@ +get: + tags: + - query-suggestions + operationId: getLogFile + description: Get the log file of the last build of a single Query Suggestion index. + parameters: + - $ref: ../../common/parameters.yml#/IndexName + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + type: object + title: LogFile + additionalProperties: false + required: + - timestamp + - level + - message + - contextLevel + properties: + timestamp: + type: string + format: date-time + description: date and time of creation of the record. + level: + type: string + enum: [INFO, SKIP, ERROR] + description: type of the record, can be one of three values (INFO, SKIP or ERROR). + message: + type: string + description: detailed description of what happened. + contextLevel: + type: integer + description: indicates the hierarchy of the records. For example, a record with contextLevel=1 belongs to a preceding record with contextLevel=0. + '401': + $ref: ../../common/responses/Unauthorized.yml + '403': + $ref: ../../common/responses/MethodNotAllowed.yml + '404': + $ref: ../../common/responses/IndexNotFound.yml + '500': + $ref: ../../common/responses/InternalError.yml diff --git a/specs/query-suggestions/paths/qsConfig.yml b/specs/query-suggestions/paths/qsConfig.yml new file mode 100644 index 0000000000..dafde87126 --- /dev/null +++ b/specs/query-suggestions/paths/qsConfig.yml @@ -0,0 +1,67 @@ +put: + tags: + - query-suggestions + operationId: updateConfig + description: Update the configuration of a Query Suggestions index. + parameters: + - $ref: ../../common/parameters.yml#/IndexName + requestBody: + required: true + content: + application/json: + schema: + $ref: ../common/parameters.yml#/QuerySuggestionsIndexParam + responses: + '200': + $ref: ../../common/responses/Success.yml + '401': + $ref: ../../common/responses/Unauthorized.yml + '500': + $ref: ../../common/responses/InternalError.yml + +delete: + tags: + - query-suggestions + operationId: deleteConfig + description: > + Delete a configuration of a Query Suggestion's index. + + By deleting a configuraton, you stop all updates to the underlying query suggestion index. + + Note that when doing this, the underlying index does not change - existing suggestions remain untouched. + parameters: + - $ref: ../../common/parameters.yml#/IndexName + responses: + '200': + $ref: ../../common/responses/Success.yml + '401': + $ref: ../../common/responses/Unauthorized.yml + '403': + $ref: ../../common/responses/MethodNotAllowed.yml + '500': + $ref: ../../common/responses/InternalError.yml + +get: + tags: + - query-suggestions + operationId: getConfig + description: Get the configuration of a single Query Suggestions index. + parameters: + - $ref: ../../common/parameters.yml#/IndexName + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: ../common/schemas/QuerySuggestionsIndex.yml#/QuerySuggestionsIndex + '400': + $ref: ../../common/responses/BadRequest.yml + '401': + $ref: ../../common/responses/Unauthorized.yml + '403': + $ref: ../../common/responses/MethodNotAllowed.yml + '404': + $ref: ../../common/responses/IndexNotFound.yml + '500': + $ref: ../../common/responses/InternalError.yml diff --git a/specs/query-suggestions/paths/qsConfigs.yml b/specs/query-suggestions/paths/qsConfigs.yml new file mode 100644 index 0000000000..be2a55d40c --- /dev/null +++ b/specs/query-suggestions/paths/qsConfigs.yml @@ -0,0 +1,50 @@ +post: + tags: + - query-suggestions + operationId: createConfig + description: Create a configuration of a Query Suggestions index. There's a limit of 100 configurations per application. + requestBody: + required: true + content: + application/json: + schema: + $ref: ../common/parameters.yml#/QuerySuggestionsIndexWithIndexParam + responses: + '200': + $ref: ../../common/responses/Success.yml + '400': + $ref: ../../common/responses/BadRequest.yml + '401': + $ref: ../../common/responses/Unauthorized.yml + '403': + $ref: ../../common/responses/MethodNotAllowed.yml + '422': + $ref: ../../common/responses/StatusUnprocessableEntity.yml + '500': + $ref: ../../common/responses/InternalError.yml + +get: + tags: + - query-suggestions + operationId: getAllConfigs + description: > + Get all the configurations of Query Suggestions. + + For each index, you get a block of JSON with a list of its configuration settings. + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: ../common/schemas/QuerySuggestionsIndex.yml#/QuerySuggestionsIndex + '401': + $ref: ../../common/responses/Unauthorized.yml + '403': + $ref: ../../common/responses/MethodNotAllowed.yml + '422': + $ref: ../../common/responses/StatusUnprocessableEntity.yml + '500': + $ref: ../../common/responses/InternalError.yml diff --git a/specs/query-suggestions/spec.yml b/specs/query-suggestions/spec.yml new file mode 100644 index 0000000000..b6c7459b56 --- /dev/null +++ b/specs/query-suggestions/spec.yml @@ -0,0 +1,23 @@ +openapi: 3.0.2 +info: + title: Query Suggestions API + description: API powering the Query Suggestions feature of Algolia. + version: 0.0.1 +components: + securitySchemes: + appId: + $ref: '../common/securitySchemes.yml#/appId' + apiKey: + $ref: '../common/securitySchemes.yml#/apiKey' +security: + - appId: [] + apiKey: [] +paths: + /1/configs: + $ref: paths/qsConfigs.yml + /1/configs/{indexName}: + $ref: paths/qsConfig.yml + /1/configs/{indexName}/status: + $ref: paths/getConfigurationStatus.yml + /1/logs/{indexName}: + $ref: paths/getLogFile.yml diff --git a/specs/query_suggestions/paths/getConfigurationStatus.yml b/specs/query_suggestions/paths/getConfigurationStatus.yml deleted file mode 100644 index 01e867a0c4..0000000000 --- a/specs/query_suggestions/paths/getConfigurationStatus.yml +++ /dev/null @@ -1 +0,0 @@ -# get: diff --git a/specs/query_suggestions/paths/getLogFile.yml b/specs/query_suggestions/paths/getLogFile.yml deleted file mode 100644 index 01e867a0c4..0000000000 --- a/specs/query_suggestions/paths/getLogFile.yml +++ /dev/null @@ -1 +0,0 @@ -# get: diff --git a/specs/query_suggestions/paths/qsConfig.yml b/specs/query_suggestions/paths/qsConfig.yml deleted file mode 100644 index 9c17774007..0000000000 --- a/specs/query_suggestions/paths/qsConfig.yml +++ /dev/null @@ -1,3 +0,0 @@ -# put: -# delete: -# get: diff --git a/specs/query_suggestions/paths/qsConfigs.yml b/specs/query_suggestions/paths/qsConfigs.yml deleted file mode 100644 index f687ddeb1f..0000000000 --- a/specs/query_suggestions/paths/qsConfigs.yml +++ /dev/null @@ -1,2 +0,0 @@ -# post: -# get: diff --git a/specs/query_suggestions/spec.yml b/specs/query_suggestions/spec.yml deleted file mode 100644 index 5afa2da29c..0000000000 --- a/specs/query_suggestions/spec.yml +++ /dev/null @@ -1,23 +0,0 @@ -# openapi: 3.0.2 -# info: -# title: Query Suggestions API -# description: API powering the Query Suggestions feature of Algolia. -# version: 0.0.1 -# components: -# securitySchemes: -# appId: -# $ref: '../common/securitySchemes.yml#/appId' -# apiKey: -# $ref: '../common/securitySchemes.yml#/apiKey' -# security: -# - appId: [] -# apiKey: [] -# paths: -# /1/configs: -# $ref: paths/qsConfigs.yml -# /1/configs/{indexName}: -# $ref: 'paths/qsConfig.yml' -# /1/configs/{indexName}/status: -# $ref: 'paths/getConfigurationStatus.yml' -# /1/logs/{indexName}: -# $ref: 'paths/getLogFile.yml' diff --git a/templates/javascript/api-single.mustache b/templates/javascript/api-single.mustache index aad28652c6..8f16b0d348 100644 --- a/templates/javascript/api-single.mustache +++ b/templates/javascript/api-single.mustache @@ -60,6 +60,9 @@ export class {{classname}} { {{#isAnalyticsHost}} region: 'de' | 'us', {{/isAnalyticsHost}} + {{#isQuerySuggestionsHost}} + region: 'eu' | 'us', + {{/isQuerySuggestionsHost}} {{/hasRegionalHost}} options?: {requester?: Requester, hosts?: Host[]} ) { @@ -119,6 +122,12 @@ export class {{classname}} { } {{/isInsightsHost}} + {{#isQuerySuggestionsHost}} + public getDefaultHosts(region: string = 'us'): Host[] { + return [{ url: `query-suggestions.${region}.algolia.com`, accept: 'readWrite', protocol: 'https' }]; + } + {{/isQuerySuggestionsHost}} + public setRequest(requester: Requester): void { this.transporter.setRequester(requester); } diff --git a/tests/CTS/clients/query-suggestions/createConfig.json b/tests/CTS/clients/query-suggestions/createConfig.json new file mode 100644 index 0000000000..eb4cae476b --- /dev/null +++ b/tests/CTS/clients/query-suggestions/createConfig.json @@ -0,0 +1,67 @@ +[ + { + "method": "createConfig", + "parameters": { + "querySuggestionsIndexWithIndexParam": { + "indexName": "theIndexName", + "sourceIndices": [ + { + "indexName": "testIndex", + "facets": [ + { + "attributes": "test" + } + ], + "generate": [ + [ + "facetA", + "facetB" + ], + [ + "facetC" + ] + ] + } + ], + "languages": [ + "french" + ], + "exclude": [ + "test" + ] + } + }, + "request": { + "path": "/1/configs", + "method": "POST", + "data": { + "indexName": "theIndexName", + "sourceIndices": [ + { + "indexName": "testIndex", + "facets": [ + { + "attributes": "test" + } + ], + "generate": [ + [ + "facetA", + "facetB" + ], + [ + "facetC" + ] + ] + } + ], + "languages": [ + "french" + ], + "exclude": [ + "test" + ] + } + } + } +] diff --git a/tests/CTS/clients/query-suggestions/deleteConfig.json b/tests/CTS/clients/query-suggestions/deleteConfig.json new file mode 100644 index 0000000000..4d07fd9d19 --- /dev/null +++ b/tests/CTS/clients/query-suggestions/deleteConfig.json @@ -0,0 +1,12 @@ +[ + { + "method": "deleteConfig", + "parameters": { + "indexName": "theIndexName" + }, + "request": { + "path": "/1/configs/theIndexName", + "method": "DELETE" + } + } +] diff --git a/tests/CTS/clients/query-suggestions/getAllConfigs.json b/tests/CTS/clients/query-suggestions/getAllConfigs.json new file mode 100644 index 0000000000..13a2deb8c7 --- /dev/null +++ b/tests/CTS/clients/query-suggestions/getAllConfigs.json @@ -0,0 +1,10 @@ +[ + { + "method": "getAllConfigs", + "parameters": {}, + "request": { + "path": "/1/configs", + "method": "GET" + } + } +] diff --git a/tests/CTS/clients/query-suggestions/getConfig.json b/tests/CTS/clients/query-suggestions/getConfig.json new file mode 100644 index 0000000000..f7e308fb9a --- /dev/null +++ b/tests/CTS/clients/query-suggestions/getConfig.json @@ -0,0 +1,12 @@ +[ + { + "method": "getConfig", + "parameters": { + "indexName": "theIndexName" + }, + "request": { + "path": "/1/configs/theIndexName", + "method": "GET" + } + } +] diff --git a/tests/CTS/clients/query-suggestions/getConfigStatus.json b/tests/CTS/clients/query-suggestions/getConfigStatus.json new file mode 100644 index 0000000000..b9e0abc1e8 --- /dev/null +++ b/tests/CTS/clients/query-suggestions/getConfigStatus.json @@ -0,0 +1,12 @@ +[ + { + "method": "getConfigStatus", + "parameters": { + "indexName": "theIndexName" + }, + "request": { + "path": "/1/configs/theIndexName/status", + "method": "GET" + } + } +] diff --git a/tests/CTS/clients/query-suggestions/getLogFile.json b/tests/CTS/clients/query-suggestions/getLogFile.json new file mode 100644 index 0000000000..caea88b93a --- /dev/null +++ b/tests/CTS/clients/query-suggestions/getLogFile.json @@ -0,0 +1,12 @@ +[ + { + "method": "getLogFile", + "parameters": { + "indexName": "theIndexName" + }, + "request": { + "path": "/1/logs/theIndexName", + "method": "GET" + } + } +] diff --git a/tests/CTS/clients/query-suggestions/updateConfig.json b/tests/CTS/clients/query-suggestions/updateConfig.json new file mode 100644 index 0000000000..117e5810e8 --- /dev/null +++ b/tests/CTS/clients/query-suggestions/updateConfig.json @@ -0,0 +1,66 @@ +[ + { + "method": "updateConfig", + "parameters": { + "indexName": "theIndexName", + "querySuggestionsIndexParam": { + "sourceIndices": [ + { + "indexName": "testIndex", + "facets": [ + { + "attributes": "test" + } + ], + "generate": [ + [ + "facetA", + "facetB" + ], + [ + "facetC" + ] + ] + } + ], + "languages": [ + "french" + ], + "exclude": [ + "test" + ] + } + }, + "request": { + "path": "/1/configs/theIndexName", + "method": "PUT", + "data": { + "sourceIndices": [ + { + "indexName": "testIndex", + "facets": [ + { + "attributes": "test" + } + ], + "generate": [ + [ + "facetA", + "facetB" + ], + [ + "facetC" + ] + ] + } + ], + "languages": [ + "french" + ], + "exclude": [ + "test" + ] + } + } + } +] diff --git a/tests/CTS/templates/javascript.mustache b/tests/CTS/templates/javascript.mustache index 5b2541fb66..32de5195b0 100644 --- a/tests/CTS/templates/javascript.mustache +++ b/tests/CTS/templates/javascript.mustache @@ -3,7 +3,7 @@ import { {{client}}, EchoRequester } from '{{{import}}}'; const appId = process.env.ALGOLIA_APPLICATION_ID || 'test_app_id'; const apiKey = process.env.ALGOLIA_SEARCH_KEY || 'test_api_key'; -const client = new {{client}}(appId, apiKey, {{#hasRegionalHost}}'de', {{/hasRegionalHost}}{ requester: new EchoRequester() }); +const client = new {{client}}(appId, apiKey, {{#hasRegionalHost}}'us', {{/hasRegionalHost}}{ requester: new EchoRequester() }); {{#blocks}} describe('{{operationId}}', () => { diff --git a/tests/generateCTS.ts b/tests/generateCTS.ts index 0af2103492..0059de757f 100644 --- a/tests/generateCTS.ts +++ b/tests/generateCTS.ts @@ -54,7 +54,9 @@ const packageNames: Record> = Object.entries( openapitools['generator-cli'].generators ).reduce((prev, [clientName, clientConfig]) => { const obj = prev; - const [lang, client] = clientName.split('-') as [Language, string]; + const parts = clientName.split('-'); + const lang = parts[0] as Language; + const client = parts.slice(1).join('-'); if (!(lang in prev)) { obj[lang] = {}; @@ -83,6 +85,13 @@ function capitalize(str: string): string { return str.charAt(0).toUpperCase() + str.slice(1); } +function createClientName(client: string): string { + return `${client + .split('-') + .map((part) => capitalize(part)) + .join('')}Api`; +} + function removeObjectName(obj: Record): void { for (const prop in obj) { if (prop === '$objectName') { @@ -211,9 +220,13 @@ async function generateCode(language: Language): Promise { const code = Mustache.render(template, { import: packageNames[language][client], - client: `${capitalize(client)}Api`, + client: createClientName(client), blocks: cts[client], - hasRegionalHost: ['personalization', 'analytics'].includes(client), + hasRegionalHost: [ + 'personalization', + 'analytics', + 'query-suggestions', + ].includes(client), }); await fsp.writeFile( `output/${language}/${client}${extensionForLanguage[language]}`, diff --git a/tests/output/javascript/analytics.test.ts b/tests/output/javascript/analytics.test.ts index 699732c9ff..281c80fd03 100644 --- a/tests/output/javascript/analytics.test.ts +++ b/tests/output/javascript/analytics.test.ts @@ -3,7 +3,7 @@ import { AnalyticsApi, EchoRequester } from '@algolia/client-analytics'; const appId = process.env.ALGOLIA_APPLICATION_ID || 'test_app_id'; const apiKey = process.env.ALGOLIA_SEARCH_KEY || 'test_api_key'; -const client = new AnalyticsApi(appId, apiKey, 'de', { +const client = new AnalyticsApi(appId, apiKey, 'us', { requester: new EchoRequester(), }); diff --git a/tests/output/javascript/personalization.test.ts b/tests/output/javascript/personalization.test.ts index 5f0c629665..1b58e2056d 100644 --- a/tests/output/javascript/personalization.test.ts +++ b/tests/output/javascript/personalization.test.ts @@ -6,7 +6,7 @@ import { const appId = process.env.ALGOLIA_APPLICATION_ID || 'test_app_id'; const apiKey = process.env.ALGOLIA_SEARCH_KEY || 'test_api_key'; -const client = new PersonalizationApi(appId, apiKey, 'de', { +const client = new PersonalizationApi(appId, apiKey, 'us', { requester: new EchoRequester(), }); diff --git a/tests/output/javascript/query-suggestions.test.ts b/tests/output/javascript/query-suggestions.test.ts new file mode 100644 index 0000000000..95100564e1 --- /dev/null +++ b/tests/output/javascript/query-suggestions.test.ts @@ -0,0 +1,135 @@ +import { + QuerySuggestionsApi, + EchoRequester, +} from '@algolia/client-query-suggestions'; + +const appId = process.env.ALGOLIA_APPLICATION_ID || 'test_app_id'; +const apiKey = process.env.ALGOLIA_SEARCH_KEY || 'test_api_key'; + +const client = new QuerySuggestionsApi(appId, apiKey, 'us', { + requester: new EchoRequester(), +}); + +describe('createConfig', () => { + test('createConfig', async () => { + const req = await client.createConfig({ + querySuggestionsIndexWithIndexParam: { + indexName: 'theIndexName', + sourceIndices: [ + { + indexName: 'testIndex', + facets: [{ attributes: 'test' }], + generate: [['facetA', 'facetB'], ['facetC']], + }, + ], + languages: ['french'], + exclude: ['test'], + }, + }); + + expect((req as any).path).toEqual('/1/configs'); + expect((req as any).method).toEqual('POST'); + expect((req as any).data).toEqual({ + indexName: 'theIndexName', + sourceIndices: [ + { + indexName: 'testIndex', + facets: [{ attributes: 'test' }], + generate: [['facetA', 'facetB'], ['facetC']], + }, + ], + languages: ['french'], + exclude: ['test'], + }); + expect((req as any).searchParams).toEqual(undefined); + }); +}); + +describe('deleteConfig', () => { + test('deleteConfig', async () => { + const req = await client.deleteConfig({ indexName: 'theIndexName' }); + + expect((req as any).path).toEqual('/1/configs/theIndexName'); + expect((req as any).method).toEqual('DELETE'); + expect((req as any).data).toEqual(undefined); + expect((req as any).searchParams).toEqual(undefined); + }); +}); + +describe('getAllConfigs', () => { + test('getAllConfigs', async () => { + const req = await client.getAllConfigs(); + + expect((req as any).path).toEqual('/1/configs'); + expect((req as any).method).toEqual('GET'); + expect((req as any).data).toEqual(undefined); + expect((req as any).searchParams).toEqual(undefined); + }); +}); + +describe('getConfig', () => { + test('getConfig', async () => { + const req = await client.getConfig({ indexName: 'theIndexName' }); + + expect((req as any).path).toEqual('/1/configs/theIndexName'); + expect((req as any).method).toEqual('GET'); + expect((req as any).data).toEqual(undefined); + expect((req as any).searchParams).toEqual(undefined); + }); +}); + +describe('getConfigStatus', () => { + test('getConfigStatus', async () => { + const req = await client.getConfigStatus({ indexName: 'theIndexName' }); + + expect((req as any).path).toEqual('/1/configs/theIndexName/status'); + expect((req as any).method).toEqual('GET'); + expect((req as any).data).toEqual(undefined); + expect((req as any).searchParams).toEqual(undefined); + }); +}); + +describe('getLogFile', () => { + test('getLogFile', async () => { + const req = await client.getLogFile({ indexName: 'theIndexName' }); + + expect((req as any).path).toEqual('/1/logs/theIndexName'); + expect((req as any).method).toEqual('GET'); + expect((req as any).data).toEqual(undefined); + expect((req as any).searchParams).toEqual(undefined); + }); +}); + +describe('updateConfig', () => { + test('updateConfig', async () => { + const req = await client.updateConfig({ + indexName: 'theIndexName', + querySuggestionsIndexParam: { + sourceIndices: [ + { + indexName: 'testIndex', + facets: [{ attributes: 'test' }], + generate: [['facetA', 'facetB'], ['facetC']], + }, + ], + languages: ['french'], + exclude: ['test'], + }, + }); + + expect((req as any).path).toEqual('/1/configs/theIndexName'); + expect((req as any).method).toEqual('PUT'); + expect((req as any).data).toEqual({ + sourceIndices: [ + { + indexName: 'testIndex', + facets: [{ attributes: 'test' }], + generate: [['facetA', 'facetB'], ['facetC']], + }, + ], + languages: ['french'], + exclude: ['test'], + }); + expect((req as any).searchParams).toEqual(undefined); + }); +}); diff --git a/tests/package.json b/tests/package.json index 383462b08f..e578318b6b 100644 --- a/tests/package.json +++ b/tests/package.json @@ -11,6 +11,7 @@ "@algolia/client-analytics": "5.0.0", "@algolia/client-insights": "5.0.0", "@algolia/client-personalization": "5.0.0", + "@algolia/client-query-suggestions": "5.0.0", "@algolia/client-search": "5.0.0", "@algolia/recommend": "5.0.0" }, diff --git a/yarn.lock b/yarn.lock index b4ae5af5bb..73923ccc64 100644 --- a/yarn.lock +++ b/yarn.lock @@ -58,6 +58,15 @@ __metadata: languageName: unknown linkType: soft +"@algolia/client-query-suggestions@5.0.0, @algolia/client-query-suggestions@workspace:clients/algoliasearch-client-javascript/client-query-suggestions": + version: 0.0.0-use.local + resolution: "@algolia/client-query-suggestions@workspace:clients/algoliasearch-client-javascript/client-query-suggestions" + dependencies: + "@types/node": 16.11.11 + typescript: 4.5.4 + languageName: unknown + linkType: soft + "@algolia/client-search@5.0.0, @algolia/client-search@workspace:clients/algoliasearch-client-javascript/client-search": version: 0.0.0-use.local resolution: "@algolia/client-search@workspace:clients/algoliasearch-client-javascript/client-search" @@ -5695,6 +5704,7 @@ fsevents@^2.3.2: "@algolia/client-analytics": 5.0.0 "@algolia/client-insights": 5.0.0 "@algolia/client-personalization": 5.0.0 + "@algolia/client-query-suggestions": 5.0.0 "@algolia/client-search": 5.0.0 "@algolia/recommend": 5.0.0 "@apidevtools/swagger-parser": 10.0.3