A dynamic API Gateway which updates REST endpoints, GraphQL schema, WebSocket handlers and access control policies by integrating metadata of discovered remote services.
moleculer-api๋ MSA ํ๊ฒฝ์์ ๋ง์ดํฌ๋ก ์๋น์ค๋ค์ API ์คํค๋ง ์กฐ๊ฐ์ ์์งํ๊ณ , ๋ฌด์ค๋จ์ผ๋ก ํตํฉ API๋ฅผ ์ ๋ฐ์ดํธํ์ฌ ์ ๊ณตํ๋ ์น ์๋ฒ ์ปดํฌ๋ํธ์ ๋๋ค.
์๋น์ค API ์คํค๋ง๋ ๋ถ์ฐ ์๋น์ค ํ๋ก์์ ์ ํธ์ถ, ๋๋ ์ค์ ๋ฉ์์ง ์๋น์ค์ ๋ํ ์ด๋ฒคํธ ๋ฐํ ๋ฐ ๊ตฌ๋ ์ ์์ฉ ํ๋กํ ์ฝ(REST, GraphQL, WebSocket ๋ฑ)์ ๋งตํํฉ๋๋ค. ์๋น์ค API ์คํค๋ง๋ JSON ํฌ๋งท์ผ๋ก ๊ตฌ์ฑ๋์ด์์ผ๋ฉฐ, ์์ฉ ํ๋กํ ์ฝ ๋ณ API ๋งตํ๊ณผ ๊ทธ์ ๋ํ ์ ๊ทผ ์ ์ด๋ก ๊ตฌ์ฑ๋์ด ์์ต๋๋ค.
{
branch: "master",
policy: {},
protocol: {
REST: {
basePath: "/storage",
routes: [
{
path: "/",
method: "GET",
call: {
action: "storage.get",
params: {
offset: "@query.offset:number",
limit: "@query.limit:number",
},
},
},
{
path: "/upload",
method: "POST",
call: {
action: "storage.create",
params: {
file: "@body.file",
meta: {
tags: {
identityId: "@context.auth.identity.sub",
},
allowedContentTypes: ["text/*", "image/*", "application/pdf"],
private: false,
},
},
},
},
{
path: "/upload-stream",
method: "POST",
description: "not a production purpose, need a wrapper action to make this safe",
call: {
action: "storage.createWithStream",
params: "@body.file",
implicitParams: false,
},
},
{
path: "/download/:id",
method: "GET",
call: {
action: "storage.getURL",
params: {
id: "@path.id",
expiryHours: "@query.expiryHours:number",
prompt: "@query.prompt:boolean",
promptAs: "@query.promptAs:string",
},
map: `({ response }) => ({
$status: response ? 303 : 404,
$headers: response ? { "Location": response } : undefined,
})`,
},
},
{
path: "/:id",
method: "GET",
call: {
action: "storage.find",
params: {
id: "@path.id",
},
map: `({ response }) => ({
$status: response ? 200 : 404,
$body: response,
})`,
},
},
{
path: "/:id",
method: "PUT",
call: {
action: "storage.update",
params: {
id: "@path.id",
// id, name, tags, private, contentType
},
},
},
{
path: "/:id",
method: "DELETE",
call: {
action: "storage.delete",
params: {
id: "@path.id",
},
},
},
],
},
},
}{
branch: "master",
policy: {},
protocol: {
GraphQL: {
typeDefs: `
extend type Query {
storage: StorageQuery!
}
type StorageQuery {
files(offset: Int = 0, limit: Int = 10): FileList!
file(id: ID!): File
}
type File {
id: ID!
name: String!
contentType: String!
tags: JSON!
private: Boolean!
byteSize: Int!
size: String!
url(expiryHours: Int = 2, prompt: Boolean = false, promptAs: String): String!
updatedAt: DateTime!
createdAt: DateTime!
}
type FileList {
offset: Int!
limit: Int!
# it is not exact value
total: Int!
entries: [File!]!
hasNext: Boolean!
hasPrev: Boolean!
}
extend type Mutation {
storage: StorageMutation!
}
type StorageMutation {
upload(file: Upload!): File!
update(id: ID!, name: String, tags: JSON, private: Boolean): File!
delete(id: ID!): Boolean!
}
`,
resolvers: {
Query: {
storage: `() => ({})`,
},
Mutation: {
storage: `() => ({})`,
},
StorageMutation: {
upload: {
call: {
action: "storage.create",
params: {
file: "@args.file",
meta: {
allowedContentTypes: [],
private: false,
},
},
},
},
update: {
call: {
action: "storage.update",
params: {},
},
},
delete: {
call: {
action: "storage.delete",
params: {},
}
},
},
StorageQuery: {
file: {
call: {
action: "storage.find",
params: {
id: "@args.id[]",
},
},
},
files: {
call: {
action: "storage.get",
params: {
offset: "@args.offset",
limit: "@args.limit",
},
},
},
},
File: {
url: {
call: {
action: "storage.getURL",
params: {
id: "@source.id[]",
expiryHours: "@args.expiryHours",
prompt: "@args.prompt",
promptAs: "@args.promptAs",
},
},
},
size: (({ source }: any) => {
const { byteSize = 0 } = source;
const boundary = 1000;
if (byteSize < boundary) {
return `${byteSize} B`;
}
let div = boundary;
let exp = 0;
for (let n = byteSize / boundary; n >= boundary; n /= boundary) {
div *= boundary;
exp++;
}
const size = byteSize / div;
return `${isNaN(size) ? "-" : size.toLocaleString()} ${"KMGTPE"[exp]}B`;
}).toString(),
},
FileList: {
hasPrev: `({ source }) => source.offset > 0`,
}
},
},
},
}{
branch: "master",
policy: {
call: [
{
actions: ["user.update"],
description: "A user can update the user profile which is belongs to own account.",
scope: ["user.write"],
filter: (({ context, params }) => {
console.log(context);
if (params.id) {
return context.auth.identity.sub === params.id;
}
return false;
}).toString(),
},
],
},
protocol: {
GraphQL: {
typeDefs: `
extend type Query {
viewer: User
user(id: Int, identityId: String, username: String, where: JSON): User
}
extend type Mutation {
createUser(input: UserInput!): User!
updateUser(input: UserInput!): User!
}
input SportsUserInput {
id: Int
username: String
birthdate: Date
gender: Gender
name: String
pictureFileId: String
}
type User {
id: Int!
username: String!
name: String!
birthdate: Date
gender: Gender
pictureFile: File
}
`,
resolvers: {
User: {
pictureFile: {
call: {
if: "({ source }) => !!source.pictureFileId",
action: "storage.find",
params: {
id: "@source.pictureFileId[]",
},
},
},
},
Mutation: {
createUser: {
call: {
action: "user.create",
params: "@args.input",
implicitParams: false,
},
},
updateUser: {
call: {
action: "user.update",
params: "@args.input",
implicitParams: false,
},
},
},
},
},
},
}{
branch: "master",
policy: {},
protocol: {
REST: {
description: "update user's FCM registration token",
basePath: "/notification",
routes: [
{
method: "PUT",
path: "/update-token",
call: {
action: "notification.updateToken",
params: {
identityId: "@context.auth.identity.sub",
token: "@body.token",
},
},
},
],
},
},
}{
branch: "master",
policy: {},
protocol: {
WebSocket: {
basePath: "/chat",
description: "...",
routes: [
/* bidirectional streaming chat */
{
path: "/message-stream/:roomId",
call: {
action: "chat.message.stream",
params: {
roomId: "@path.roomId",
},
},
},
/* pub/sub chat */
{
path: "/message-pubsub/:roomId",
subscribe: {
events: `({ path }) => ["chat.message." + path.roomId]`,
},
publish: {
event: `({ path }) => "chat.message." + path.roomId`,
params: "@message",
},
},
/* pub/sub video */
{
path: "/video-pubsub",
subscribe: {
events: ["chat.video"],
},
publish: {
event: "chat.video",
params: {
id: "@context.id",
username: "@query.username",
data: "@message",
},
filter: `({ params }) => params.id && params.username && params.data`,
},
},
/* streaming video */
{
path: "/video-stream/:type",
call: {
action: "chat.video.stream",
params: {
id: "@context.id",
type: "@path.type",
},
},
},
],
},
},
}Gateway๋ ํน์ ์๋น์ค API ์คํค๋ง์ ์ถ๊ฐ, ์ ๊ฑฐ ๋ฐ ์ ๋ฐ์ดํธ์ ๊ธฐ์กด ํตํฉ API ์คํค๋ง์ ๋ณํฉ์ ์๋ํ๊ณ , ์ฑ๊ณต์ ๋ฌด์ค๋จ์ผ๋ก ๋ผ์ฐํฐ๋ฅผ ์ ๋ฐ์ดํธํ๋ฉฐ ๊ทธ ๊ฒฐ๊ณผ๋ฅผ ์๊ฒฉ ์๋น์ค์ ๋ค์ ๋ณด๊ณ ํฉ๋๋ค.
๋ถ์ฐ ์๋น์ค์ API ์คํค๋ง๋ฅผ ์์งํ๊ณ ๋ณํฉํ์ฌ API๋ฅผ ์ค์๊ฐ์ผ๋ก ์ ๋ฐ์ดํธ
Polyglot ํ๊ฑฐ๋ ์๋ก ๋ค๋ฅธ ์๋น์ค ๋ธ๋ก์ปค์ ๊ธฐ๋ฐํ ์๋น์ค๋ค์ API ์คํค๋ง๋ฅผ ์์งํ๊ณ ๋ณํฉ ํ ์ ์์
๊ฐ๋ฐ ํธ์ ๋ฐ ์ถฉ๋ ๋ฐฉ์ง๋ฅผ ์ํ API ๋ธ๋์นญ ๋ฐ ๋ฒ์ ๋ ๊ธฐ๋ฅ
์ํ ๊ฒ์ฌ ๋ฐ API ๋ฌธ์ ์์ฑ (WIP)
๋ฏธ๋ค์จ์ด ๋ฐฉ์์ ์์ฒญ ํ๋ฆ ์ ์ด
Error
Logging
Body Parser
Helmet
CORS
Serve Static File
HTTP/HTTPS/HTTP2
(ํ์ฅ ๊ฐ๋ฅ)
๋ฏธ๋ค์จ์ด ๋ฐฉ์์ ์ปจํ ์คํธ ์์ฑ ์ ์ด
Authn/Authz
Locale
Correlation ID
IP Address
User-Agent
Request
(ํ์ฅ ๊ฐ๋ฅ)
์์ฉ ํ๋กํ ์ฝ ํ๋ฌ๊ทธ์ธ
REST
GraphQL
WebSocket
(ํ์ฅ ๊ฐ๋ฅ)
์ ๊ทผ ์ ์ด ์ ์ฑ ํ๋ฌ๊ทธ์ธ
OAuth2 scope ๊ธฐ๋ฐ ์ ๊ทผ ์ ์ด
JavaScript FBAC; Function Based Access Control ๊ธฐ๋ฐ ์ ๊ทผ ์ ์ด
(ํ์ฅ ๊ฐ๋ฅ)
The project is available under the MIT license.
