Skip to content

Commit 1b33b73

Browse files
authored
Merge pull request #9 from apollo-server-integrations/initial-implementation
Initial implementation for Azure Functions
2 parents 1a934f3 + 8cb1602 commit 1b33b73

File tree

9 files changed

+4451
-97
lines changed

9 files changed

+4451
-97
lines changed

package-lock.json

+4,109-87
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+12-1
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,21 @@
2929
"spell-check": "cspell lint '**' --no-progress || (echo 'Add any real words to cspell-dict.txt.'; exit 1)",
3030
"test": "jest",
3131
"test:ci": "jest --coverage --ci --maxWorkers=2 --reporters=default --reporters=jest-junit",
32-
"watch": "tsc --build --watch"
32+
"watch": "tsc --build --watch",
33+
"start": "npm-run-all watch start:host",
34+
"start:host": "cd src/sample && func start --cors *"
3335
},
3436
"devDependencies": {
37+
"@apollo/server-integration-testsuite": "^4.1.1",
3538
"@changesets/changelog-github": "0.4.7",
3639
"@changesets/cli": "2.25.1",
3740
"@types/jest": "29.2.0",
3841
"@types/node": "14.18.33",
42+
"azure-functions-core-tools": "^4.0.4895",
3943
"cspell": "6.13.1",
4044
"jest": "29.2.2",
4145
"jest-junit": "14.0.1",
46+
"npm-run-all": "^4.1.5",
4247
"prettier": "2.7.1",
4348
"ts-jest": "29.0.3",
4449
"ts-node": "10.9.1",
@@ -47,5 +52,11 @@
4752
"volta": {
4853
"node": "16.18.0",
4954
"npm": "8.19.2"
55+
},
56+
"dependencies": {
57+
"@apollo/server": "^4.1.1",
58+
"@azure/functions": "^3.2.0",
59+
"graphql": "^16.6.0",
60+
"graphql-tag": "^2.12.6"
5061
}
5162
}

src/__tests__/helloWorld.test.ts

-7
This file was deleted.

src/__tests__/index.test.ts

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { ApolloServer, ApolloServerOptions, BaseContext } from '@apollo/server';
2+
import { startServerAndCreateHandler } from '..';
3+
import {
4+
CreateServerForIntegrationTestsOptions,
5+
defineIntegrationTestSuite,
6+
} from '@apollo/server-integration-testsuite';
7+
import { createServer } from 'http';
8+
import type { AzureFunction } from '@azure/functions';
9+
import { createMockServer, urlForHttpServer } from './mockServer';
10+
11+
describe('Azure Functions', () => {
12+
defineIntegrationTestSuite(
13+
async function (
14+
serverOptions: ApolloServerOptions<BaseContext>,
15+
testOptions?: CreateServerForIntegrationTestsOptions,
16+
) {
17+
const httpServer = createServer();
18+
const server = new ApolloServer({
19+
...serverOptions,
20+
});
21+
22+
const handler: AzureFunction = testOptions
23+
? startServerAndCreateHandler(server, testOptions)
24+
: startServerAndCreateHandler(server);
25+
26+
await new Promise<void>((resolve) => {
27+
httpServer.listen({ port: 0 }, resolve);
28+
});
29+
30+
httpServer.addListener('request', createMockServer(handler));
31+
32+
return {
33+
server,
34+
url: urlForHttpServer(httpServer),
35+
async extraCleanup() {
36+
await new Promise<void>((resolve) => {
37+
httpServer.close(() => resolve());
38+
});
39+
},
40+
};
41+
},
42+
{
43+
serverIsStartedInBackground: true,
44+
noIncrementalDelivery: true,
45+
},
46+
);
47+
});

src/__tests__/mockServer.ts

