Skip to content

Commit 7684950

Browse files
committed
feat: support @httpApiKeyAuth trait
This adds codegen support for services and operations that use the @httpApiKeyAuth trait. The generated client will have a new `apiKey` config attribute. This value will be injected into the header that's specified in the trait by new middleware that's included in the codegen client.
1 parent d263078 commit 7684950

File tree

9 files changed

+744
-1
lines changed

9 files changed

+744
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/*
2+
* Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
package software.amazon.smithy.typescript.codegen.integration;
17+
18+
import java.nio.file.Paths;
19+
import java.util.HashMap;
20+
import java.util.List;
21+
import java.util.Set;
22+
import java.util.function.BiConsumer;
23+
import java.util.function.Consumer;
24+
import software.amazon.smithy.codegen.core.Symbol;
25+
import software.amazon.smithy.codegen.core.SymbolProvider;
26+
import software.amazon.smithy.model.Model;
27+
import software.amazon.smithy.model.knowledge.ServiceIndex;
28+
import software.amazon.smithy.model.knowledge.TopDownIndex;
29+
import software.amazon.smithy.model.shapes.OperationShape;
30+
import software.amazon.smithy.model.shapes.ServiceShape;
31+
import software.amazon.smithy.model.traits.HttpApiKeyAuthTrait;
32+
import software.amazon.smithy.model.traits.OptionalAuthTrait;
33+
import software.amazon.smithy.typescript.codegen.CodegenUtils;
34+
import software.amazon.smithy.typescript.codegen.TypeScriptSettings;
35+
import software.amazon.smithy.typescript.codegen.TypeScriptWriter;
36+
import software.amazon.smithy.utils.IoUtils;
37+
import software.amazon.smithy.utils.ListUtils;
38+
import software.amazon.smithy.utils.SmithyInternalApi;
39+
40+
/**
41+
* Add config and middleware to support a service with the @httpApiKeyAuth trait.
42+
*/
43+
@SmithyInternalApi
44+
public final class AddHttpApiKeyAuthPlugin implements TypeScriptIntegration {
45+
46+
/**
47+
* Plug into code generation for the client.
48+
*
49+
* This adds the configuration items to the client config and plugs in the
50+
* middleware to operations that need it.
51+
*
52+
* The middleware will inject the client's configured API key into the
53+
* request as defined by the @httpApiKeyAuth trait. If the trait says to
54+
* put the API key into a named header, that header will be used, optionally
55+
* prefixed with a scheme. If the trait says to put the API key into a named
56+
* query parameter, that query parameter will be used.
57+
*/
58+
@Override
59+
public List<RuntimeClientPlugin> getClientPlugins() {
60+
return ListUtils.of(
61+
// Add the config if the service uses HTTP API key authorization.
62+
RuntimeClientPlugin.builder()
63+
.inputConfig(Symbol.builder()
64+
.namespace("./" + CodegenUtils.SOURCE_FOLDER + "/middleware/HttpApiKeyAuth", "/")
65+
.name("HttpApiKeyAuthInputConfig")
66+
.build())
67+
.resolvedConfig(Symbol.builder()
68+
.namespace("./" + CodegenUtils.SOURCE_FOLDER + "/middleware/HttpApiKeyAuth", "/")
69+
.name("HttpApiKeyAuthResolvedConfig")
70+
.build())
71+
.resolveFunction(Symbol.builder()
72+
.namespace("./" + CodegenUtils.SOURCE_FOLDER + "/middleware/HttpApiKeyAuth", "/")
73+
.name("resolveHttpApiKeyAuthConfig")
74+
.build())
75+
.servicePredicate((m, s) -> hasEffectiveHttpApiKeyAuthTrait(m, s))
76+
.build(),
77+
78+
// Add the middleware to operations that use HTTP API key authorization.
79+
RuntimeClientPlugin.builder()
80+
.pluginFunction(Symbol.builder()
81+
.namespace("./" + CodegenUtils.SOURCE_FOLDER + "/middleware/HttpApiKeyAuth", "/")
82+
.name("getHttpApiKeyAuthPlugin")
83+
.build())
84+
.additionalPluginFunctionParamsSupplier((m, s, o) -> new HashMap<String, Object>() {{
85+
// It's safe to do expectTrait() because the operation predicate ensures that the trait
86+
// exists `in` and `name` are required attributes of the trait, `scheme` is optional.
87+
put("in", s.expectTrait(HttpApiKeyAuthTrait.class).getIn().toString());
88+
put("name", s.expectTrait(HttpApiKeyAuthTrait.class).getName());
89+
s.expectTrait(HttpApiKeyAuthTrait.class).getScheme().ifPresent(scheme ->
90+
put("scheme", scheme));
91+
}})
92+
.operationPredicate((m, s, o) -> ServiceIndex.of(m).getEffectiveAuthSchemes(s, o)
93+
.keySet()
94+
.contains(HttpApiKeyAuthTrait.ID)
95+
&& !o.hasTrait(OptionalAuthTrait.class))
96+
.build()
97+
);
98+
}
99+
100+
@Override
101+
public void writeAdditionalFiles(
102+
TypeScriptSettings settings,
103+
Model model,
104+
SymbolProvider symbolProvider,
105+
BiConsumer<String, Consumer<TypeScriptWriter>> writerFactory
106+
) {
107+
ServiceShape service = settings.getService(model);
108+
109+
// If the service doesn't use HTTP API keys, we don't need to do anything and the generated
110+
// code doesn't need any additional files.
111+
if (!hasEffectiveHttpApiKeyAuthTrait(model, service)) {
112+
return;
113+
}
114+
115+
String noTouchNoticePrefix = "// Please do not touch this file. It's generated from a template in:\n"
116+
+ "// https://github.com/awslabs/smithy-typescript/blob/main/smithy-typescript-codegen/"
117+
+ "src/main/resources/software/amazon/smithy/aws/typescript/codegen/integration/";
118+
119+
// Write the middleware source.
120+
writerFactory.accept(
121+
Paths.get(CodegenUtils.SOURCE_FOLDER, "middleware", "HttpApiKeyAuth", "index.ts").toString(),
122+
writer -> {
123+
String source = IoUtils.readUtf8Resource(getClass(), "http-api-key-auth.ts");
124+
writer.write("$L$L", noTouchNoticePrefix, "http-api-key-auth.ts");
125+
writer.write("$L", source);
126+
});
127+
128+
// Write the middleware tests.
129+
writerFactory.accept(
130+
Paths.get(CodegenUtils.SOURCE_FOLDER, "middleware", "HttpApiKeyAuth", "index.spec.ts").toString(),
131+
writer -> {
132+
String source = IoUtils.readUtf8Resource(getClass(), "http-api-key-auth.spec.ts");
133+
writer.write("$L$L", noTouchNoticePrefix, "http-api-key-auth.spec.ts");
134+
writer.write("$L", source);
135+
});
136+
}
137+
138+
// The service has the effective trait if it's in the "effective auth schemes" response
139+
// AND if not all of the operations have the optional auth trait.
140+
private static boolean hasEffectiveHttpApiKeyAuthTrait(Model model, ServiceShape service) {
141+
return ServiceIndex.of(model).getEffectiveAuthSchemes(service)
142+
.keySet()
143+
.contains(HttpApiKeyAuthTrait.ID)
144+
&& !areAllOptionalAuthOperations(model, service);
145+
}
146+
147+
148+
// This is derived from https://github.com/aws/aws-sdk-js-v3/blob/main/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/AddAwsAuthPlugin.java.
149+
private static boolean areAllOptionalAuthOperations(Model model, ServiceShape service) {
150+
TopDownIndex topDownIndex = TopDownIndex.of(model);
151+
Set<OperationShape> operations = topDownIndex.getContainedOperations(service);
152+
ServiceIndex index = ServiceIndex.of(model);
153+
154+
for (OperationShape operation : operations) {
155+
if (index.getEffectiveAuthSchemes(service, operation).isEmpty()
156+
|| !operation.hasTrait(OptionalAuthTrait.class)) {
157+
return false;
158+
}
159+
}
160+
return true;
161+
}
162+
}
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
software.amazon.smithy.typescript.codegen.integration.AddEventStreamDependency
2-
software.amazon.smithy.typescript.codegen.integration.AddChecksumRequiredDependency
2+
software.amazon.smithy.typescript.codegen.integration.AddChecksumRequiredDependency
3+
software.amazon.smithy.typescript.codegen.integration.AddHttpApiKeyAuthPlugin
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import {
2+
getHttpApiKeyAuthPlugin,
3+
httpApiKeyAuthMiddleware,
4+
resolveHttpApiKeyAuthConfig,
5+
} from "./index";
6+
import { HttpRequest } from "@aws-sdk/protocol-http";
7+
8+
describe("resolveHttpApiKeyAuthConfig", () => {
9+
it("should return the input unchanged", () => {
10+
const config = {
11+
apiKey: "exampleApiKey",
12+
};
13+
expect(resolveHttpApiKeyAuthConfig(config)).toEqual(config);
14+
});
15+
});
16+
17+
describe("getHttpApiKeyAuthPlugin", () => {
18+
it("should apply the middleware to the stack", () => {
19+
const plugin = getHttpApiKeyAuthPlugin(
20+
{
21+
apiKey: "exampleApiKey",
22+
},
23+
{
24+
in: "query",
25+
name: "key",
26+
}
27+
);
28+
29+
const mockAdd = jest.fn();
30+
const mockOther = jest.fn();
31+
32+
// TODO there's got to be a better way to do this mocking
33+
plugin.applyToStack({
34+
add: mockAdd,
35+
// We don't expect any of these others to be called.
36+
addRelativeTo: mockOther,
37+
concat: mockOther,
38+
resolve: mockOther,
39+
applyToStack: mockOther,
40+
use: mockOther,
41+
clone: mockOther,
42+
remove: mockOther,
43+
removeByTag: mockOther,
44+
});
45+
46+
expect(mockAdd.mock.calls.length).toEqual(1);
47+
expect(mockOther.mock.calls.length).toEqual(0);
48+
});
49+
});
50+
51+
describe("httpApiKeyAuthMiddleware", () => {
52+
describe("returned middleware function", () => {
53+
const mockNextHandler = jest.fn();
54+
55+
beforeEach(() => {
56+
jest.clearAllMocks();
57+
});
58+
59+
it("should set the query parameter if the location is `query`", async () => {
60+
const middleware = httpApiKeyAuthMiddleware(
61+
{
62+
apiKey: "exampleApiKey",
63+
},
64+
{
65+
in: "query",
66+
name: "key",
67+
}
68+
);
69+
70+
const handler = middleware(mockNextHandler, {});
71+
72+
await handler({
73+
input: {},
74+
request: new HttpRequest({}),
75+
});
76+
77+
expect(mockNextHandler.mock.calls.length).toEqual(1);
78+
expect(
79+
mockNextHandler.mock.calls[0][0].request.query.key
80+
).toBe("exampleApiKey");
81+
});
82+
83+
it("should throw an error if the api key has not been set", async () => {
84+
const middleware = httpApiKeyAuthMiddleware(
85+
{},
86+
{
87+
in: "header",
88+
name: "auth",
89+
scheme: "scheme",
90+
}
91+
);
92+
93+
const handler = middleware(mockNextHandler, {});
94+
95+
await expect(
96+
handler({
97+
input: {},
98+
request: new HttpRequest({}),
99+
})
100+
).rejects.toThrow("no API key was provided");
101+
102+
expect(mockNextHandler.mock.calls.length).toEqual(0);
103+
});
104+
105+
it("should skip if the request is not an HttpRequest", async () => {
106+
const middleware = httpApiKeyAuthMiddleware(
107+
{},
108+
{
109+
in: "header",
110+
name: "Authorization",
111+
}
112+
);
113+
114+
const handler = middleware(mockNextHandler, {});
115+
116+
await handler({
117+
input: {},
118+
request: {},
119+
});
120+
121+
expect(mockNextHandler.mock.calls.length).toEqual(1);
122+
});
123+
124+
it("should set the API key in the lower-cased named header", async () => {
125+
const middleware = httpApiKeyAuthMiddleware(
126+
{
127+
apiKey: "exampleApiKey",
128+
},
129+
{
130+
in: "header",
131+
name: "Authorization",
132+
}
133+
);
134+
135+
const handler = middleware(mockNextHandler, {});
136+
137+
await handler({
138+
input: {},
139+
request: new HttpRequest({}),
140+
});
141+
142+
expect(mockNextHandler.mock.calls.length).toEqual(1);
143+
expect(
144+
mockNextHandler.mock.calls[0][0].request.headers.authorization
145+
).toBe("exampleApiKey");
146+
});
147+
148+
it("should set the API key in the named header with the provided scheme", async () => {
149+
const middleware = httpApiKeyAuthMiddleware(
150+
{
151+
apiKey: "exampleApiKey",
152+
},
153+
{
154+
in: "header",
155+
name: "authorization",
156+
scheme: "exampleScheme",
157+
}
158+
);
159+
const handler = middleware(mockNextHandler, {});
160+
161+
await handler({
162+
input: {},
163+
request: new HttpRequest({}),
164+
});
165+
166+
expect(mockNextHandler.mock.calls.length).toEqual(1);
167+
expect(
168+
mockNextHandler.mock.calls[0][0].request.headers.authorization
169+
).toBe("exampleScheme exampleApiKey");
170+
});
171+
});
172+
});

0 commit comments

Comments
 (0)