From c5b843fc532b833e81ac018d70937a852a724fcf Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:54:16 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20rpc=20feature=20router#?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/internal/features/featureRpcRouter.ts | 15 ++++ .../handlers/rpc/handleCreateFeatureRpc.ts | 44 +++++++++++ .../handlers/rpc/handleDeleteFeatureRpc.ts | 54 +++++++++++++ .../handlers/rpc/handleGetFeatureRpc.ts | 43 +++++++++++ .../handlers/rpc/handleListFeaturesRpc.ts | 22 ++++++ .../handlers/rpc/handleUpdateFeatureRpc.ts | 75 +++++++++++++++++++ server/src/routers/apiRouter.ts | 3 + server/src/routers/rpcRouter.ts | 7 ++ .../features/rpc/feature-rpc-crud.test.ts | 50 +++++++++++++ 9 files changed, 313 insertions(+) create mode 100644 server/src/internal/features/featureRpcRouter.ts create mode 100644 server/src/internal/features/handlers/rpc/handleCreateFeatureRpc.ts create mode 100644 server/src/internal/features/handlers/rpc/handleDeleteFeatureRpc.ts create mode 100644 server/src/internal/features/handlers/rpc/handleGetFeatureRpc.ts create mode 100644 server/src/internal/features/handlers/rpc/handleListFeaturesRpc.ts create mode 100644 server/src/internal/features/handlers/rpc/handleUpdateFeatureRpc.ts create mode 100644 server/src/routers/rpcRouter.ts create mode 100644 server/tests/integration/features/rpc/feature-rpc-crud.test.ts diff --git a/server/src/internal/features/featureRpcRouter.ts b/server/src/internal/features/featureRpcRouter.ts new file mode 100644 index 000000000..46b14f3aa --- /dev/null +++ b/server/src/internal/features/featureRpcRouter.ts @@ -0,0 +1,15 @@ +import { Hono } from "hono"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { handleCreateFeatureRpc } from "./handlers/rpc/handleCreateFeatureRpc.js"; +import { handleDeleteFeatureRpc } from "./handlers/rpc/handleDeleteFeatureRpc.js"; +import { handleGetFeatureRpc } from "./handlers/rpc/handleGetFeatureRpc.js"; +import { handleListFeaturesRpc } from "./handlers/rpc/handleListFeaturesRpc.js"; +import { handleUpdateFeatureRpc } from "./handlers/rpc/handleUpdateFeatureRpc.js"; + +export const featureRpcRouter = new Hono(); + +featureRpcRouter.post("/features.list", ...handleListFeaturesRpc); +featureRpcRouter.post("/features.get", ...handleGetFeatureRpc); +featureRpcRouter.post("/features.create", ...handleCreateFeatureRpc); +featureRpcRouter.post("/features.update", ...handleUpdateFeatureRpc); +featureRpcRouter.post("/features.delete", ...handleDeleteFeatureRpc); diff --git a/server/src/internal/features/handlers/rpc/handleCreateFeatureRpc.ts b/server/src/internal/features/handlers/rpc/handleCreateFeatureRpc.ts new file mode 100644 index 000000000..ac4627756 --- /dev/null +++ b/server/src/internal/features/handlers/rpc/handleCreateFeatureRpc.ts @@ -0,0 +1,44 @@ +import { + AffectedResource, + ApiVersion, + ApiVersionClass, + CreateFeatureV1ParamsSchema, + dbToApiFeatureV1, + featureV1ToDbFeature, + InternalError, +} from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { createFeature } from "@/internal/features/featureActions/createFeature.js"; + +export const handleCreateFeatureRpc = createRoute({ + body: CreateFeatureV1ParamsSchema, + resource: AffectedResource.Feature, + handler: async (c) => { + const body = c.req.valid("json"); + const ctx = c.get("ctx"); + + // Get backend feature + const feature = featureV1ToDbFeature({ + apiFeature: body, + originalFeature: undefined, + }); + + // Body is now always in the latest V1 format, regardless of API version + const dbFeature = await createFeature({ + ctx, + data: feature, + }); + + if (!dbFeature) { + throw new InternalError({ message: "Insert feature returned null" }); + } + + return c.json( + dbToApiFeatureV1({ + ctx, + dbFeature, + targetVersion: new ApiVersionClass(ApiVersion.V2_1), + }), + ); + }, +}); diff --git a/server/src/internal/features/handlers/rpc/handleDeleteFeatureRpc.ts b/server/src/internal/features/handlers/rpc/handleDeleteFeatureRpc.ts new file mode 100644 index 000000000..0909d9d22 --- /dev/null +++ b/server/src/internal/features/handlers/rpc/handleDeleteFeatureRpc.ts @@ -0,0 +1,54 @@ +import { FeatureNotFoundError, RecaseError } from "@autumn/shared"; +import { z } from "zod/v4"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js"; +import { getCreditSystemsFromFeature } from "../../creditSystemUtils.js"; +import { FeatureService } from "../../FeatureService.js"; + +export const handleDeleteFeatureRpc = createRoute({ + body: z.object({ + feature_id: z.string(), + }), + handler: async (c) => { + const ctx = c.get("ctx"); + const { db, org, features } = ctx; + const { feature_id } = c.req.valid("json"); + + const feature = features.find((f) => f.id === feature_id); + if (!feature) { + throw new FeatureNotFoundError({ featureId: feature_id }); + } + + const creditSystems = getCreditSystemsFromFeature({ + featureId: feature_id, + features, + }); + + if (creditSystems.length > 0) { + throw new RecaseError({ + message: `Feature ${feature_id} is used by credit system ${creditSystems[0].id}`, + }); + } + + // Get prices that use this feature + const ent = await EntitlementService.getByFeature({ + db, + internalFeatureId: feature.internal_id!, + }); + + if (ent) { + throw new RecaseError({ + message: `Feature ${feature_id} is used in a product. You must delete the product first, or archive it instead.`, + }); + } + + await FeatureService.delete({ + db, + orgId: org.id, + featureId: feature_id, + env: ctx.env, + }); + + return c.json({ success: true }); + }, +}); diff --git a/server/src/internal/features/handlers/rpc/handleGetFeatureRpc.ts b/server/src/internal/features/handlers/rpc/handleGetFeatureRpc.ts new file mode 100644 index 000000000..d1267044a --- /dev/null +++ b/server/src/internal/features/handlers/rpc/handleGetFeatureRpc.ts @@ -0,0 +1,43 @@ +import { + AffectedResource, + dbToApiFeatureV1, + ErrCode, + RecaseError, +} from "@autumn/shared"; +import { z } from "zod/v4"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { FeatureService } from "@/internal/features/FeatureService.js"; + +export const handleGetFeatureRpc = createRoute({ + resource: AffectedResource.Feature, + body: z.object({ + feature_id: z.string(), + }), + handler: async (c) => { + const ctx = c.get("ctx"); + const { feature_id } = c.req.valid("json"); + + const feature = await FeatureService.get({ + db: ctx.db, + id: feature_id, + orgId: ctx.org.id, + env: ctx.env, + }); + + if (!feature) { + throw new RecaseError({ + message: `Feature with id ${feature_id} not found`, + code: ErrCode.FeatureNotFound, + statusCode: 404, + }); + } + + const apiFeature = dbToApiFeatureV1({ + ctx, + dbFeature: feature, + targetVersion: ctx.apiVersion, + }); + + return c.json(apiFeature); + }, +}); diff --git a/server/src/internal/features/handlers/rpc/handleListFeaturesRpc.ts b/server/src/internal/features/handlers/rpc/handleListFeaturesRpc.ts new file mode 100644 index 000000000..5c1b0a0d4 --- /dev/null +++ b/server/src/internal/features/handlers/rpc/handleListFeaturesRpc.ts @@ -0,0 +1,22 @@ +import { AffectedResource, dbToApiFeatureV1 } from "@autumn/shared"; +import { z } from "zod/v4"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; + +export const handleListFeaturesRpc = createRoute({ + body: z.object({}), + resource: AffectedResource.Feature, + handler: async (c) => { + c.req.valid("json"); + const ctx = c.get("ctx"); + + const apiFeatures = ctx.features.map((feature) => + dbToApiFeatureV1({ + ctx, + dbFeature: feature, + targetVersion: ctx.apiVersion, + }), + ); + + return c.json({ list: apiFeatures }); + }, +}); diff --git a/server/src/internal/features/handlers/rpc/handleUpdateFeatureRpc.ts b/server/src/internal/features/handlers/rpc/handleUpdateFeatureRpc.ts new file mode 100644 index 000000000..afc0c2483 --- /dev/null +++ b/server/src/internal/features/handlers/rpc/handleUpdateFeatureRpc.ts @@ -0,0 +1,75 @@ +import { + AffectedResource, + ApiVersion, + ApiVersionClass, + dbToApiFeatureV1, + FeatureNotFoundError, + FeatureType, + featureV1ToDbFeatureConfig, + InternalError, + nullish, + RecaseError, + UpdateFeatureV1ParamsSchema, +} from "@autumn/shared"; +import { z } from "zod/v4"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { updateFeature } from "@/internal/features/featureActions/updateFeature.js"; + +const UpdateFeatureV1RpcParamsSchema = UpdateFeatureV1ParamsSchema.extend({ + feature_id: z.string(), +}); + +export const handleUpdateFeatureRpc = createRoute({ + body: UpdateFeatureV1RpcParamsSchema, + resource: AffectedResource.Feature, + handler: async (c) => { + const body = c.req.valid("json"); + const ctx = c.get("ctx"); + + const { feature_id } = body; + const originalFeature = ctx.features.find((f) => f.id === feature_id); + if (!originalFeature) { + throw new FeatureNotFoundError({ featureId: feature_id }); + } + + // If changing type and consumable not provided, throw error + if (body.type === FeatureType.Metered && nullish(body.consumable)) { + throw new RecaseError({ + message: "Consumable is required when changing type to metered", + }); + } + + const newConfig = featureV1ToDbFeatureConfig({ + apiFeature: body, + originalFeature, + }); + + const updatedFeature = await updateFeature({ + ctx, + featureId: feature_id, + updates: { + id: body.id, + name: body.name ?? undefined, + type: body.type, + + config: newConfig, + + archived: body.archived, + event_names: body.event_names, + display: body.display, + }, + }); + + if (!updatedFeature) { + throw new InternalError({ message: "Update feature returned null" }); + } + + return c.json( + dbToApiFeatureV1({ + ctx, + dbFeature: updatedFeature, + targetVersion: new ApiVersionClass(ApiVersion.V2_1), + }), + ); + }, +}); diff --git a/server/src/routers/apiRouter.ts b/server/src/routers/apiRouter.ts index 73839fab7..d1123fea6 100644 --- a/server/src/routers/apiRouter.ts +++ b/server/src/routers/apiRouter.ts @@ -34,6 +34,7 @@ import { honoProductRouter, migrationRouter, } from "../internal/products/productRouter.js"; +import { rpcRouter } from "./rpcRouter.js"; export const apiRouter = new Hono(); @@ -48,6 +49,8 @@ apiRouter.use("*", rateLimitMiddleware); apiRouter.use("*", queryMiddleware()); apiRouter.use("*", idempotencyMiddleware); +apiRouter.route("", rpcRouter); + apiRouter.route("", billingRouter); apiRouter.route("", balancesRouter); apiRouter.route("", migrationRouter); diff --git a/server/src/routers/rpcRouter.ts b/server/src/routers/rpcRouter.ts new file mode 100644 index 000000000..8587ffc89 --- /dev/null +++ b/server/src/routers/rpcRouter.ts @@ -0,0 +1,7 @@ +import { Hono } from "hono"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { featureRpcRouter } from "@/internal/features/featureRpcRouter.js"; + +export const rpcRouter = new Hono(); + +rpcRouter.route("", featureRpcRouter); diff --git a/server/tests/integration/features/rpc/feature-rpc-crud.test.ts b/server/tests/integration/features/rpc/feature-rpc-crud.test.ts new file mode 100644 index 000000000..38c1a9a12 --- /dev/null +++ b/server/tests/integration/features/rpc/feature-rpc-crud.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "bun:test"; +import { ApiFeatureV1Schema, ApiVersion, FeatureType } from "@autumn/shared"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; + +const makeFeatureId = () => + `rpc_feature_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + +test.concurrent(`${chalk.yellowBright("feature-rpc: crud + list")}`, async () => { + const autumn = new AutumnInt({ version: ApiVersion.V2_1 }); + const featureId = makeFeatureId(); + const featureName = "Feature RPC CRUD"; + const updatedName = "Feature RPC CRUD Updated"; + + const created = await autumn.post("/features.create", { + id: featureId, + name: featureName, + type: FeatureType.Boolean, + }); + ApiFeatureV1Schema.parse(created); + expect(created.id).toBe(featureId); + expect(created.name).toBe(featureName); + + const listed = await autumn.post("/features.list", {}); + expect(Array.isArray(listed.list)).toBe(true); + const listedFeature = listed.list.find( + (feature: { id: string }) => feature.id === featureId, + ); + expect(listedFeature).toBeDefined(); + ApiFeatureV1Schema.parse(listedFeature); + + const got = await autumn.post("/features.get", { + feature_id: featureId, + }); + ApiFeatureV1Schema.parse(got); + expect(got.id).toBe(featureId); + + const updated = await autumn.post("/features.update", { + feature_id: featureId, + name: updatedName, + }); + ApiFeatureV1Schema.parse(updated); + expect(updated.id).toBe(featureId); + expect(updated.name).toBe(updatedName); + + const deleted = await autumn.post("/features.delete", { + feature_id: featureId, + }); + expect(deleted).toEqual({ success: true }); +});