Skip to content

Commit

Permalink
feat(mapping): endpoint api (#360)
Browse files Browse the repository at this point in the history
* feat(mapping): mapping api

* revmap

* security

* better naming

* fix

* rename

* more tests

* security tests

* security tests

* doc
  • Loading branch information
samuelmasse committed Feb 16, 2022
1 parent aae6946 commit b94d3e5
Show file tree
Hide file tree
Showing 11 changed files with 295 additions and 3 deletions.
18 changes: 18 additions & 0 deletions docs/routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,21 @@ x-bp-messaging-client-id: `clientId`
x-bp-messaging-client-token: `clientToken`

Deletes all messages of a conversation

## Endpoints

POST `/api/v1/endpoints/map`

x-bp-messaging-client-id: `clientId`

x-bp-messaging-client-token: `clientToken`

Maps an endpoints and returns a conversation id

GET `/api/v1/endpoints/conversation/:conversationId`

x-bp-messaging-client-id: `clientId`

x-bp-messaging-client-token: `clientToken`

List endpoints of a conversation
24 changes: 24 additions & 0 deletions misc/api.rest
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ x-bp-messaging-client-token: {{clientToken}}
{
"channels": {
"telegram": {
"version": "1.0.0",
"botToken": "my-telegram-token"
}
},
Expand All @@ -62,6 +63,29 @@ Authorization: Bearer {{authToken}}
x-bp-messaging-client-id: {{clientId}}
x-bp-messaging-client-token: {{clientToken}}

### Map Endpoint
POST {{baseUrl}}/api/v1/endpoints/map
Authorization: Bearer {{authToken}}
x-bp-messaging-client-id: {{clientId}}
x-bp-messaging-client-token: {{clientToken}}
Content-Type: application/json

{
"channel": {
"name": "telegram",
"version": "1.0.0"
},
"identity": "*",
"sender": "my-sender-id",
"thread": "my-thread-id"
}

### List Endpoints
GET {{baseUrl}}/api/v1/endpoints/conversation/{{convoId}}
Authorization: Bearer {{authToken}}
x-bp-messaging-client-id: {{clientId}}
x-bp-messaging-client-token: {{clientToken}}

### Create User
POST {{baseUrl}}/api/v1/users
Authorization: Bearer {{authToken}}
Expand Down
6 changes: 6 additions & 0 deletions packages/base/src/endpoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface Endpoint {
channel: { name: string; version: string }
identity: string
sender: string
thread: string
}
1 change: 1 addition & 0 deletions packages/base/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './conversations'
export * from './emitter'
export * from './endpoint'
export * from './health'
export * from './messages'
export * from './sync'
Expand Down
25 changes: 24 additions & 1 deletion packages/client/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { Conversation, HealthReport, Message, SyncRequest, SyncResult, User, uuid } from '@botpress/messaging-base'
import {
Conversation,
Endpoint,
HealthReport,
Message,
SyncRequest,
SyncResult,
User,
uuid
} from '@botpress/messaging-base'
import { MessagingChannelBase } from './base'
import { handleNotFound } from './errors'

Expand Down Expand Up @@ -110,6 +119,20 @@ export abstract class MessagingChannelApi extends MessagingChannelBase {
await this.http.post(`/messages/turn/${id}`, undefined, { headers: this.headers[clientId] })
}

async mapEndpoint(clientId: uuid, endpoint: Endpoint): Promise<uuid> {
return (
await this.http.post<{ conversationId: uuid }>('/endpoints/map', endpoint, { headers: this.headers[clientId] })
).data.conversationId
}

async listEndpoints(clientId: uuid, conversationId: uuid): Promise<Endpoint[]> {
return (
await this.http.get<Endpoint[]>(`/endpoints/conversation/${conversationId}`, {
headers: this.headers[clientId]
})
).data
}

protected deserializeHealth(report: HealthReport) {
for (const channel of Object.keys(report.channels)) {
report.channels[channel].events = report.channels[channel].events.map((x) => ({ ...x, time: new Date(x.time) }))
Expand Down
19 changes: 18 additions & 1 deletion packages/client/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { Conversation, HealthReport, Message, SyncRequest, SyncResult, User, uuid } from '@botpress/messaging-base'
import {
Conversation,
Endpoint,
HealthReport,
Message,
SyncRequest,
SyncResult,
User,
uuid
} from '@botpress/messaging-base'
import { AxiosRequestConfig } from 'axios'
import { Router } from 'express'
import { MessageFeedbackEvent } from '.'
Expand Down Expand Up @@ -145,4 +154,12 @@ export class MessagingClient extends ProtectedEmitter<{
async endTurn(id: uuid) {
return this.channel.endTurn(this.clientId, id)
}

async mapEndpoint(endpoint: Endpoint): Promise<uuid> {
return this.channel.mapEndpoint(this.clientId, endpoint)
}

async listEndpoints(conversationId: uuid): Promise<Endpoint[]> {
return this.channel.listEndpoints(this.clientId, conversationId)
}
}
42 changes: 42 additions & 0 deletions packages/client/test/e2e/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,4 +340,46 @@ describe('Http Client', () => {
})
})
})

