Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added Request/Response types definitions #970

Closed
wants to merge 19 commits into from
Closed

Conversation

delvedor
Copy link
Member

@delvedor delvedor commented Sep 18, 2019

With this pr we introduce the response type definitions for the following APIs:

  • Index
  • Create
  • Update
  • Delete
  • Search
  • MSearch
  • Bulk

For now, we will not support more APIs because we are studying a more structured approach to be able to generate all the response definitions automatically.

Closes: #933

total: {
value: number;
relation: string;
};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This type is only correct when the server is running Elasticsearch 7.0 and above, and only when the request did not set the property rest_total_hits_as_int:

rest_total_hits_as_int
    (Optional, boolean) Indicates whether hits.total should be rendered as an integer or an object in the rest search response. Defaults to false.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now, this type definitions will land only for 7.x and above, so it shouldn't be an issue.

and only when the request did not set the property rest_total_hits_as_int:

How would you solve this? The easiest solutions that come to my mind is to use a union, in the same way I did in BulkItems.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, just for general awareness, I'd recommend looking at a discriminated union feature.

Anyway, what looks simpler to me is just using two separate overloads:

// Don't export this one, because it is likely to be removed when the legacy interface is removed
interface BasicSearchResponse {
    /* all stable fields */
}

export interface SearchResponse extends BasicSearchResponse {
    hits: {
        total: {
            value: number,
            relation: "gte" | "eq" // @delvedor I suggest using an explicit string literal union here instead of just a `string`
        }
    }
}

// legacy, thus the interface name is verbose:
export interface SearchResponseWithTotalHintsAsInt extends BasicSearchResponse {
    hits: { total: number }
}

// don't export it for the same reason as BasicSearchResponse
interface BasicSearchParams { /* all fields except for rest_total_hits_as_int */ }

export interface SearchParams extends BasicSearchParams {
    rest_total_hits_as_int?: false;
}

export interface SearchParamsWithTotalHintsAsInt extends BasicSearchParams {
    rest_total_hits_as_int: true;
}

export class Client {
    search(params: SearchParams): Promise<SearchResponse>;
    // @deprecated: please use other overload
    search(params: SearchParamsWithTotalHintsAsInt): Promise<SearchResponseWithTotalHintsAsInt>;
}

api/ResponseTypes.ts Outdated Show resolved Hide resolved
@lesyk-lesyk
Copy link

@delvedor Any updates on this PR?

Should I define SearchResponse / Explanation / ShardsResponse etc in my code like describe in documentation here?
https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/6.x/typescript.html

Or should I wait for adding these types directly in the Elastic package?

Copy link
Member Author

@delvedor delvedor left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello everyone! After #1119 and #1132 I finally start working again on this proposal.
I've just pushed a few changes that can be summarized as follows.

We have two files that contain all we know about the Elasticsearch's API, which can be found in api/RequestTypes.d.ts and api/ResponseTypes.d.ts. The first one is generated from the rest-api-spec and provides every parameter aside from the body, which can be defined with a generic.
The response types at the moment are maintained by hand, so we can't expose all of them right now. We decided to start with the most commonly used APIs, which are already listed in the pr description. The code generation is aware of this list and will use the definition we have instead of a generic object definition.
All those types will be exposed through the client, for example:

import { Client, SearchRequest, SearchResponse } from '@elastic/elasticsearch'

By default, all APIs that have a dynamic element inside, such as Search, will accept a generic that defaults to unknown, if you want to give it a specific definition, you have two ways:

import { Client, SearchResponse } from '@elastic/elasticsearch'

interface Source {
  foo: string
}

// without a generic
const response = await client.search({
  index: 'test',
  body: {
    query: {
      match: { foo: 'bar' }
    }
  }
})

const source = response.body.hits.hits[0]._source as Source

// override the default generic
const response = await client.search<SearchResponse<Source>>({
  index: 'test',
  body: {
    query: {
      match: { foo: 'bar' }
    }
  }
})

const source = response.body.hits.hits[0]._source // this will be Source

Feedback is welcome!
cc @restrry @wylieconlon @joshdover @dgieselaar @nreese @spalger

