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

Adds typescript definitions to SDK #1189

Closed
wants to merge 7 commits into from
Closed

Adds typescript definitions to SDK #1189

wants to merge 7 commits into from

Conversation

chrisradek
Copy link
Contributor

This PR adds a new script that generates TypeScript Definition files for each service.

It also includes a number of d.ts files for classes that aren't dynamically generated at runtime.

If it helps with reviewing, I can upload a new PR that doesn't include all the d.ts files that are in the clients folder. These are the files generated by the script that will be automatically updated with each service update.

These typings were tested using TypeScript 2.0.x, which was recently released.

Developer tools, like vscode, will pick up the correct typings whether you require the whole sdk using require('aws-sdk') or if you require individual services using require('aws-sdk/clients/s3').

I will also work on updating the README to describe how to use these typings.

/cc @LiuJoyceC

@chrisradek
Copy link
Contributor Author

Updated the global.d.ts and index.d.ts files.
Using Object.assign was causing the typings to only work when targeting ES6, now these will also work when targeting ES5.

The index.d.ts file also exports the typings as a global namespace, AWS, so that browser SDK users can get typing support as well.

*/
resendValidationEmail(callback?: (err: AWSError, data: {}) => void): Request<{}, AWSError>;
}
declare namespace ACM {
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a reason the service model's shapes need to be on this namespace? When I type AWS.ACM., I don't think my code completion tool should try to complete AddTagsToCertificateRequest or CertificateBody, since these aren't actually valid properties on AWS.ACM. Instead, this namespace should contain apiVersions, serviceIdentifier, and services (unless these are private, in which case just don't have an ACM namespace). The shapes can live in a separate namespace that isn't exported, and you just have to change the references above. I've tested this by changing this namespace name to be Shapes (and changing references above), and I still get the correct code completion suggestions when I'm creating my params in a request, and I no longer get shape name suggestions when I type AWS.ACM..

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I wanted to export all the interfaces so that users could cast a result if they needed to, but I'm not sure if that's necessary. I'll have to take a look at some other TypeScript libraries to see if that's common practice.

You should only see these interfaces when looking at the service client constructors, but not on the service client instances.

Copy link
Contributor

Choose a reason for hiding this comment

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