describe('Endpoints', () => {
describe('Map', () => {
const endpoint = { channel: { name: 'telegram', version: '1.0.0' }, identity: '*', sender: 'yoyo', thread: 'ya' }
let convoId: string | undefined

test('Should be able to map an endpoint to a conversation id', async () => {
convoId = await client.mapEndpoint(endpoint)
expect(validateUuid(convoId)).toBeTruthy()
})

test('Should be able to map the endpoint to the same conversation id again', async () => {
const convoId2 = await client.mapEndpoint(endpoint)
expect(convoId2).toBe(convoId)
})

test('Should fail to map an endpoint with an unknown channel', async () => {
await expect(client.mapEndpoint({ ...endpoint, channel: { name: 'yoyo', version: '1.0.0' } })).rejects.toThrow(
new Error('Request failed with status code 400')
)
})

test('Should fail to map an endpoint with invalid fields', async () => {
await expect(client.mapEndpoint({ ...endpoint, identity: null as any })).rejects.toThrow(
new Error('Request failed with status code 400')
)
})

test('Should be able to list the endpoints of a conversation to get back the same endpoint', async () => {
const [mappedEndpoint] = await client.listEndpoints(convoId!)
expect(mappedEndpoint).toEqual(endpoint)
})

test('Should be able to list the endpoints of a conversation that has not endpoints', async () => {
const user = await client.createUser()
const conversation = await client.createConversation(user.id)

const endpoints = await client.listEndpoints(conversation.id)
expect(endpoints).toEqual([])
})
})
})
})
4 changes: 4 additions & 0 deletions packages/server/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ChannelApi } from './channels/api'
import { ClientApi } from './clients/api'
import { ConversationApi } from './conversations/api'
import { HealthApi } from './health/api'
import { MappingApi } from './mapping/api'
import { MessageApi } from './messages/api'
import { SyncApi } from './sync/api'
import { UserApi } from './users/api'
Expand All @@ -25,6 +26,7 @@ export class Api {
private users: UserApi
private conversations: ConversationApi
private messages: MessageApi
private mapping: MappingApi
private channels: ChannelApi

constructor(private app: App, private root: Router) {
Expand All @@ -39,6 +41,7 @@ export class Api {
this.users = new UserApi(this.app.users)
this.conversations = new ConversationApi(this.app.users, this.app.conversations)
this.messages = new MessageApi(this.app.users, this.app.conversations, this.app.messages, this.app.converse)
this.mapping = new MappingApi(this.app.channels, this.app.conversations, this.app.mapping)

this.channels = new ChannelApi(this.root, this.app)
}
Expand Down Expand Up @@ -67,6 +70,7 @@ export class Api {
this.users.setup(this.manager)
this.conversations.setup(this.manager)
this.messages.setup(this.manager)
this.mapping.setup(this.manager)

await this.channels.setup()

Expand Down
52 changes: 52 additions & 0 deletions packages/server/src/mapping/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Endpoint } from '@botpress/messaging-base'
import { Response } from 'express'
import { ApiManager } from '../base/api-manager'
import { ClientApiRequest } from '../base/auth/client'
import { ChannelService } from '../channels/service'
import { ConversationService } from '../conversations/service'
import { makeMapRequestSchema, Schema } from './schema'
import { MappingService } from './service'

export class MappingApi {
constructor(
private channels: ChannelService,
private conversations: ConversationService,
private mapping: MappingService
) {}

setup(router: ApiManager) {
router.post('/endpoints/map', makeMapRequestSchema(this.channels.list()), this.map.bind(this))
router.get('/endpoints/conversation/:conversationId', Schema.Api.List, this.list.bind(this))
}

async map(req: ClientApiRequest, res: Response) {
const endpoint = req.body as Endpoint

const channel = this.channels.getByNameAndVersion(endpoint.channel.name, endpoint.channel.version)
const { conversationId } = await this.mapping.getMapping(req.clientId, channel.meta.id, endpoint)

res.send({ conversationId })
}

async list(req: ClientApiRequest, res: Response) {
const { conversationId } = req.params

const conversation = await this.conversations.fetch(conversationId)
if (!conversation || conversation.clientId !== req.clientId) {
return res.sendStatus(404)
}

const convmaps = await this.mapping.convmap.listByConversationId(conversationId)
const endpoints = []

for (const convmap of convmaps) {
const endpoint = await this.mapping.getEndpoint(convmap.threadId)
const tunnel = await this.mapping.tunnels.get(convmap.tunnelId)
const channel = this.channels.getById(tunnel!.channelId)

endpoints.push({ channel: { name: channel.meta.name, version: channel.meta.version }, ...endpoint })
}

res.send(endpoints)
}
}
31 changes: 31 additions & 0 deletions packages/server/src/mapping/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Channel } from '@botpress/messaging-channels'
import Joi from 'joi'
import { ReqSchema } from '../base/schema'

export const makeMapRequestSchema = (channels: Channel[]) => {
return ReqSchema({
body: {
channel: Joi.alternatives(
channels.map((x) =>
Joi.object({
name: Joi.string().valid(x.meta.name).required(),
version: Joi.string().valid(x.meta.version).required()
})
)
).required(),
identity: Joi.string().required(),
sender: Joi.string().required(),
thread: Joi.string().required()
}
})
}

const Api = {
List: ReqSchema({
params: {
conversationId: Joi.string().guid().required()
}
})
}

export const Schema = { Api }
76 changes: 75 additions & 1 deletion packages/server/test/security/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Conversation, Message, SyncRequest, SyncResult, User } from '@botpress/messaging-base'
import { Conversation, Endpoint, Message, SyncRequest, SyncResult, User } from '@botpress/messaging-base'
import axios, { AxiosError, AxiosRequestConfig, Method } from 'axios'
import _ from 'lodash'
import querystring from 'querystring'
import { v4 as uuid } from 'uuid'
import { randStr } from '../integration/utils'
import froth from './mocha-froth'