+119
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import type {
2+
AzureFunction,
3+
Context,
4+
HttpMethod,
5+
HttpRequest,
6+
HttpRequestHeaders,
7+
HttpRequestQuery,
8+
Logger,
9+
} from '@azure/functions';
10+
import type {
11+
IncomingHttpHeaders,
12+
IncomingMessage,
13+
Server,
14+
ServerResponse,
15+
} from 'http';
16+
import type { AddressInfo } from 'net';
17+
18+
export function urlForHttpServer(httpServer: Server): string {
19+
const { address, port } = httpServer.address() as AddressInfo;
20+
21+
// Convert IPs which mean "any address" (IPv4 or IPv6) into localhost
22+
// corresponding loopback ip. Note that the url field we're setting is
23+
// primarily for consumption by our test suite. If this heuristic is wrong for
24+
// your use case, explicitly specify a frontend host (in the `host` option
25+
// when listening).
26+
const hostname = address === '' || address === '::' ? 'localhost' : address;
27+
28+
return `http://${hostname}:${port}`;
29+
}
30+
31+
export const createMockServer = (handler: AzureFunction) => {
32+
return (req: IncomingMessage, res: ServerResponse) => {
33+
let body = '';
34+
req.on('data', (chunk) => (body += chunk));
35+
36+
req.on('end', async () => {
37+
const azReq: HttpRequest = {
38+
method: (req.method as HttpMethod) || null,
39+
url: new URL(req.url || '', 'http://localhost').toString(),
40+
headers: processHeaders(req.headers),
41+
body,
42+
query: processQuery(req.url),
43+
params: {},
44+
user: null,
45+
parseFormBody: () => {
46+
throw new Error('Not implemented');
47+
},
48+
};
49+
50+
const context: Context = {
51+
invocationId: 'mock',
52+
executionContext: {
53+
invocationId: 'mock',
54+
functionName: 'mock',
55+
functionDirectory: 'mock',
56+
retryContext: null,
57+
},
58+
bindings: {},
59+
bindingData: {
60+
invocationId: 'mock',
61+
},
62+
log: createConsoleLogger(),
63+
bindingDefinitions: [],
64+
traceContext: {
65+
traceparent: '',
66+
tracestate: '',
67+
attributes: {},
68+
},
69+
done: () => {},
70+
};
71+
72+
const azRes = await handler(context, azReq);
73+
74+
res.statusCode = azRes.status || 200;
75+
Object.entries(azRes.headers ?? {}).forEach(([key, value]) => {
76+
res.setHeader(key, value!.toString());
77+
});
78+
res.write(azRes.body);
79+
res.end();
80+
});
81+
};
82+
};
83+
84+
const processQuery: (url: string | undefined) => HttpRequestQuery = (url) => {
85+
if (!url) {
86+
return {};
87+
}
88+
89+
const uri = new URL(url, 'http://localhost');
90+
91+
const query: HttpRequestQuery = {};
92+
for (const [key, value] of uri.searchParams.entries()) {
93+
if (query[key] !== undefined) {
94+
query[key] = `${query[key]},${value}`;
95+
} else {
96+
query[key] = value;
97+
}
98+
}
99+
return query;
100+
};
101+
102+
const processHeaders: (headers: IncomingHttpHeaders) => HttpRequestHeaders = (
103+
headers,
104+
) => {
105+
const result: HttpRequestHeaders = {};
106+
for (const [key, value] of Object.entries(headers)) {
107+
result[key] = Array.isArray(value) ? value.join(',') : value ?? '';
108+
}
109+
return result;
110+
};
111+
112+
const createConsoleLogger = () => {
113+
const logger = console.log as Logger;
114+
logger.error = console.error;
115+
logger.warn = console.warn;
116+
logger.info = console.info;
117+
logger.verbose = console.log;
118+
return logger;
119+
};

src/index.ts

