diff --git a/specification/eslint.config.js b/specification/eslint.config.js index ad85b7f178..1791b0d344 100644 --- a/specification/eslint.config.js +++ b/specification/eslint.config.js @@ -35,6 +35,7 @@ export default defineConfig({ 'es-spec-validator/dictionary-key-is-string': 'error', 'es-spec-validator/no-native-types': 'error', 'es-spec-validator/invalid-node-types': 'error', - 'es-spec-validator/no-generic-number': 'error' + 'es-spec-validator/no-generic-number': 'error', + 'es-spec-validator/request-must-have-urls': 'error' } }) diff --git a/specification/esql/async_query/AsyncQueryRequest.ts b/specification/esql/async_query/AsyncQueryRequest.ts index 4118ebaf3d..020c182ef3 100644 --- a/specification/esql/async_query/AsyncQueryRequest.ts +++ b/specification/esql/async_query/AsyncQueryRequest.ts @@ -37,6 +37,12 @@ import { Dictionary } from '@spec_utils/Dictionary' * @index_privileges read */ export interface Request extends RequestBase { + urls: [ + { + path: '/_query/async' + methods: ['POST'] + } + ] query_parameters: { /** * If `true`, partial results will be returned if there are shard failures, but the query can continue to execute on other clusters and shards. diff --git a/specification/esql/async_query_delete/AsyncQueryDeleteRequest.ts b/specification/esql/async_query_delete/AsyncQueryDeleteRequest.ts index 9a78be3a87..cdc742a763 100644 --- a/specification/esql/async_query_delete/AsyncQueryDeleteRequest.ts +++ b/specification/esql/async_query_delete/AsyncQueryDeleteRequest.ts @@ -35,6 +35,12 @@ import { Id } from '@_types/common' * @ext_doc_id esql */ export interface Request extends RequestBase { + urls: [ + { + path: '/_query/async/{id}' + methods: ['DELETE'] + } + ] path_parts: { /** * The unique identifier of the query. diff --git a/specification/esql/async_query_get/AsyncQueryGetRequest.ts b/specification/esql/async_query_get/AsyncQueryGetRequest.ts index c7de13e57e..3c96b7aaaf 100644 --- a/specification/esql/async_query_get/AsyncQueryGetRequest.ts +++ b/specification/esql/async_query_get/AsyncQueryGetRequest.ts @@ -32,6 +32,12 @@ import { EsqlFormat } from '@esql/_types/QueryParameters' * @ext_doc_id esql */ export interface Request extends RequestBase { + urls: [ + { + path: '/_query/async/{id}' + methods: ['GET'] + } + ] path_parts: { /** * The unique identifier of the query. diff --git a/specification/esql/async_query_stop/AsyncQueryStopRequest.ts b/specification/esql/async_query_stop/AsyncQueryStopRequest.ts index f7e975e956..caca864d0d 100644 --- a/specification/esql/async_query_stop/AsyncQueryStopRequest.ts +++ b/specification/esql/async_query_stop/AsyncQueryStopRequest.ts @@ -31,6 +31,12 @@ import { Id } from '@_types/common' * @ext_doc_id esql */ export interface Request extends RequestBase { + urls: [ + { + path: '/_query/async/{id}/stop' + methods: ['POST'] + } + ] path_parts: { /** * The unique identifier of the query. diff --git a/specification/indices/cancel_migrate_reindex/MigrateCancelReindexRequest.ts b/specification/indices/cancel_migrate_reindex/MigrateCancelReindexRequest.ts index 7a1f17dcf8..ab5d3fd207 100644 --- a/specification/indices/cancel_migrate_reindex/MigrateCancelReindexRequest.ts +++ b/specification/indices/cancel_migrate_reindex/MigrateCancelReindexRequest.ts @@ -31,6 +31,12 @@ import { Indices } from '@_types/common' * @doc_tag migration */ export interface Request extends RequestBase { + urls: [ + { + path: '/_migration/reindex/{index}/_cancel' + methods: ['POST'] + } + ] path_parts: { /** The index or data stream name */ index: Indices diff --git a/specification/indices/create_from/MigrateCreateFromRequest.ts b/specification/indices/create_from/MigrateCreateFromRequest.ts index 27a46ab5b3..5d8b2cfbda 100644 --- a/specification/indices/create_from/MigrateCreateFromRequest.ts +++ b/specification/indices/create_from/MigrateCreateFromRequest.ts @@ -33,6 +33,12 @@ import { IndexSettings } from '@indices/_types/IndexSettings' * @doc_tag migration */ export interface Request extends RequestBase { + urls: [ + { + path: '/_create_from/{source}/{dest}' + methods: ['PUT', 'POST'] + } + ] path_parts: { /** The source index or data stream name */ source: IndexName diff --git a/specification/indices/get_migrate_reindex_status/MigrateGetReindexStatusRequest.ts b/specification/indices/get_migrate_reindex_status/MigrateGetReindexStatusRequest.ts index 582de89597..6783803207 100644 --- a/specification/indices/get_migrate_reindex_status/MigrateGetReindexStatusRequest.ts +++ b/specification/indices/get_migrate_reindex_status/MigrateGetReindexStatusRequest.ts @@ -31,6 +31,12 @@ import { Indices } from '@_types/common' * @doc_tag migration */ export interface Request extends RequestBase { + urls: [ + { + path: '/_migration/reindex/{index}/_status' + methods: ['GET'] + } + ] path_parts: { /** The index or data stream name. */ index: Indices diff --git a/specification/indices/migrate_reindex/MigrateReindexRequest.ts b/specification/indices/migrate_reindex/MigrateReindexRequest.ts index dd9231ea88..3031840f15 100644 --- a/specification/indices/migrate_reindex/MigrateReindexRequest.ts +++ b/specification/indices/migrate_reindex/MigrateReindexRequest.ts @@ -32,6 +32,12 @@ import { IndexName } from '@_types/common' * @doc_tag migration */ export interface Request extends RequestBase { + urls: [ + { + path: '/_migration/reindex' + methods: ['POST'] + } + ] /** @codegen_name reindex */ body: MigrateReindex } diff --git a/specification/snapshot/repository_analyze/SnapshotAnalyzeRepositoryRequest.ts b/specification/snapshot/repository_analyze/SnapshotAnalyzeRepositoryRequest.ts index 377a6995e5..58fb95d6a2 100644 --- a/specification/snapshot/repository_analyze/SnapshotAnalyzeRepositoryRequest.ts +++ b/specification/snapshot/repository_analyze/SnapshotAnalyzeRepositoryRequest.ts @@ -133,6 +133,12 @@ import { Duration } from '@_types/Time' * @doc_id analyze-repository */ export interface Request extends RequestBase { + urls: [ + { + path: '/_snapshot/{repository}/_analyze' + methods: ['POST'] + } + ] path_parts: { /** * The name of the repository. diff --git a/validator/README.md b/validator/README.md index bfc886feb1..8e48e886fd 100644 --- a/validator/README.md +++ b/validator/README.md @@ -12,6 +12,7 @@ It is configured [in the specification directory](../specification/eslint.config | `no-native-types` | `Typescript native types not allowed, use aliases. | | `invalid-node-types` | The spec uses a subset of TypeScript, so some types, clauses and expressions are not allowed. | | `no-generic-number` | Generic `number` type is not allowed outside of `_types/Numeric.ts`. Use concrete numeric types like `integer`, `long`, `float`, `double`, etc. | +| `request-must-have-urls` | All Request interfaces extending `RequestBase` must have a `urls` property defining their endpoint paths and HTTP methods. | ## Usage diff --git a/validator/eslint-plugin-es-spec.js b/validator/eslint-plugin-es-spec.js index a823ed46ee..c56e8ace14 100644 --- a/validator/eslint-plugin-es-spec.js +++ b/validator/eslint-plugin-es-spec.js @@ -21,6 +21,7 @@ import dict from './rules/dictionary-key-is-string.js' import noNativeTypes from './rules/no-native-types.js' import invalidNodeTypes from './rules/invalid-node-types.js' import noGenericNumber from './rules/no-generic-number.js' +import requestMustHaveUrls from './rules/request-must-have-urls.js' export default { rules: { @@ -29,5 +30,6 @@ export default { 'no-native-types': noNativeTypes, 'invalid-node-types': invalidNodeTypes, 'no-generic-number': noGenericNumber, + 'request-must-have-urls': requestMustHaveUrls, } } diff --git a/validator/rules/request-must-have-urls.js b/validator/rules/request-must-have-urls.js new file mode 100644 index 0000000000..f2e624cdd5 --- /dev/null +++ b/validator/rules/request-must-have-urls.js @@ -0,0 +1,69 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { ESLintUtils } from '@typescript-eslint/utils'; + +const createRule = ESLintUtils.RuleCreator(name => `https://example.com/rule/${name}`) + +export default createRule({ + name: 'request-must-have-urls', + create(context) { + return { + TSInterfaceDeclaration(node) { + const extendsRequestBase = node.extends?.some( + (heritage) => + heritage.expression.type === 'Identifier' && + heritage.expression.name === 'RequestBase' + ); + + const isNamedRequest = node.id.name === 'Request'; + + if (!extendsRequestBase && !isNamedRequest) { + return; + } + + const hasUrls = node.body.body.some( + (member) => + member.type === 'TSPropertySignature' && + member.key.type === 'Identifier' && + member.key.name === 'urls' + ); + + if (!hasUrls) { + context.report({ + node, + messageId: 'missingUrls', + data: { + interfaceName: node.id.name, + }, + }); + } + }, + } + }, + meta: { + docs: { + description: 'All Request interfaces must have a urls property defining their endpoints', + }, + messages: { + missingUrls: 'Request interface "{{interfaceName}}" must have a urls property with endpoint paths and methods' + }, + type: 'problem', + }, + defaultOptions: [] +}) diff --git a/validator/test/request-must-have-urls.test.js b/validator/test/request-must-have-urls.test.js new file mode 100644 index 0000000000..f5b920fb1d --- /dev/null +++ b/validator/test/request-must-have-urls.test.js @@ -0,0 +1,163 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { RuleTester } from '@typescript-eslint/rule-tester'; +import rule from '../rules/request-must-have-urls.js'; + +const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + projectService: { + allowDefaultProject: ['*.ts*'], + defaultProject: 'tsconfig.json', + }, + tsconfigRootDir: new URL('../../specification/', import.meta.url).pathname, + }, + }, +}); + +ruleTester.run('request-must-have-urls', rule, { + valid: [ + { + name: 'Request with urls property', + code: ` + interface RequestBase {} + export interface Request extends RequestBase { + urls: [ + { + path: '/_search' + methods: ['GET', 'POST'] + } + ] + path_parts: {} + query_parameters: {} + body: {} + } + `, + }, + { + name: 'Request with multiple URL patterns', + code: ` + interface RequestBase {} + export interface Request extends RequestBase { + urls: [ + { + path: '/_snapshot/{repository}/{snapshot}' + methods: ['GET'] + }, + { + path: '/_snapshot/{repository}/_all' + methods: ['GET'] + } + ] + path_parts: { + repository: string + snapshot: string + } + query_parameters: {} + body: {} + } + `, + }, + { + name: 'Non-Request interface without urls', + code: ` + export interface ResponseBody { + took: number + timed_out: boolean + } + `, + }, + { + name: 'Response interface without urls', + code: ` + interface RequestBase {} + export interface Response { + body: { + result: string + } + } + `, + }, + ], + invalid: [ + { + name: 'Request without urls property', + code: ` + interface RequestBase {} + export interface Request extends RequestBase { + path_parts: {} + query_parameters: {} + body: {} + } + `, + errors: [ + { + messageId: 'missingUrls', + data: { + interfaceName: 'Request' + } + }, + ], + }, + { + name: 'Named Request without urls', + code: ` + export interface Request { + path_parts: { + index: string + } + body: { + query: any + } + } + `, + errors: [ + { + messageId: 'missingUrls', + data: { + interfaceName: 'Request' + } + }, + ], + }, + { + name: 'Request extending RequestBase without urls', + code: ` + interface RequestBase {} + export interface SearchRequest extends RequestBase { + path_parts: { + index: string + } + query_parameters: { + q: string + } + } + `, + errors: [ + { + messageId: 'missingUrls', + data: { + interfaceName: 'SearchRequest' + } + }, + ], + }, + ], +});