Right, it would only be on the constructor, but there are valid reasons a customer may want to look at the properties of the constructor such as looking up what apiVersions are available for a service, or for the case of S3 and DynamoDB, they want to access the ManagedUpload or DocumentClient. When they try to access these, the constructors will autocomplete with a large number of properties that aren't real and make it difficult to find the actual property they are trying to access.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So, I tried a few different things. Ultimately, I went with putting the exported types in a sub-namespace:
declare namespace SERVICE.Types {
When I put them on their own namespace, I had to explicitly import them into my app, otherwise the typescript compiler would complain:
microsoft/TypeScript#9944

I also wanted them to be exported so user's can specify a type for cases when the typescript compiler can't quite infer what a type should be. That might not be necessary once microsoft/TypeScript#6606 is addressed.

import {AWSError} from '../lib/error';
import {Service} from '../lib/service';
declare class ACM extends Service {
/**
Copy link
Contributor

@LiuJoyceC LiuJoyceC Oct 25, 2016

Choose a reason for hiding this comment

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

This might not be a must-have, but it would be nice to have code completion work when I'm binding params to a service client on construction.
One way you could do this is have a constructor such as:
constructor(options?: ServiceConfigurationOptions & ServiceParams)
And then near the bottom of the file you can define:

interface ServiceParams {
    params?: ACM.AddTagsToCertificateRequest &
                    ACM.DeleteCertificateRequest &
                    ...
}

Since the TSGenerator has to iterate through every operation input shape anyway, it could just keep track of a list of all of the input shape names.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's an interesting idea. I don't think this will work for S3, or services that have custom config options (anything with dualstack, just S3 for now) if we stop exporting the service interfaces though. I'll take a look.

Copy link
Contributor

@LiuJoyceC LiuJoyceC Oct 25, 2016

Choose a reason for hiding this comment

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

Well, for S3, since you currently have
constructor(options?: ServiceConfigurationOptions & UseDualstackConfigOptions),
couldn't that just be changed to
constructor(options?: ServiceConfigurationOptions & UseDualstackConfigOptions & ServiceParams)?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, but I'd have to import ServiceParams from somewhere, presumably the base S3 class.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think the ServiceParams should be defined in the same file as where the service model shapes are defined (in the clients/#{service}.d.ts file), since they are specific to each service and they will reuse the model shapes definitions already in the file. And that would be the same file that the constructor definition for that service class is defined, too. For S3, it would be the same (the constructor definition is already in the same file as the model shapes definitions). The only exceptions would be any custom params (for example, partSize in the upload operation), which could be imported from the service customizations file (the S3Customizations class is already being imported from there anyway).

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, as a modification to what I said above, instead of merging together all of the input shapes, it would be better if each potential param is explicitly listed, since they are all optional here, and we don't want to keep the requirements of the input params. Most services reuse most of their input params, so for many services the list of possible params may actually be shorter than the list of input shapes. So instead of what I have above, it could be more like:

interface ServiceParams {
    params?: ACM.AllInputParams
}

and inside the ACM namespace have:

interface AllInputParams {
    CertificateArn?: Arn;
    Tags?: TagList;
    CertificateStatuses?: CertificateStatuses;
    ...
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It looks like I'll have to create a custom shape, since only top-level params are actually allowed:
https://github.com/aws/aws-sdk-js/blob/master/lib/service.js#L163

import {AWSError} from '../lib/error';
import {Service} from '../lib/service';
declare class ACM extends Service {
/**
Copy link
Contributor

Choose a reason for hiding this comment

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

Another reason to have a constructor definition for each service class is to allow code completion for API version dates (by defining the optional apiVersion as an enum in ServiceParams), especially if we want to encourage users to specify API versions when constructing the service clients.

* Determines if a member is required by checking for it in a list.
*/
TSGenerator.prototype.checkRequired = function checkRequired(list, member) {
if (list.indexOf(member)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

if (list.indexOf(member) !== -1)

Copy link
Contributor

Choose a reason for hiding this comment

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

Otherwise it's always returning true if the member isn't actually required, and it's returning false if the member happens to be the first one in the list

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks, good catch!

if (member.documentation) {
code += self.generateDocString(member.documentation, tabCount + 1);
}
var required = self.checkRequired(shape.required || [], memberKey) ? '?' : '';
Copy link
Contributor

Choose a reason for hiding this comment

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

If checkRequired is supposed to return true when the member is required, then this should be switched.
self.checkRequired(shape.required || [], memberKey) ? '' : '?'

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks, didn't catch this due to the mistake above.

} else if (['double', 'long', 'short', 'biginteger', 'bigdecimal', 'integer', 'float'].indexOf(type) >= 0) {
code += tabs(tabCount) + 'export type ' + shapeKey + ' = number;\n';
} else if (type === 'timestamp') {
code += tabs(tabCount) + 'export type ' + shapeKey + ' = Date;\n';
Copy link
Contributor

@LiuJoyceC LiuJoyceC Oct 26, 2016

Choose a reason for hiding this comment

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

Perhaps timestamp should be Date|string|number? When the SDK validates input params, these 3 types are valid for timestamps, so we don't want to raise a type error if the user is using a valid type. For timestamps in the output, it often is returned as a string, and an error should be raised if someone tries to invoke a Date method on it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

What's an example of a timestamp in the output that is returned as a string, instead of a Date?
If the shape is modeled as a timestamp, and the value is a string or number, we call util.date.parseTimestamp, and parseTimestamp always returns a Date object, or throws an error.

Fair point about the above for inputs though.

Copy link
Contributor

Choose a reason for hiding this comment

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

If an output field modeled as a timestamp is sourced from the response header rather than the body, then it remains as a string in the output data. For example, in S3, the LastModified shape is modeled as a timestamp. In the output of getObject and headObject, the LastModified field is sourced from the header rather than the body, so the value is a string. In s3.d.ts, LastModified is typed as a Date, which would be incorrect for the output of these two operations. In some other operations such as listObjects or copyObjects, the LastModified field (modeled by the same LastModified shape) is sourced from the response body and the value is converted to a Date object.

TSGenerator.prototype.generateTypingsFromShape = function generateTypingsFromShape(shapeKey, shape, tabCount) {
// some shapes shouldn't be generated if they are javascript primitives
if (['string', 'boolean', 'number', 'Date', 'Blob'].indexOf(shapeKey) >= 0) {
return '';
Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of skipping shapes that happen to be named these, we could just add an underscore in front to differentiate the name of the shape from the javascript primitives.
It could still be useful to generate a typing for these shapes. For example, some services (like CodeCommit) have a shape named "Date" that is modeled as a string rather than a timestamp. If we rename the shape rather than skip it and have it be typed as a JavaScript Date object, then it could prevent errors before runtime, such as calling a Date method like getMonth() on the string.

Copy link
Contributor

Choose a reason for hiding this comment

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

So for example, the typing could look like:

export type _Date = string;

And the references to this shape would have to have an underscore added in front as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fair point, thanks for finding that.

code += tabs(tabCount) + 'export type ' + shapeKey + ' = ' + shape.member.shape + '[];\n';
} else if (type === 'map') {
code += tabs(tabCount) + 'export type ' + shapeKey + ' = {[key: string]: ' + shape.value.shape + '};\n';
} else if (type === 'string' || type === 'character') {
Copy link
Contributor

@LiuJoyceC LiuJoyceC Oct 26, 2016

Choose a reason for hiding this comment

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

It would be helpful to support enums.
(after checking for complex types and before starting to check primitive types, so between map and string):

else if (Array.isArray(shape.enum)) {
    code += tabs(tabCount) + 'export type ' + shapeKey + ' = ' + shape.enum.map(JSON.stringify).join('|') + ';\n';
}

So for example, Lambda's Runtime declaration would look like:

export type Runtime = "nodejs"|"nodejs4.3"|"java8"|"python2.7";

instead of just a generic string

export type Runtime = string;

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agreed, I had meant to get to this but hadn't yet. I think we should also still add the primitive type (string) as well, so that users can specify new enums as they become available without having to update the SDK.

Copy link
Contributor

Choose a reason for hiding this comment

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

So do you mean like this?

export type Runtime = "nodejs"|"nodejs4.3"|"java8"|"python2.7"|string;

If our main goal is for this to be used for code completion, then this is fine. However, if a user wants to catch compile-time errors, having the enum without the catch-all string type could be really useful in catching typos.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, that's exactly what it'd look like.

I don't want the user to have to update the SDK to use a new enum. If Lambda added support for nodejs5, and we didn't specify string as an allowable type, they would have to update their SDK to the latest version or else get compilation errors. Today with JavaScript, they could just continue using their current version of the SDK.

code += this.generateDocString(operation.documentation, tabCount);
code += tabs(tabCount) + operationName + '(params: ' + inputShape + ', callback?: (err: AWSError, data: ' + outputShape + ') => void): Request<' + outputShape + ', AWSError>;\n';
code += this.generateDocString(operation.documentation, tabCount);
code += tabs(tabCount) + operationName + '(callback?: (err: AWSError, data: ' + outputShape + ') => void): Request<' + outputShape + ', AWSError>;\n';
Copy link
Contributor

Choose a reason for hiding this comment

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

Perhaps we could check whether the input has any required params, and if it has at least one, then don't generate this second second declaration where params is optional.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If the required params were specified in the service-level config though, then they wouldn't be required here, so the 2nd option could still be valid.

Copy link
Contributor

Choose a reason for hiding this comment

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

Well, we have that problem anyway if the user defines required params in the service-level config but has additional parameters. Perhaps we should just make all input params be optional?

tabCount = tabCount || 0;
var operationName = waiter.operation.charAt(0).toLowerCase() + waiter.operation.substring(1);

var docString = 'Waits for the ' + waiterState + ' state by periodically calling the underlying ' + className + '.' + operationName + 'operation every ' + waiter.delay + ' seconds (at most ' + waiter.maxAttempts + ' times).';
Copy link
Contributor

Choose a reason for hiding this comment

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

Add a space before the word 'operation'.

/**
* Generates class method types for a waiter.
*/
TSGenerator.prototype.generateTypingsFromWaiters = function generateTypingsFromWaiters(className, waiterState, waiter, underlyingOperation, tabCount) {
Copy link
Contributor

Choose a reason for hiding this comment

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

waiterState needs to have its first letter lowercased. For example, using the state "BucketExists" will result in an error, but "bucketExists" is correct.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch!

code += this.generateDocString(docString, tabCount);
code += this.tabs(tabCount) + 'waitFor(state: "' + waiterState + '", params: ' + inputShape + ', callback?: (err: AWSError, data: ' + outputShape + ') => void): Request<' + outputShape + ', AWSError>;\n';
code += this.generateDocString(docString, tabCount);
code += this.tabs(tabCount) + 'waitFor(state: "' + waiterState + '", callback?: (err: AWSError, data: ' + outputShape + ') => void): Request<' + outputShape + ', AWSError>;\n';
Copy link
Contributor

Choose a reason for hiding this comment

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

Same comment as above about required params. If the underlying operation has any required params, that perhaps we shouldn't generate this second typing where params is optional.

@chrisradek
Copy link
Contributor Author

Posting my sample tsconfig.json file here:

{
    "compilerOptions": {
        "target": "es5",
        "module": "commonjs",
        "outDir": "dist",
        "declarationDir": "dist",
        "declaration": true,
        "lib": [
            "es5",
            "es2015.promise",
            "dom"
        ]
    },
    "include": [
        "src/**/*"
    ]
}

Assumes that .ts files are located under a src directory.

* @param {string} keyPairId - The ID of the CloudFront key pair being used.
* @param {string} privateKey - A private key in RSA format.
*/
constructor(keyPaidId: string, privateKey: string);
Copy link
Contributor

Choose a reason for hiding this comment

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

typo: keyPairId instead of keyPaidId

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Nice catch!

@@ -0,0 +1,268 @@
/// <reference types="node" />

import * as http from 'http';
Copy link
Contributor

Choose a reason for hiding this comment

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

Would doing import {Agent as httpAgent} from 'http'; and import {Agent as httpsAgent} from 'https'; make a difference in how much typings data is loaded into memory by Intellisense or by the typescript compiler? I've seen complaints about Intellisense slowing down VS Code or the typescript compiler being too slow when declaration files are large. Since the only typings we need are for Agent from both modules, it seems unnecessary to load all of the typings from those modules into memory.

import * as https from 'https';
import {AWSError} from './error';
import {Credentials} from './credentials';
export class Config {
Copy link
Contributor

Choose a reason for hiding this comment

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

The Config class also needs to have each service identifier as an optional instance variable. Otherwise the compiler will complain if a user tries to define service-specific parameters and options:

AWS.config.s3 = {params: {Bucket: 'myBucket'}, useDualstack: true};

*/
customBackoff?: (retryCount: number) => number
}
interface ConfigurationOptions {
Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of maintaining two separate but identical lists of these config options plus comments (here and above in the Config class), perhaps we could define this as an abstract class rather than an interface, and then the Config class could extend this abstract class.
Another solution could be to generate this file (which may be a good idea anyway since we need to include all of the service identifiers in the Config class as mentioned above). However, we would then need to maintain the comments for each of these configuration options in another file, since I noticed these comments aren't exactly identical to the documentation in the config.js file.

…rs. Fixes required inputs checks. Adds support for string enum types. Fixes issue where some service operations included the apiversion in the name.
…ves service interfaces to [ClassName].Types namespace.
@chrisradek
Copy link
Contributor Author

Closed by #1218

@chrisradek chrisradek closed this Nov 10, 2016
@chrisradek chrisradek deleted the typings branch November 10, 2016 20:37
@lock
Copy link

lock bot commented Sep 28, 2019

This thread has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs and link to relevant comments in this thread.

@lock lock bot locked as resolved and limited conversation to collaborators Sep 28, 2019
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants