mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-13 01:05:37 +02:00
fix: Move more directories
This commit is contained in:
40
cloud/packages/functions/src/api/account.ts
Normal file
40
cloud/packages/functions/src/api/account.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Hono } from "hono";
|
||||
import { notPublic } from "./utils";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { ErrorResponses, Result } from "./utils";
|
||||
import { Account } from "@nestri/core/account/index";
|
||||
|
||||
export namespace AccountApi {
|
||||
export const route = new Hono()
|
||||
.use(notPublic)
|
||||
.get("/",
|
||||
describeRoute({
|
||||
tags: ["Account"],
|
||||
summary: "Get user account",
|
||||
description: "Get the current user's account details",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Account.Info.openapi({
|
||||
description: "User account information",
|
||||
example: { ...Examples.User, profiles: [Examples.SteamAccount] }
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "User account details"
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
404: ErrorResponses[404],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
async (c) =>
|
||||
c.json({
|
||||
data: await Account.list()
|
||||
}, 200)
|
||||
)
|
||||
}
|
||||
93
cloud/packages/functions/src/api/friend.ts
Normal file
93
cloud/packages/functions/src/api/friend.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { z } from "zod"
|
||||
import { Hono } from "hono";
|
||||
import { validator } from "hono-openapi/zod";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { Friend } from "@nestri/core/friend/index";
|
||||
import { ErrorResponses, notPublic, Result } from "./utils";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
|
||||
export namespace FriendApi {
|
||||
export const route = new Hono()
|
||||
.use(notPublic)
|
||||
.get("/",
|
||||
describeRoute({
|
||||
tags: ["Friend"],
|
||||
summary: "List friends accounts",
|
||||
description: "List all this user's friends accounts",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Friend.Info.array().openapi({
|
||||
description: "All friends accounts",
|
||||
example: [Examples.Friend]
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Friends accounts details"
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
404: ErrorResponses[404],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
async (c) =>
|
||||
c.json({
|
||||
data: await Friend.list()
|
||||
})
|
||||
)
|
||||
.get("/:id",
|
||||
describeRoute({
|
||||
tags: ["Friend"],
|
||||
summary: "Get a friend",
|
||||
description: "Get a friend's details by their SteamID",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Friend.Info.openapi({
|
||||
description: "Friend's accounts",
|
||||
example: Examples.Friend
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Friends accounts details"
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
404: ErrorResponses[404],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: z.string().openapi({
|
||||
description: "ID of the friend to get",
|
||||
example: Examples.Friend.id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const friendSteamID = c.req.valid("param").id
|
||||
|
||||
const friend = await Friend.fromFriendID(friendSteamID)
|
||||
|
||||
if (!friend) {
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
`Friend ${friendSteamID} not found`
|
||||
)
|
||||
}
|
||||
|
||||
return c.json({
|
||||
data: friend
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
92
cloud/packages/functions/src/api/game.ts
Normal file
92
cloud/packages/functions/src/api/game.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { z } from "zod"
|
||||
import { Hono } from "hono";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { Game } from "@nestri/core/game/index";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { Library } from "@nestri/core/library/index";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
import { ErrorResponses, notPublic, Result, validator } from "./utils";
|
||||
|
||||
export namespace GameApi {
|
||||
export const route = new Hono()
|
||||
.use(notPublic)
|
||||
.get("/",
|
||||
describeRoute({
|
||||
tags: ["Game"],
|
||||
summary: "List games",
|
||||
description: "List all the games on this user's library",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Game.Info.array().openapi({
|
||||
description: "All games in the library",
|
||||
example: [Examples.Game]
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "All games in the library"
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
404: ErrorResponses[404],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
async (c) =>
|
||||
c.json({
|
||||
data: await Library.list()
|
||||
})
|
||||
)
|
||||
.get("/:id",
|
||||
describeRoute({
|
||||
tags: ["Game"],
|
||||
summary: "Get game",
|
||||
description: "Get a game by its id, it does not have to be in user's library",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Game.Info.openapi({
|
||||
description: "Game details",
|
||||
example: Examples.Game
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Game details"
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: z.string().openapi({
|
||||
description: "ID of the game to get",
|
||||
example: Examples.Game.id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const gameID = c.req.valid("param").id
|
||||
|
||||
const game = await Game.fromID(gameID)
|
||||
|
||||
if (!game) {
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
`Game ${gameID} does not exist`
|
||||
)
|
||||
}
|
||||
|
||||
return c.json({
|
||||
data: game
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
96
cloud/packages/functions/src/api/index.ts
Normal file
96
cloud/packages/functions/src/api/index.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import "zod-openapi/extend";
|
||||
import { Hono } from "hono";
|
||||
import { GameApi } from "./game";
|
||||
import { SteamApi } from "./steam";
|
||||
import { auth } from "./utils/auth";
|
||||
import { FriendApi } from "./friend";
|
||||
import { logger } from "hono/logger";
|
||||
import { AccountApi } from "./account";
|
||||
import { openAPISpecs } from "hono-openapi";
|
||||
import { patchLogger } from "../utils/patch-logger";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { handle, streamHandle } from "hono/aws-lambda";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
|
||||
patchLogger();
|
||||
|
||||
export const app = new Hono();
|
||||
app
|
||||
.use(logger())
|
||||
.use(async (c, next) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
return next();
|
||||
})
|
||||
.use(auth)
|
||||
|
||||
const routes = app
|
||||
.get("/", (c) => c.text("Hello World!"))
|
||||
.route("/games", GameApi.route)
|
||||
.route("/steam", SteamApi.route)
|
||||
.route("/friends", FriendApi.route)
|
||||
.route("/account", AccountApi.route)
|
||||
.onError((error, c) => {
|
||||
if (error instanceof VisibleError) {
|
||||
console.error("api error:", error);
|
||||
// @ts-expect-error
|
||||
return c.json(error.toResponse(), error.statusCode());
|
||||
}
|
||||
// Handle HTTP exceptions
|
||||
if (error instanceof HTTPException) {
|
||||
console.error("http error:", error);
|
||||
return c.json(
|
||||
{
|
||||
type: "validation",
|
||||
code: ErrorCodes.Validation.INVALID_PARAMETER,
|
||||
message: "Invalid request",
|
||||
},
|
||||
error.status,
|
||||
);
|
||||
}
|
||||
console.error("unhandled error:", error);
|
||||
return c.json(
|
||||
{
|
||||
type: "internal",
|
||||
code: ErrorCodes.Server.INTERNAL_ERROR,
|
||||
message: "Internal server error",
|
||||
},
|
||||
500,
|
||||
);
|
||||
});
|
||||
|
||||
app.get(
|
||||
"/doc",
|
||||
openAPISpecs(routes, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: "Nestri API",
|
||||
description: "The Nestri API gives you the power to run your own customized cloud gaming platform.",
|
||||
version: "0.0.1",
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
Bearer: {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "JWT",
|
||||
},
|
||||
TeamID: {
|
||||
type: "apiKey",
|
||||
description: "The steam ID to use for this query",
|
||||
in: "header",
|
||||
name: "x-nestri-steam"
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [{ Bearer: [], TeamID: [] }],
|
||||
servers: [
|
||||
{ description: "Production", url: "https://api.nestri.io" },
|
||||
{ description: "Sandbox", url: "https://api.dev.nestri.io" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export type Routes = typeof routes;
|
||||
|
||||
export const handler = process.env.SST_LIVE ? handle(app) : streamHandle(app);
|
||||
28
cloud/packages/functions/src/api/realtime/actor-core.ts
Normal file
28
cloud/packages/functions/src/api/realtime/actor-core.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { actor } from "actor-core";
|
||||
|
||||
// Define a chat room actor
|
||||
const chatRoom = actor({
|
||||
// Initialize state when the actor is first created
|
||||
createState: () => ({
|
||||
messages: [] as any[],
|
||||
}),
|
||||
|
||||
// Define actions clients can call
|
||||
actions: {
|
||||
// Action to send a message
|
||||
sendMessage: (c, sender, text) => {
|
||||
// Update state
|
||||
c.state.messages.push({ sender, text });
|
||||
|
||||
// Broadcast to all connected clients
|
||||
c.broadcast("newMessage", { sender, text });
|
||||
},
|
||||
|
||||
// Action to get chat history
|
||||
getHistory: (c) => {
|
||||
return c.state.messages;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default chatRoom;
|
||||
28
cloud/packages/functions/src/api/realtime/index.ts
Normal file
28
cloud/packages/functions/src/api/realtime/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { setup } from "actor-core";
|
||||
import chatRoom from "./actor-core";
|
||||
import { createRouter } from "@actor-core/bun";
|
||||
import {
|
||||
FileSystemGlobalState,
|
||||
FileSystemActorDriver,
|
||||
FileSystemManagerDriver,
|
||||
} from "@actor-core/file-system";
|
||||
|
||||
export namespace Realtime {
|
||||
const app = setup({
|
||||
actors: { chatRoom },
|
||||
basePath: "/realtime"
|
||||
});
|
||||
|
||||
const fsState = new FileSystemGlobalState("/tmp");
|
||||
|
||||
const realtimeRouter = createRouter(app, {
|
||||
topology: "standalone",
|
||||
drivers: {
|
||||
manager: new FileSystemManagerDriver(app, fsState),
|
||||
actor: new FileSystemActorDriver(fsState),
|
||||
}
|
||||
});
|
||||
|
||||
export const route = realtimeRouter.router;
|
||||
export const webSocketHandler = realtimeRouter.webSocketHandler;
|
||||
}
|
||||
165
cloud/packages/functions/src/api/steam.ts
Normal file
165
cloud/packages/functions/src/api/steam.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { z } from "zod";
|
||||
import { Hono } from "hono";
|
||||
import { Resource } from "sst";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { User } from "@nestri/core/user/index";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { Steam } from "@nestri/core/steam/index";
|
||||
import { getCookie, setCookie } from "hono/cookie";
|
||||
import { Client } from "@nestri/core/client/index";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
import { ErrorResponses, validator, Result, notPublic } from "./utils";
|
||||
|
||||
export namespace SteamApi {
|
||||
export const route = new Hono()
|
||||
.get("/",
|
||||
describeRoute({
|
||||
tags: ["Steam"],
|
||||
summary: "List Steam accounts",
|
||||
description: "List all Steam accounts belonging to this user",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Steam.Info.array().openapi({
|
||||
description: "All linked Steam accounts",
|
||||
example: [Examples.SteamAccount]
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Linked Steam accounts details"
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
notPublic,
|
||||
async (c) =>
|
||||
c.json({
|
||||
data: await Steam.list()
|
||||
})
|
||||
)
|
||||
.get("/callback/:id",
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: z.string().openapi({
|
||||
description: "ID of the user to login",
|
||||
example: Examples.User.id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const cookieID = getCookie(c, "user_id");
|
||||
|
||||
const userID = c.req.valid("param").id;
|
||||
|
||||
if (!cookieID || cookieID !== userID) {
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
"You should not be here"
|
||||
);
|
||||
}
|
||||
|
||||
const currentUser = await User.fromID(userID);
|
||||
if (!currentUser) {
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
`User ${userID} not found`
|
||||
)
|
||||
}
|
||||
|
||||
const params = new URL(c.req.url).searchParams;
|
||||
|
||||
// Verify OpenID response and get steamID
|
||||
const steamID = await Client.verifyOpenIDResponse(params);
|
||||
|
||||
// If verification failed, return error
|
||||
if (!steamID) {
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
"Invalid OpenID authentication response"
|
||||
);
|
||||
}
|
||||
|
||||
const user = (await Client.getUserInfo([steamID]))[0];
|
||||
|
||||
if (!user) {
|
||||
throw new VisibleError(
|
||||
"internal",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
"Steam user data is missing"
|
||||
);
|
||||
}
|
||||
|
||||
const wasAdded = await Steam.create({ ...user, userID });
|
||||
|
||||
if (!wasAdded) {
|
||||
// Update the owner of the Steam account
|
||||
await Steam.updateOwner({ userID, steamID })
|
||||
}
|
||||
|
||||
return c.html(
|
||||
`
|
||||
<script>
|
||||
window.location.href = "about:blank";
|
||||
window.close()
|
||||
</script>
|
||||
`
|
||||
)
|
||||
}
|
||||
)
|
||||
.get("/popup/:id",
|
||||
describeRoute({
|
||||
tags: ["Steam"],
|
||||
summary: "Login to Steam",
|
||||
description: "Login to Steam in a popup",
|
||||
responses: {
|
||||
400: ErrorResponses[400],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: z.string().openapi({
|
||||
description: "ID of the user to login",
|
||||
example: Examples.User.id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const userID = c.req.valid("param").id;
|
||||
|
||||
const user = await User.fromID(userID);
|
||||
if (!user) {
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
`User ${userID} not found`
|
||||
)
|
||||
}
|
||||
|
||||
setCookie(c, "user_id", user.id);
|
||||
|
||||
const returnUrl = `${new URL(Resource.Urls.api).origin}/steam/callback/${userID}`
|
||||
|
||||
const params = new URLSearchParams({
|
||||
'openid.ns': 'http://specs.openid.net/auth/2.0',
|
||||
'openid.mode': 'checkid_setup',
|
||||
'openid.return_to': returnUrl,
|
||||
'openid.realm': new URL(returnUrl).origin,
|
||||
'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select',
|
||||
'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select',
|
||||
'user_id': user.id
|
||||
});
|
||||
|
||||
return c.redirect(`https://steamcommunity.com/openid/login?${params.toString()}`, 302)
|
||||
}
|
||||
)
|
||||
}
|
||||
77
cloud/packages/functions/src/api/utils/auth.ts
Normal file
77
cloud/packages/functions/src/api/utils/auth.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Resource } from "sst";
|
||||
import { subjects } from "../../subjects";
|
||||
import { Actor } from "@nestri/core/actor";
|
||||
import { type MiddlewareHandler } from "hono";
|
||||
import { Steam } from "@nestri/core/steam/index";
|
||||
import { createClient } from "@openauthjs/openauth/client";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
|
||||
const client = createClient({
|
||||
clientID: "api",
|
||||
issuer: Resource.Auth.url,
|
||||
});
|
||||
|
||||
export const notPublic: MiddlewareHandler = async (c, next) => {
|
||||
const actor = Actor.use();
|
||||
if (actor.type === "public")
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
"Missing authorization header",
|
||||
);
|
||||
return next();
|
||||
};
|
||||
|
||||
export const auth: MiddlewareHandler = async (c, next) => {
|
||||
const authHeader = c.req.header("authorization");
|
||||
if (!authHeader) return Actor.provide("public", {}, next);
|
||||
const match = authHeader.match(/^Bearer (.+)$/);
|
||||
if (!match) {
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.INVALID_TOKEN,
|
||||
"Invalid personal access token",
|
||||
);
|
||||
}
|
||||
const bearerToken = match[1];
|
||||
let result = await client.verify(subjects, bearerToken!);
|
||||
if (result.err) {
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.INVALID_TOKEN,
|
||||
"Invalid bearer token",
|
||||
);
|
||||
}
|
||||
|
||||
if (result.subject.type === "user") {
|
||||
const steamID = c.req.header("x-nestri-steam");
|
||||
if (!steamID) {
|
||||
return Actor.provide(result.subject.type, result.subject.properties, next);
|
||||
}
|
||||
const userID = result.subject.properties.userID
|
||||
return Actor.provide(
|
||||
"steam",
|
||||
{
|
||||
steamID
|
||||
},
|
||||
async () => {
|
||||
const steamAcc = await Steam.confirmOwnerShip(userID)
|
||||
if (!steamAcc) {
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`You don't have permission to access this resource.`
|
||||
)
|
||||
}
|
||||
return Actor.provide(
|
||||
"member",
|
||||
{
|
||||
steamID,
|
||||
userID,
|
||||
},
|
||||
next)
|
||||
});
|
||||
}
|
||||
|
||||
return Actor.provide("public", {}, next);
|
||||
};
|
||||
129
cloud/packages/functions/src/api/utils/error.ts
Normal file
129
cloud/packages/functions/src/api/utils/error.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { ErrorResponse } from "@nestri/core/error";
|
||||
|
||||
export const ErrorResponses = {
|
||||
400: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Validation error",
|
||||
example: {
|
||||
type: "validation",
|
||||
code: "invalid_parameter",
|
||||
message: "The request was invalid",
|
||||
param: "email",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description:
|
||||
"Bad Request - The request could not be understood or was missing required parameters.",
|
||||
},
|
||||
401: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Authentication error",
|
||||
example: {
|
||||
type: "authentication",
|
||||
code: "unauthorized",
|
||||
message: "Authentication required",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description:
|
||||
"Unauthorized - Authentication is required and has failed or has not been provided.",
|
||||
},
|
||||
403: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Permission error",
|
||||
example: {
|
||||
type: "forbidden",
|
||||
code: "permission_denied",
|
||||
message: "You do not have permission to access this resource",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description:
|
||||
"Forbidden - You do not have permission to access this resource.",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Not found error",
|
||||
example: {
|
||||
type: "not_found",
|
||||
code: "resource_not_found",
|
||||
message: "The requested resource could not be found",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Not Found - The requested resource does not exist.",
|
||||
},
|
||||
409: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Conflict Error",
|
||||
example: {
|
||||
type: "already_exists",
|
||||
code: "resource_already_exists",
|
||||
message: "The resource could not be created because it already exists",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Conflict - The resource could not be created because it already exists.",
|
||||
},
|
||||
429: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Rate limit error",
|
||||
example: {
|
||||
type: "rate_limit",
|
||||
code: "too_many_requests",
|
||||
message: "Rate limit exceeded",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description:
|
||||
"Too Many Requests - You have made too many requests in a short period of time.",
|
||||
},
|
||||
500: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Server error",
|
||||
example: {
|
||||
type: "internal",
|
||||
code: "internal_error",
|
||||
message: "Internal server error",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Internal Server Error - Something went wrong on our end.",
|
||||
},
|
||||
};
|
||||
20
cloud/packages/functions/src/api/utils/hook.ts
Normal file
20
cloud/packages/functions/src/api/utils/hook.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ZodError, ZodSchema, z } from 'zod';
|
||||
import type { Env, ValidationTargets, Context, TypedResponse, Input, MiddlewareHandler } from 'hono';
|
||||
|
||||
type Hook<T, E extends Env, P extends string, Target extends keyof ValidationTargets = keyof ValidationTargets, O = {}> = (result: ({
|
||||
success: true;
|
||||
data: T;
|
||||
} | {
|
||||
success: false;
|
||||
error: ZodError;
|
||||
data: T;
|
||||
}) & {
|
||||
target: Target;
|
||||
}, c: Context<E, P>) => Response | void | TypedResponse<O> | Promise<Response | void | TypedResponse<O>>;
|
||||
type HasUndefined<T> = undefined extends T ? true : false;
|
||||
declare const zValidator: <T extends ZodSchema<any, z.ZodTypeDef, any>, Target extends keyof ValidationTargets, E extends Env, P extends string, In = z.input<T>, Out = z.output<T>, I extends Input = {
|
||||
in: HasUndefined<In> extends true ? { [K in Target]?: (In extends ValidationTargets[K] ? In : { [K2 in keyof In]?: ValidationTargets[K][K2] | undefined; }) | undefined; } : { [K_1 in Target]: In extends ValidationTargets[K_1] ? In : { [K2_1 in keyof In]: ValidationTargets[K_1][K2_1]; }; };
|
||||
out: { [K_2 in Target]: Out; };
|
||||
}, V extends I = I>(target: Target, schema: T, hook?: Hook<z.TypeOf<T>, E, P, Target, {}> | undefined) => MiddlewareHandler<E, P, V>;
|
||||
|
||||
export { type Hook, zValidator };
|
||||
4
cloud/packages/functions/src/api/utils/index.ts
Normal file
4
cloud/packages/functions/src/api/utils/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./auth";
|
||||
export * from "./error";
|
||||
export * from "./result";
|
||||
export * from "./validator";
|
||||
6
cloud/packages/functions/src/api/utils/result.ts
Normal file
6
cloud/packages/functions/src/api/utils/result.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
|
||||
export function Result<T extends z.ZodTypeAny>(schema: T) {
|
||||
return resolver(z.object({ data: schema }));
|
||||
}
|
||||
77
cloud/packages/functions/src/api/utils/validator.ts
Normal file
77
cloud/packages/functions/src/api/utils/validator.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { Hook } from "./hook";
|
||||
import { z, ZodSchema } from "zod";
|
||||
import { ErrorCodes } from "@nestri/core/error";
|
||||
import { validator as zodValidator } from "hono-openapi/zod";
|
||||
import type { MiddlewareHandler, ValidationTargets } from "hono";
|
||||
|
||||
type ZodIssueExtended = z.ZodIssue & {
|
||||
expected?: unknown;
|
||||
received?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom validator wrapper around hono-openapi/zod validator that formats errors
|
||||
*/
|
||||
export const validator = <
|
||||
T extends ZodSchema,
|
||||
Target extends keyof ValidationTargets
|
||||
>(
|
||||
target: Target,
|
||||
schema: T
|
||||
): MiddlewareHandler<
|
||||
Record<string, unknown>,
|
||||
string,
|
||||
{
|
||||
in: {
|
||||
[K in Target]: z.input<T>;
|
||||
};
|
||||
out: {
|
||||
[K in Target]: z.output<T>;
|
||||
};
|
||||
}
|
||||
> => {
|
||||
const standardErrorHandler: Hook<z.infer<T>, any, any, Target> = (
|
||||
result,
|
||||
c,
|
||||
) => {
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues || result.error.errors || [];
|
||||
const firstIssue = issues[0];
|
||||
const fieldPath = Array.isArray(firstIssue?.path)
|
||||
? firstIssue.path.join(".")
|
||||
: firstIssue?.path;
|
||||
|
||||
let errorCode = ErrorCodes.Validation.INVALID_PARAMETER;
|
||||
if (firstIssue?.code === "invalid_type" && firstIssue?.received === "undefined") {
|
||||
errorCode = ErrorCodes.Validation.MISSING_REQUIRED_FIELD;
|
||||
} else if (
|
||||
["invalid_string", "invalid_date", "invalid_regex"].includes(firstIssue?.code as string)
|
||||
) {
|
||||
errorCode = ErrorCodes.Validation.INVALID_FORMAT;
|
||||
}
|
||||
|
||||
const response = {
|
||||
type: "validation",
|
||||
code: errorCode,
|
||||
message: firstIssue?.message,
|
||||
param: fieldPath,
|
||||
details: issues.length > 1
|
||||
? {
|
||||
issues: issues.map((issue: ZodIssueExtended) => ({
|
||||
path: Array.isArray(issue.path) ? issue.path.join(".") : issue.path,
|
||||
code: issue.code,
|
||||
message: issue.message,
|
||||
expected: issue.expected,
|
||||
received: issue.received,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
console.log("Validation error in validator:", response);
|
||||
return c.json(response, 400);
|
||||
}
|
||||
};
|
||||
|
||||
return zodValidator(target, schema, standardErrorHandler);
|
||||
};
|
||||
Reference in New Issue
Block a user