+107-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,108 @@
1-
export function helloWorld() {
2-
return 'Hello World!';
1+
import type {
2+
AzureFunction,
3+
Context,
4+
HttpRequest,
5+
HttpRequestHeaders,
6+
} from '@azure/functions';
7+
import {
8+
ApolloServer,
9+
BaseContext,
10+
ContextFunction,
11+
HeaderMap,
12+
HTTPGraphQLRequest,
13+
} from '@apollo/server';
14+
import type { WithRequired } from '@apollo/utils.withrequired';
15+
16+
export interface AzureFunctionsContextFunctionArgument {
17+
context: Context;
18+
}
19+
20+
export interface AzureFunctionsMiddlewareOptions<TContext extends BaseContext> {
21+
context?: ContextFunction<[AzureFunctionsContextFunctionArgument], TContext>;
22+
}
23+
24+
const defaultContext: ContextFunction<
25+
[AzureFunctionsContextFunctionArgument],
26+
any
27+
> = async () => ({});
28+
29+
export function startServerAndCreateHandler(
30+
server: ApolloServer<BaseContext>,
31+
options?: AzureFunctionsMiddlewareOptions<BaseContext>,
32+
): AzureFunction;
33+
export function startServerAndCreateHandler<TContext extends BaseContext>(
34+
server: ApolloServer<TContext>,
35+
options: WithRequired<AzureFunctionsMiddlewareOptions<TContext>, 'context'>,
36+
): AzureFunction;
37+
export function startServerAndCreateHandler<TContext extends BaseContext>(
38+
server: ApolloServer<TContext>,
39+
options?: AzureFunctionsMiddlewareOptions<TContext>,
40+
): AzureFunction {
41+
server.startInBackgroundHandlingStartupErrorsByLoggingAndFailingAllRequests();
42+
return async (context: Context, req: HttpRequest) => {
43+
const contextFunction = options?.context ?? defaultContext;
44+
try {
45+
const normalizedRequest = normalizeRequest(req);
46+
47+
const { body, headers, status } = await server.executeHTTPGraphQLRequest({
48+
httpGraphQLRequest: normalizedRequest,
49+
context: () => contextFunction({ context }),
50+
});
51+
52+
if (body.kind === 'chunked') {
53+
throw Error('Incremental delivery not implemented');
54+
}
55+
56+
return {
57+
status: status || 200,
58+
headers: {
59+
...Object.fromEntries(headers),
60+
'content-length': Buffer.byteLength(body.string).toString(),
61+
},
62+
body: body.string,
63+
};
64+
} catch (e) {
65+
context.log.error('Failure processing GraphQL request', e);
66+
return {
67+
status: 400,
68+
body: (e as Error).message,
69+
};
70+
}
71+
};
72+
}
73+
74+
function normalizeRequest(req: HttpRequest): HTTPGraphQLRequest {
75+
if (!req.method) {
76+
throw new Error('No method');
77+
}
78+
79+
return {
80+
method: req.method,
81+
headers: normalizeHeaders(req.headers),
82+
search: new URL(req.url).search,
83+
body: parseBody(req.body, req.headers['content-type']),
84+
};
85+
}
86+
87+
function parseBody(
88+
body: string | null | undefined,
89+
contentType: string | undefined,
90+
): object | string {
91+
if (body) {
92+
if (contentType === 'application/json' && typeof body === 'string') {
93+
return JSON.parse(body);
94+
}
95+
if (contentType === 'text/plain') {
96+
return body;
97+
}
98+
}
99+
return '';
100+
}
101+
102+
function normalizeHeaders(headers: HttpRequestHeaders): HeaderMap {
103+
const headerMap = new HeaderMap();
104+
for (const [key, value] of Object.entries(headers)) {
105+
headerMap.set(key, value ?? '');
106+
}
107+
return headerMap;
3108
}

src/sample/graphql/function.json

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"bindings": [
3+
{
4+
"authLevel": "function",
5+
"type": "httpTrigger",
6+
"direction": "in",
7+
"name": "req",
8+
"methods": ["get", "post"]
9+
},
10+
{
11+
"type": "http",
12+
"direction": "out",
13+
"name": "$return"
14+
}
15+
],
16+
"scriptFile": "../../../dist/sample/graphql/index.js"
17+
}
18+

src/sample/graphql/index.ts

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { ApolloServer } from '@apollo/server';
2+
import { startServerAndCreateHandler } from '../..';
3+
4+
// The GraphQL schema
5+
const typeDefs = `#graphql
6+
type Query {
7+
hello: String
8+
}
9+
`;
10+
11+
// A map of functions which return data for the schema.
12+
const resolvers = {
13+
Query: {
14+
hello: () => 'world',
15+
},
16+
};
17+
18+
// Set up Apollo Server
19+
const server = new ApolloServer({
20+
typeDefs,
21+
resolvers,
22+
});
23+
24+
export default startServerAndCreateHandler(server);

src/sample/host.json

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"version": "2.0",
3+
"logging": {
4+
"applicationInsights": {
5+
"samplingSettings": {
6+
"isEnabled": true,
7+
"excludedTypes": "Request"
8+
}
9+
}
10+
},
11+
"extensionBundle": {
12+
"id": "Microsoft.Azure.Functions.ExtensionBundle",
13+
"version": "[3.*, 4.0.0)"
14+
}
15+
}

0 commit comments

Comments
 (0)