relation: string
}
max_score: number
hits: Array<{
Copy link

@Veetaha Veetaha Apr 7, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@delvedor I'd suggest extracting this inline object literal type into an interface. I in my code usually use not only the Source type, but the whole hit type because there is an id in it I'd like to use:

interface Doc<TSource> {
    _id: string;
    _source: TSource;
}

If we don't extract this into an interface we won't be able to write the following code:

interface MyDocSource { foo: number }
// The type of hit should be Doc<MyDocSource> but we don't have the Doc type as per this PR right now
function processHit(hit: ??) {
    console.log("doc with id", hit._id, "has foo:", hit._source.foo);
}

const res = await client.search<MyDocSource>({ ... });

// notice that we cannot annotate the docHit param type with an interface name here too
res.body.hits.hits.forEach(docHit => processHit(docHit))

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 here. I hit this problem a couple of days ago. An example from Kibana codebase:
https://github.com/elastic/kibana/blob/master/x-pack/plugins/logstash/server/types.ts#L11-L12

@Veetaha
Copy link

Veetaha commented Apr 7, 2020

@delvedor won't it be error-prone if the user can fully override the response type here?

client.msearch<MSearchResponse<Source>>(...);

The user can very easily mistakenly write an incorrect response type:

// notice the `M` prefix is mistakenly omitted
client.msearch<SearchResponse<Source>>(...);

My gut feeling is that we should pass not the response type as a whole, but only the generic parameters of that response type if there are any:

// Now this is way less verbose and less error-prone
client.msearch<Source>(...);

@dgieselaar
Copy link
Member

@delvedor looks good to me, this should allow us to tack our types on top of those of the client.

hits: {
total: {
value: number
relation: string
Copy link

@Veetaha Veetaha Apr 7, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
relation: string
relation: "eq" | "gte"

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm surprised that we are able to generate request types from the API spec, but no responses. https://github.com/elastic/elasticsearch/blob/d7bbc9df1d3edf47f8d1c2f0983c88c64d78a4b4/rest-api-spec/src/main/resources/rest-api-spec/api/search.json#L231
@delvedor Do you know if the elasticesearch team is going to introduce a spec for API responses as well?

master_timeout?: string;
}

export {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the rationale for using this export style?

Copy link
Member Author

@delvedor delvedor Apr 7, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As you can see in the index.d.ts, I'm exporting all the request/response type definitions with

export * from './api/RequestTypes'
export * from './api/ResponseTypes'

For some reason that I cannot fully understand, if I use the export style you are suggesting, I was able to also use interfaces that were not exported.
For example:

/* ResponseTypes.d.ts */
export interface Foo {}
interface Bar {}

/* index.d.ts */
export * from './api/ResponseTypes.d.ts'
export { Client }

/* code.ts */
// here I should be able to import Bar, but I can
import { Client, Foo, Bar } from './'

Then I discovered that using the "single export" statement solves this quirk.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, this is indeed a quick of .d.ts. files, all their symbols are exported by default. However, I think there should not be any private interfaces in .d.ts files, users should be able to access any interface that the can potentially use in their code, e.g. why not exporting interface Shards?
The user might want to write the following code:

const res = await client.update(...);
const shards: Shards = res._shards;
processShards(shards);

// Why not giving the possibility to use the Shards type?
function processShards(shards: Shards) {}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As you know, the API surface of Elasticsearch is massive, and given that we are doing this work manually, it's very easy to miss something.
There might be a ShardsResponse that is different from Shards (I don't think this is the case, but it might happen), and I would love to avoid doing too many breaking changes because I wasn't cautious about what I'm exposing.
Adding these response types will be a very long work that will be done during time, and a big part of it will be fix and improve the types already written.
Thank you for your suggestions!

Comment on lines +75 to +82
interface UpdateResponse {
_shards: Shards
_index: string
_type: string
_id: string
_version: number
result: string
}
Copy link

@Veetaha Veetaha Apr 7, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Elasticsearch 7 supports returning the document source in update requests, e.g. the following request

POST /<index>/_update/<id>
{
  "_source": "*",
  "doc": {
    "field": "val"
  }
}

gives the following response

{
  "_index" : "<index>",
  "_type" : "_doc",
  "_id" : "<id>",
  "_version" : 5,
  "result" : "updated",
  "_shards" : {
    "total" : 2,
    "successful" : 1,
    "failed" : 0
  },
  "_seq_no" : 53,
  "_primary_term" : 1,
  "get" : {
    "_seq_no" : 53,
    "_primary_term" : 1,
    "found" : true,
    "_source" : {
      "field": "val"
    }
  }
}

So the user should be able to specify the type of that _source (furthermore this get object should be an optional property in UpdateResponse<T>.

@delvedor also note that the shards object I got from Elasticsearch doesn't contain skipped field, this is either a bug or an intended behavior, so if the latter we should mark it as optional: interface Shards { skipped?: number }

Also note that there are two more fields:

{
  "_seq_no" : 53,
  "_primary_term" : 1
}

I think they also should be included in interface UpdateResponse

@delvedor
Copy link
Member Author

delvedor commented Apr 7, 2020

@delvedor won't it be error-prone if the user can fully override the response type here?

@Veetaha it's something that could happen, yes. The main issue right now is that we don't have all the response types, and while we are working on it, this is not something that will change soon.
I fear that if some APIs offer you a way to define the full body response, others allow you to specify a part of it, and others do not provide anything at all, what you will get at the end is a massive source of confusion. Using the same approach for every API until we have a stable enough type definition provides a predictable API, that is easy to think about and do not require you to go and read the documentation/type definitions every time you are writing a line of code.

@@ -36,6 +37,11 @@ export interface Generic {
`

api.forEach(generateRequestType)
types += '\nexport {\n'
api.forEach(generateExport)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. the line above and the line below belong to generateExport operation.
  2. IMO it's not obvious that generateRequestType & generateExport mutate types variable. I'd refactor the function to follow Transform pattern

function generateExport (spec) {
const api = Object.keys(spec)[0]
const name = api
.replace(/\.([a-z])/g, k => k[1].toUpperCase())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We repeat the same operation across several generate utils. I'm wondering if we can normalize data once in a centralized manner.

hits: {
total: {
value: number
relation: string
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm surprised that we are able to generate request types from the API spec, but no responses. https://github.com/elastic/elasticsearch/blob/d7bbc9df1d3edf47f8d1c2f0983c88c64d78a4b4/rest-api-spec/src/main/resources/rest-api-spec/api/search.json#L231
@delvedor Do you know if the elasticesearch team is going to introduce a spec for API responses as well?

@@ -181,36 +192,37 @@ function buildMethodDefinition (api, name, hasBody) {
const Name = toPascalCase(name)
const bodyType = ndjsonApiKey.includes(Name) ? 'RequestNDBody' : 'RequestBody'
const defaultBodyType = ndjsonApiKey.includes(Name) ? 'Record<string, any>[]' : 'Record<string, any>'
const responseType = supportedResponses.includes(Name) ? `Res.${Name}Response` : 'Record<string, any>'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optional: why not export responses used Response namespace?

details: Explanation[]
}

interface MSearchResponse<TSource = unknown, TAggregations = unknown> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't see it's used in index.d.ts file

@@ -1311,7 +1311,7 @@ export interface Termvectors<T = RequestBody> extends Generic {
body?: T;
}

export interface Update<T = RequestBody> extends Generic {
interface UpdateRequest<T = RequestBody> extends GenericRequest {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interestingly, we use RequestBody as a default value here, but Record<string, any> in index.d.ts.

@villasv
Copy link
Contributor

villasv commented Apr 30, 2020

Just wanted to chime in once more and thank @delvedor for the hard work on this, once released I'm going to slap this into our codebase like it's Flex Tape.

@delvedor delvedor changed the title Added Response types definitions Added Request/Response types definitions Jul 20, 2020
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

interface SearchResponse<TSource = unknown, TAggregations = unknown> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not meant to be used in api/kibana.d.ts?

@delvedor
Copy link
Member Author

Closed in favor of #1358.

@delvedor delvedor closed this Mar 16, 2021
@delvedor delvedor deleted the add-response-types branch August 11, 2021 12:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Define and export type SearchResponse
7 participants