Compare commits
12
Commits
48db190c3f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb3f5ff891 | ||
|
|
edd6f24eab | ||
|
|
cc51d3766a | ||
|
|
04e0847d49 | ||
|
|
90d4251ff6 | ||
|
|
ee710254e7 | ||
|
|
27d2d88a8b | ||
|
|
a6eaba5944 | ||
|
|
f56f87aa18
|
||
|
|
bea0491211
|
||
|
|
f7af0f0b62
|
||
|
|
86d6be5910 |
+3
-1
@@ -25,4 +25,6 @@ JWT_SECRET=
|
||||
HTTP_BASE_URL=http://example.com
|
||||
HTTP_USERNAME=
|
||||
HTTP_PASSWORD=
|
||||
HTTP_TIMEOUT=3000
|
||||
HTTP_TIMEOUT=3000
|
||||
|
||||
MERCHANT_REGISTER=[{"merchantId":"","merchantName":"", "contactPhone": "", "apiKey": "", "secretKey": ""}]
|
||||
@@ -1,4 +1,3 @@
|
||||
# MBG vega cloud
|
||||

|
||||
|
||||
A vega cloud service built by 🌧 Skyrain Studio.
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Elysia } from 'elysia'
|
||||
import deviceRouter from '~/modules/device/router'
|
||||
import authRouter from '~/modules/auth/router'
|
||||
import topicsRouter from '~/modules/topics/router'
|
||||
|
||||
export const router = new Elysia({
|
||||
name: 'codebase.router',
|
||||
})
|
||||
.use(authRouter)
|
||||
.use(topicsRouter)
|
||||
.use(deviceRouter)
|
||||
.get('/', () => ({
|
||||
message: 'This service is running as expected.',
|
||||
|
||||
@@ -31,6 +31,10 @@ export const apiAuth = {
|
||||
apiPrefix: process.env.AUTH_API_KEY_PREFIX || 'ak_prod',
|
||||
secretPrefix: process.env.AUTH_SECRET_KEY_PREFIX || 'sk_prod',
|
||||
skewSeconds: Number(process.env.API_KEY_SKEW_SECONDS || 300),
|
||||
rateLimit: {
|
||||
windowSeconds: Number(process.env.API_KEY_RATE_LIMIT_WINDOW_SECONDS || 60),
|
||||
maxRequests: Number(process.env.API_KEY_RATE_LIMIT_MAX_REQUESTS || 100),
|
||||
},
|
||||
}
|
||||
|
||||
export const s3Configs = {
|
||||
@@ -73,3 +77,5 @@ export const httpConfig = {
|
||||
password: process.env.HTTP_PASSWORD,
|
||||
timeout: Number(process.env.HTTP_TIMEOUT) || 3000,
|
||||
}
|
||||
|
||||
export const merchantRegister = process.env.MERCHANT_REGISTER || '[]'
|
||||
|
||||
@@ -32,3 +32,36 @@ export const convertAlphabetString = (input: string): string => {
|
||||
.filter((s) => s !== '')
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a two-digit string number (01-26) into its corresponding alphabet character (A-Z).
|
||||
* @param numStr The two-digit numeric string to convert.
|
||||
* @returns The alphabet character, or an empty string if invalid.
|
||||
*/
|
||||
export const numberStringToAlphabet = (numStr: string): string => {
|
||||
if (!numStr || numStr.length !== 2) return ''
|
||||
|
||||
const position = parseInt(numStr, 10)
|
||||
if (isNaN(position) || position < 1 || position > 26) return ''
|
||||
|
||||
// A is 65 (1 + 64)
|
||||
return String.fromCharCode(position + 64)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a string of numbers into a string of alphabet characters.
|
||||
* Example: "010203" -> "ABC"
|
||||
* @param input The string of numbers (must be even length).
|
||||
* @returns The converted alphabet string.
|
||||
*/
|
||||
export const convertNumberStringToAlphabet = (input: string): string => {
|
||||
if (!input || input.length % 2 !== 0) return ''
|
||||
|
||||
const result: string[] = []
|
||||
for (let i = 0; i < input.length; i += 2) {
|
||||
const part = input.substring(i, i + 2)
|
||||
result.push(numberStringToAlphabet(part))
|
||||
}
|
||||
|
||||
return result.join('')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export class RateLimitError extends Error {
|
||||
constructor(public readonly message: string) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,12 @@ import { FieldValidationError } from './FieldValidationError'
|
||||
import { MultiFieldValidationError } from './MultiFieldValidationError'
|
||||
import { UnauthenticatedError } from './UnauthenticatedError'
|
||||
import { DataAlreadyExistsError } from './DataAlreadyExistsError'
|
||||
import { RateLimitError } from './RateLimitError'
|
||||
|
||||
export * from './FieldValidationError'
|
||||
export * from './UnauthenticatedError'
|
||||
export * from './DataAlreadyExistsError'
|
||||
export * from './RateLimitError'
|
||||
export * from './DeviceErrorResponseMap'
|
||||
export { InternalServerError } from 'elysia'
|
||||
|
||||
@@ -14,4 +16,5 @@ export default {
|
||||
MULTI_FIELD_VALIDATION: MultiFieldValidationError,
|
||||
UNAUTHENTICATED: UnauthenticatedError,
|
||||
DATA_ALREADY_EXISTS: DataAlreadyExistsError,
|
||||
RATE_LIMIT: RateLimitError,
|
||||
}
|
||||
|
||||
+14
-4
@@ -5,22 +5,32 @@ export const passwordVerify = (password: string, hash: string) => Bun.password.v
|
||||
export const dataMapping = [
|
||||
{
|
||||
deviceName: 'AC',
|
||||
deviceType: 'S',
|
||||
command: 'AC=',
|
||||
},
|
||||
{
|
||||
deviceName: 'AC',
|
||||
deviceType: 'A',
|
||||
command: 'Temp=',
|
||||
},
|
||||
{
|
||||
deviceName: 'L',
|
||||
deviceType: 'S',
|
||||
command: 'Light=',
|
||||
},
|
||||
{
|
||||
deviceName: 'DL',
|
||||
deviceType: 'S',
|
||||
command: 'Doorlock=',
|
||||
},
|
||||
{
|
||||
deviceName: 'BL',
|
||||
command: 'Blind=',
|
||||
},
|
||||
{
|
||||
deviceName: 'BL',
|
||||
deviceType: 'S',
|
||||
command: 'Blind=',
|
||||
},
|
||||
]
|
||||
|
||||
export const getDeviceCommand = (deviceName: string, deviceType: string): string => {
|
||||
const device = dataMapping.find((item) => item.deviceName === deviceName && item.deviceType === deviceType)
|
||||
return device?.command || ''
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import Elysia from 'elysia'
|
||||
import { apiAuth, merchantRegister } from '~/config'
|
||||
import { verify } from '~/helpers/signature'
|
||||
import { UnauthenticatedError, RateLimitError } from '~/helpers/errors'
|
||||
import { logger } from '~/plugins'
|
||||
import { acquireLock, get, set, incrBy, redisKey } from '~/helpers/cache/redis'
|
||||
|
||||
interface RegisteredMerchant {
|
||||
merchantId: string
|
||||
merchantName: string
|
||||
contactPhone: string
|
||||
apiKey: string
|
||||
secretKey: string
|
||||
}
|
||||
|
||||
function getRegisteredMerchants(): RegisteredMerchant[] {
|
||||
try {
|
||||
const parsed = JSON.parse(merchantRegister)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.map((m) => ({
|
||||
merchantId: String(m.merchantId),
|
||||
merchantName: String(m.merchantName),
|
||||
contactPhone: String(m.contactPhone),
|
||||
apiKey: String(m.apiKey),
|
||||
secretKey: String(m.secretKey),
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function findMerchant(apiKey: string): RegisteredMerchant | undefined {
|
||||
return getRegisteredMerchants().find((m) => m.apiKey === apiKey)
|
||||
}
|
||||
|
||||
const nonceKey = (merchantId: string, nonce: string) =>
|
||||
redisKey('merchant_nonces', merchantId, nonce)
|
||||
|
||||
const rateLimitKey = (merchantId: string) =>
|
||||
redisKey('merchant_nonces_limitter', merchantId)
|
||||
|
||||
const lockKey = (merchantId: string, nonce: string) =>
|
||||
redisKey('merchant_nonces', 'lock', merchantId, nonce)
|
||||
|
||||
async function enforceRateLimit(merchantId: string): Promise<void> {
|
||||
const key = rateLimitKey(merchantId)
|
||||
const current = await incrBy(key, 1)
|
||||
if (current === 1) {
|
||||
await set(key, String(current), apiAuth.rateLimit.windowSeconds)
|
||||
}
|
||||
if (current > apiAuth.rateLimit.maxRequests) {
|
||||
logger.debug({
|
||||
merchantId,
|
||||
current,
|
||||
max: apiAuth.rateLimit.maxRequests,
|
||||
}, 'Rate limit exceeded for merchant')
|
||||
throw new RateLimitError('Too many requests, please slow down')
|
||||
}
|
||||
}
|
||||
|
||||
async function enforceUniqueNonce(merchantId: string, nonce: string): Promise<void> {
|
||||
const key = nonceKey(merchantId, nonce)
|
||||
|
||||
const existing = await get(key)
|
||||
if (existing) {
|
||||
logger.debug({
|
||||
merchantId,
|
||||
nonce,
|
||||
}, 'Failed API key authentication attempt due to replay attack detected')
|
||||
throw new UnauthenticatedError('Duplicate request detected, ensure your nonce is unique')
|
||||
}
|
||||
|
||||
const lock = await acquireLock(lockKey(merchantId, nonce), 5000)
|
||||
if (!lock) {
|
||||
logger.debug({
|
||||
merchantId,
|
||||
nonce,
|
||||
}, 'Failed API key authentication attempt due to concurrent nonce contention')
|
||||
throw new UnauthenticatedError('Duplicate request detected, ensure your nonce is unique')
|
||||
}
|
||||
|
||||
try {
|
||||
const concurrent = await get(key)
|
||||
if (concurrent) {
|
||||
throw new UnauthenticatedError('Duplicate request detected, ensure your nonce is unique')
|
||||
}
|
||||
await set(key, '1', apiAuth.skewSeconds)
|
||||
} finally {
|
||||
await lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticate(
|
||||
headers: Record<string, string | undefined>,
|
||||
payload: unknown,
|
||||
): Promise<void> {
|
||||
const apiKey = headers['x-api-key']
|
||||
const nonce = headers['x-nonce']
|
||||
const signature = headers['x-signature']
|
||||
const rawTimestamp = headers['x-timestamp']
|
||||
|
||||
const missingHeaders: string[] = []
|
||||
if (!apiKey) missingHeaders.push('X-API-Key')
|
||||
if (!nonce) missingHeaders.push('X-Nonce')
|
||||
if (!rawTimestamp) missingHeaders.push('X-Timestamp')
|
||||
if (!signature) missingHeaders.push('X-Signature')
|
||||
if (missingHeaders.length > 0) {
|
||||
const missing = missingHeaders.join(', ')
|
||||
logger.debug({
|
||||
'headers.authorization': headers.authorization,
|
||||
}, 'Failed API key authentication attempt due to missing auth headers: ' + missing)
|
||||
throw new UnauthenticatedError('Missing auth headers: ' + missing)
|
||||
}
|
||||
|
||||
const apiKeyStr = apiKey as string
|
||||
const nonceStr = nonce as string
|
||||
const signatureStr = signature as string
|
||||
|
||||
const timestamp = Number(rawTimestamp)
|
||||
if (!Number.isFinite(timestamp)) {
|
||||
logger.debug({
|
||||
'headers.authorization': headers.authorization,
|
||||
}, 'Failed API key authentication attempt due to invalid timestamp')
|
||||
throw new UnauthenticatedError('Invalid timestamp')
|
||||
}
|
||||
const nowSec = Math.floor(Date.now() / 1000)
|
||||
if (Math.abs(nowSec - timestamp) > apiAuth.skewSeconds) {
|
||||
logger.debug({
|
||||
'headers.authorization': headers.authorization,
|
||||
}, 'Failed API key authentication attempt due to timestamp out of range')
|
||||
throw new UnauthenticatedError('Timestamp out of range')
|
||||
}
|
||||
|
||||
const merchant = findMerchant(apiKeyStr)
|
||||
if (!merchant) {
|
||||
logger.debug({
|
||||
'headers.authorization': headers.authorization,
|
||||
}, 'Failed API key authentication attempt due to invalid API key')
|
||||
throw new UnauthenticatedError('Invalid API key')
|
||||
}
|
||||
|
||||
await enforceRateLimit(merchant.merchantId)
|
||||
await enforceUniqueNonce(merchant.merchantId, nonceStr)
|
||||
|
||||
if (!verify(payload, timestamp, nonceStr, signatureStr, merchant.secretKey)) {
|
||||
logger.debug({
|
||||
'headers.authorization': headers.authorization,
|
||||
}, 'Failed API key authentication attempt due to invalid signature')
|
||||
throw new UnauthenticatedError('Invalid signature')
|
||||
}
|
||||
}
|
||||
|
||||
export const apiKeyAuthRedisMacro = new Elysia().macro({
|
||||
verifyKeyRedis: (enabled: boolean) => ({
|
||||
async beforeHandle({ headers, body }) {
|
||||
if (!enabled) return
|
||||
await authenticate(headers as Record<string, string | undefined>, body)
|
||||
},
|
||||
}),
|
||||
verifyKeyQueryRedis: (enabled: boolean) => ({
|
||||
async beforeHandle({ headers, query }) {
|
||||
if (!enabled) return
|
||||
await authenticate(headers as Record<string, string | undefined>, query)
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export default apiKeyAuthRedisMacro
|
||||
@@ -1,22 +1,24 @@
|
||||
import { HTTPHeaders, StatusMap } from 'elysia'
|
||||
import { Elysia } from 'elysia'
|
||||
import { basicAuth as credential } from '~/config'
|
||||
import { UnauthenticatedError } from '~/helpers/errors'
|
||||
import { logger } from '~/plugins'
|
||||
|
||||
export const basicAuthMiddleware = (
|
||||
headers: Record<string, string | undefined>,
|
||||
set: {
|
||||
headers: HTTPHeaders
|
||||
status?: number | keyof StatusMap
|
||||
},
|
||||
) => {
|
||||
const expectedAuth = Buffer.from(`${credential.username}:${credential.password}`).toString('base64')
|
||||
if (headers.authorization !== `Basic ${expectedAuth}`) {
|
||||
logger.debug({
|
||||
'headers.authorization': headers.authorization,
|
||||
}, 'Failed basic authentication attempt')
|
||||
set.status = 401
|
||||
set.headers['WWW-Authenticate'] = 'Basic realm="Restricted Area"'
|
||||
throw new UnauthenticatedError('Unauthenticated.')
|
||||
}
|
||||
}
|
||||
export const basicAuthMacro = new Elysia()
|
||||
.macro({
|
||||
verifyBasicAuth: (enabled: boolean) => ({
|
||||
beforeHandle({ set, headers }) {
|
||||
if (!enabled) return
|
||||
|
||||
const expectedAuth = Buffer.from(`${credential.username}:${credential.password}`).toString('base64')
|
||||
|
||||
if (headers.authorization !== `Basic ${expectedAuth}`) {
|
||||
logger.debug({
|
||||
'headers.authorization': headers.authorization,
|
||||
}, 'Failed basic authentication attempt')
|
||||
set.status = 401
|
||||
set.headers['WWW-Authenticate'] = 'Basic realm="Restricted Area"'
|
||||
throw new UnauthenticatedError('Unauthenticated.')
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { basicAuthMiddleware } from './basicAuth'
|
||||
export { basicAuthMacro } from './basicAuth'
|
||||
export { bearerAuthMiddleware } from './bearerAuth'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Elysia, t } from 'elysia'
|
||||
import { createMerchantBody, createMerchantResponseSchema } from './schema'
|
||||
import CreateMerchantService from './commands/service'
|
||||
import { basicAuthMiddleware } from '~/middlewares/basicAuth'
|
||||
import { basicAuthMacro } from '~/middlewares/basicAuth'
|
||||
import { DataAlreadyExistsError } from '~/helpers/errors'
|
||||
import { apiKeyAuthMacro } from '~/middlewares/apiKeyAuth'
|
||||
|
||||
@@ -10,9 +10,8 @@ export const router = new Elysia({
|
||||
detail: { tags: ['Auth'] },
|
||||
prefix: '/auth',
|
||||
})
|
||||
.post('/get-key', async ({ body, headers, set }) => {
|
||||
basicAuthMiddleware(headers, set)
|
||||
|
||||
.use(basicAuthMacro)
|
||||
.post('/get-key', async ({ body, set }) => {
|
||||
try {
|
||||
const result = await CreateMerchantService.createMerchant(body)
|
||||
return {
|
||||
@@ -39,6 +38,7 @@ export const router = new Elysia({
|
||||
response: createMerchantResponseSchema,
|
||||
detail: {
|
||||
security: [{ basicAuth: [] }],
|
||||
verifyBasicAuth: true,
|
||||
},
|
||||
})
|
||||
.use(apiKeyAuthMacro)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { dataMapping } from '~/helpers/utils'
|
||||
import { getDeviceCommand } from '~/helpers/utils'
|
||||
import { DeviceCommandBody } from '../schema'
|
||||
import { sendCommandToThirdParty } from './command'
|
||||
import { alphabetToNumberString } from '~/helpers/alphabet_conversion'
|
||||
@@ -11,8 +11,8 @@ export default abstract class CommandService {
|
||||
}> {
|
||||
const unitNumber = alphabetToNumberString(body.unitNumber)
|
||||
const topicData = `${body.merchantName}/${body.floorName}_${unitNumber}-${body.deviceName}-${body.roomName}-${body.deviceType}-${body.commandType}-${body.towerNumber}`
|
||||
const cmd = dataMapping.find((device) => device.deviceName == body.deviceName)
|
||||
const request = await sendCommandToThirdParty(topicData, `${cmd?.command}${body.payload.action}`)
|
||||
const cmd = getDeviceCommand(body.deviceName, body.deviceType)
|
||||
const request = await sendCommandToThirdParty(topicData, `${cmd}${body.payload.action}`)
|
||||
if (request.status !== 200 || request.data.status == 400) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { alphabetToNumberString } from '~/helpers/alphabet_conversion'
|
||||
import { alphabetToNumberString, convertNumberStringToAlphabet } from '~/helpers/alphabet_conversion'
|
||||
import { sendQueryToThirdParty } from '../commands/command'
|
||||
import { DeviceQueryParams } from '../schema'
|
||||
|
||||
@@ -39,13 +39,15 @@ export default abstract class QueryService {
|
||||
message: 'command accepted',
|
||||
data: {
|
||||
floorName: request.data.data.floor,
|
||||
unitNumber: request.data.data.unit,
|
||||
unitNumber: convertNumberStringToAlphabet(request.data.data.unit),
|
||||
deviceName: request.data.data.device,
|
||||
roomName: request.data.data.room,
|
||||
deviceType: request.data.data.devType,
|
||||
code: request.data.data.code,
|
||||
towerNumber: request.data.data.tower,
|
||||
payload: request.data.data.payload?.split('=')[1],
|
||||
payload: request.data.data.payload?.includes('=')
|
||||
? request.data.data.payload?.split('=')[1]
|
||||
: request.data.data.payload || '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { httpConfig } from '~/config'
|
||||
import logger from '~/plugins/logger'
|
||||
import { PatchTopic, PostDeletetopic } from '../schema'
|
||||
|
||||
export async function postTopic(payload: PostDeletetopic) {
|
||||
try {
|
||||
const request = await fetch(`${httpConfig.baseUrl}/addTopic`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (request.status != 200) {
|
||||
return {
|
||||
status: request.status,
|
||||
message: request.statusText,
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: request.status,
|
||||
message: request.statusText,
|
||||
data: await request.json(),
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(e, 'ERROR')
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function delTopic(payload: PostDeletetopic) {
|
||||
try {
|
||||
const request = await fetch(`${httpConfig.baseUrl}/deleteTopic`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (request.status != 200) {
|
||||
return {
|
||||
status: request.status,
|
||||
message: request.statusText,
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: request.status,
|
||||
message: request.statusText,
|
||||
data: await request.json(),
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export async function patchTopic(payload: PatchTopic) {
|
||||
try {
|
||||
const request = await fetch(`${httpConfig.baseUrl}/editTopic`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (request.status != 200) {
|
||||
return {
|
||||
status: request.status,
|
||||
message: request.statusText,
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: request.status,
|
||||
message: request.statusText,
|
||||
data: await request.json(),
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { PatchTopic, PostDeleteTopicResponseSchema, PostDeletetopic } from '../schema'
|
||||
import { delTopic, patchTopic, postTopic } from './command'
|
||||
export default abstract class CommandService {
|
||||
static async postTopic(body: PostDeletetopic): Promise<PostDeleteTopicResponseSchema> {
|
||||
const postRequest = await postTopic(body)
|
||||
if (postRequest.status != 200) {
|
||||
return {
|
||||
status: postRequest.status,
|
||||
message: postRequest.message,
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
message: 'Create topic success',
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
|
||||
static async delTopic(body: PostDeletetopic): Promise<PostDeleteTopicResponseSchema> {
|
||||
const postRequest = await delTopic(body)
|
||||
if (postRequest.status != 200) {
|
||||
return {
|
||||
status: postRequest.status,
|
||||
message: postRequest.message,
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
message: 'Delete topic success',
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
|
||||
static async patchTopic(body: PatchTopic): Promise<PostDeleteTopicResponseSchema> {
|
||||
const postRequest = await patchTopic(body)
|
||||
if (postRequest.status != 200) {
|
||||
return {
|
||||
status: postRequest.status,
|
||||
message: postRequest.message,
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
message: 'Update topic success',
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { httpConfig } from '~/config'
|
||||
import logger from '~/plugins/logger'
|
||||
import { ListTopics } from '../schema'
|
||||
|
||||
export async function getCommands(payload: ListTopics) {
|
||||
try {
|
||||
const qParams = new URLSearchParams(payload).toString()
|
||||
const request = await fetch(`${httpConfig.baseUrl}/listCommand?${qParams}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
if (request.status != 200) {
|
||||
return {
|
||||
status: request.status,
|
||||
message: request.statusText,
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: request.status,
|
||||
message: request.statusText,
|
||||
data: await request.json(),
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStateReply(payload: ListTopics) {
|
||||
try {
|
||||
const qParams = new URLSearchParams(payload).toString()
|
||||
const request = await fetch(`${httpConfig.baseUrl}/listState-reply?${qParams}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
if (request.status != 200) {
|
||||
return {
|
||||
status: request.status,
|
||||
message: request.statusText,
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: request.status,
|
||||
message: request.statusText,
|
||||
data: await request.json(),
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ListTopics } from '../schema'
|
||||
import { getCommands, getStateReply } from './query'
|
||||
|
||||
export default abstract class QueryService {
|
||||
static async getCommands(payload: ListTopics) {
|
||||
const result = await getCommands(payload)
|
||||
if (result.status != 200) {
|
||||
return {
|
||||
status: result.status,
|
||||
message: result.message,
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
message: 'Get commands success',
|
||||
data: result.data.data,
|
||||
}
|
||||
}
|
||||
|
||||
static async getStateReply(payload: ListTopics) {
|
||||
const result = await getStateReply(payload)
|
||||
if (result.status != 200) {
|
||||
return {
|
||||
status: result.status,
|
||||
message: result.message,
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
message: 'Get state-reply success',
|
||||
data: result.data.data,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Elysia } from 'elysia'
|
||||
import CommandService from './commands/service'
|
||||
import { listTopicResponseSchema, listTopics, patchTopic, postDeleteTopic, postDeleteTopicResponseSchema } from './schema'
|
||||
import QueryService from './queries/service'
|
||||
import { basicAuthMacro } from '../../middlewares/basicAuth'
|
||||
|
||||
export const router = new Elysia({
|
||||
name: 'modules.topics',
|
||||
detail: { tags: ['Topics'] },
|
||||
prefix: '/topics/v1',
|
||||
})
|
||||
.use(basicAuthMacro)
|
||||
.get('/state-reply', async ({ query, set }) => {
|
||||
const result = await QueryService.getStateReply(query)
|
||||
if (result.status != 200) {
|
||||
set.status = result.status
|
||||
return result
|
||||
}
|
||||
return result
|
||||
}, {
|
||||
query: listTopics,
|
||||
response: listTopicResponseSchema,
|
||||
verifyBasicAuth: true,
|
||||
})
|
||||
.get('/commands', async ({ query, set }) => {
|
||||
const result = await QueryService.getCommands(query)
|
||||
if (result.status != 200) {
|
||||
set.status = result.status
|
||||
return result
|
||||
}
|
||||
return result
|
||||
}, {
|
||||
query: listTopics,
|
||||
response: listTopicResponseSchema,
|
||||
verifyBasicAuth: true,
|
||||
})
|
||||
.post('/', async ({ body, set }) => {
|
||||
const result = await CommandService.postTopic(body)
|
||||
if (result.status != 200) {
|
||||
set.status = result.status
|
||||
return result
|
||||
}
|
||||
return result
|
||||
}, {
|
||||
body: postDeleteTopic,
|
||||
response: postDeleteTopicResponseSchema,
|
||||
verifyBasicAuth: true,
|
||||
})
|
||||
.patch('/', async ({ body, set }) => {
|
||||
const result = await CommandService.patchTopic(body)
|
||||
if (result.status != 200) {
|
||||
set.status = result.status
|
||||
return result
|
||||
}
|
||||
return result
|
||||
}, {
|
||||
verifyBasicAuth: true,
|
||||
body: patchTopic,
|
||||
response: postDeleteTopicResponseSchema,
|
||||
})
|
||||
.delete('/', async ({ body, set }) => {
|
||||
const result = await CommandService.delTopic(body)
|
||||
if (result.status != 200) {
|
||||
set.status = result.status
|
||||
return result
|
||||
}
|
||||
return result
|
||||
}, {
|
||||
body: postDeleteTopic,
|
||||
response: postDeleteTopicResponseSchema,
|
||||
verifyBasicAuth: true,
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const postDeleteTopic = z.object({
|
||||
topic: z.string(),
|
||||
type: z.enum(['state-reply', 'command']),
|
||||
})
|
||||
export type PostDeletetopic = z.infer<typeof postDeleteTopic>
|
||||
|
||||
export const patchTopic = z.object({
|
||||
oldTopic: z.string(),
|
||||
newTopic: z.string(),
|
||||
type: z.enum(['state-reply', 'command']),
|
||||
})
|
||||
export type PatchTopic = z.infer<typeof patchTopic>
|
||||
|
||||
export const listTopics = z.object({
|
||||
topic: z.string().optional(),
|
||||
})
|
||||
export type ListTopics = z.infer<typeof listTopics>
|
||||
|
||||
export const postDeleteTopicResponseSchema = z.object({
|
||||
status: z.number(),
|
||||
message: z.string(),
|
||||
data: z.any().nullable(),
|
||||
})
|
||||
export type PostDeleteTopicResponseSchema = z.infer<typeof postDeleteTopicResponseSchema>
|
||||
|
||||
export const listTopicResponseSchema = z.object({
|
||||
status: z.number(),
|
||||
data: z.array(
|
||||
z.object({
|
||||
topic: z.string().nullable(),
|
||||
}).nullable(),
|
||||
).nullable(),
|
||||
})
|
||||
export type ListTopicResponseSchema = z.infer<typeof listTopicResponseSchema>
|
||||
|
||||
export const postDeleteTopicRequestSchema = {
|
||||
body: postDeleteTopic,
|
||||
response: {
|
||||
200: postDeleteTopicResponseSchema,
|
||||
400: postDeleteTopicResponseSchema,
|
||||
401: postDeleteTopicResponseSchema,
|
||||
},
|
||||
}
|
||||
|
||||
export const listTopicRequestSchema = {
|
||||
query: listTopics,
|
||||
response: {
|
||||
200: listTopicResponseSchema,
|
||||
400: listTopicResponseSchema,
|
||||
401: listTopicResponseSchema,
|
||||
},
|
||||
}
|
||||
@@ -44,6 +44,11 @@ export const defaultHandlerPlugin = new Elysia({
|
||||
message: error.message,
|
||||
})
|
||||
|
||||
case 'RATE_LIMIT':
|
||||
return status(429, {
|
||||
message: error.message,
|
||||
})
|
||||
|
||||
case 'FIELD_VALIDATION':
|
||||
return status(422, handleFieldValidationError(error))
|
||||
|
||||
|
||||
@@ -19,11 +19,12 @@ export const swaggerPlugin = openapi({
|
||||
'tags': [
|
||||
{ name: 'Auth', description: 'Auth API' },
|
||||
{ name: 'Device', description: 'Device API' },
|
||||
{ name: 'Topics', description: 'Management Topic' },
|
||||
],
|
||||
// --- Grouping di atas tag ---
|
||||
'x-tagGroups': [
|
||||
{ name: 'Auth', tags: ['Auth'] },
|
||||
{ name: 'Device', tags: ['Device'] },
|
||||
{ name: 'Device', tags: ['Device', 'Topics'] },
|
||||
],
|
||||
'components': {
|
||||
securitySchemes: {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"module": "es2022", /* Specify what module code is generated. */
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
"ignoreDeprecations": "6.0", /* Silence deprecation warnings for deprecated compiler options. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
"paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
"~/*": ["./src/*"],
|
||||
|
||||
Reference in New Issue
Block a user