const UUID_LENGTH = uuid().length
Expand Down Expand Up @@ -970,4 +971,77 @@ describe('API', () => {
})
})
})

describe('Endpoints', () => {
const mapEndpoint = async (
endpoint: Endpoint,
clientId?: string,
clientToken?: string,
config?: AxiosRequestConfig
) => {
const res = await http(clientId, clientToken).post<{ conversationId: string }>(
'/api/v1/endpoints/map',
endpoint,
config
)
return res.data.conversationId
}

const listEndpoints = async (
conversationId: string,
clientId?: string,
clientToken?: string,
config?: AxiosRequestConfig
) => {
const res = await http(clientId, clientToken).get<Endpoint[]>(
`/api/v1/endpoints/conversation/${conversationId}`,
config
)

return res.data
}

describe('Map', () => {
test('Mapping an endpoint on two different clients should produce two different results', async () => {
const endpoint = {
channel: { name: 'telegram', version: '1.0.0' },
identity: randStr(),
sender: randStr(),
thread: randStr()
}

const convFirst1 = await mapEndpoint(endpoint, clients.first.clientId, clients.first.clientToken)
const convFirst2 = await mapEndpoint(endpoint, clients.first.clientId, clients.first.clientToken)
expect(convFirst1).toEqual(convFirst2)

const convSecond1 = await mapEndpoint(endpoint, clients.second.clientId, clients.second.clientToken)
const convSecond2 = await mapEndpoint(endpoint, clients.second.clientId, clients.second.clientToken)
expect(convSecond1).toEqual(convSecond2)

expect(convFirst1).not.toEqual(convSecond1)
})
})

describe('List', () => {
test('Should not be able to list endpoints without being authenticated', async () => {
await shouldFail(
async () => listEndpoints(clients.first.conversationId),
(err) => {
expect(err.response?.data).toEqual('Unauthorized')
expect(err.response?.status).toEqual(401)
}
)
})

test('Should not be able to list endpoints of another client', async () => {
await shouldFail(
async () => listEndpoints(clients.second.conversationId, clients.first.clientId, clients.first.clientToken),
(err) => {
expect(err.response?.data).toEqual('Not Found')
expect(err.response?.status).toEqual(404)
}
)
})
})
})
})

0 comments on commit b94d3e5

Please sign in to comment.