Procházet zdrojové kódy

feat(suggest-path): register route endpoint with auth and validation

Add POST /_api/v3/ai-tools/suggest-path endpoint with middleware chain:
accessTokenParser, loginRequiredStrictly, certifyAiService, body
validator, and apiV3FormValidator. Handler invokes generateMemoSuggestion
and returns suggestions array via standard apiv3 response format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
VANELLOPE\tomoyuki-t před 1 měsícem
rodič
revize
d91cacf874

+ 1 - 1
.kiro/specs/suggest-path/tasks.md

@@ -11,7 +11,7 @@
   - Include unit tests covering both user-pages-enabled and user-pages-disabled paths, verifying correct path format, grant value, and description
   - _Requirements: 1.2, 1.3, 2.1, 2.2, 2.3, 2.4, 2.5, 6.1, 6.2_
 
-- [ ] 1.2 Register route endpoint with authentication and validation
+- [x] 1.2 Register route endpoint with authentication and validation
   - Create the route under a new namespace separate from the page API, following the existing handler factory pattern
   - Apply the standard middleware chain: access token parsing, strict login requirement, AI service gating, request body validation
   - Implement the handler to invoke memo suggestion generation for the authenticated user and return the suggestions array using the standard API response format

+ 12 - 0
apps/app/src/server/routes/apiv3/ai-tools/index.ts

@@ -0,0 +1,12 @@
+import express from 'express';
+
+import type Crowi from '~/server/crowi';
+
+import { suggestPathHandlersFactory } from './suggest-path';
+
+const router = express.Router();
+
+export const factory = (crowi: Crowi): express.Router => {
+  router.post('/suggest-path', suggestPathHandlersFactory(crowi));
+  return router;
+};

+ 137 - 0
apps/app/src/server/routes/apiv3/ai-tools/suggest-path.spec.ts

@@ -0,0 +1,137 @@
+import type { Request, RequestHandler } from 'express';
+import type { Mock } from 'vitest';
+
+import type Crowi from '~/server/crowi';
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+
+const mocks = vi.hoisted(() => {
+  return {
+    generateMemoSuggestionMock: vi.fn(),
+    loginRequiredFactoryMock: vi.fn(),
+    certifyAiServiceMock: vi.fn(),
+  };
+});
+
+vi.mock('./generate-memo-suggestion', () => ({
+  generateMemoSuggestion: mocks.generateMemoSuggestionMock,
+}));
+
+vi.mock('~/server/middlewares/login-required', () => ({
+  default: mocks.loginRequiredFactoryMock,
+}));
+
+vi.mock(
+  '~/features/openai/server/routes/middlewares/certify-ai-service',
+  () => ({
+    certifyAiService: mocks.certifyAiServiceMock,
+  }),
+);
+
+vi.mock('~/server/middlewares/access-token-parser', () => ({
+  accessTokenParser: vi.fn(() => vi.fn()),
+}));
+
+vi.mock('~/server/middlewares/apiv3-form-validator', () => ({
+  apiV3FormValidator: vi.fn(),
+}));
+
+describe('suggestPathHandlersFactory', () => {
+  const mockCrowi = {} as unknown as Crowi;
+
+  beforeEach(() => {
+    vi.resetAllMocks();
+    mocks.loginRequiredFactoryMock.mockReturnValue(vi.fn());
+  });
+
+  describe('middleware chain', () => {
+    it('should return an array of request handlers', async () => {
+      const { suggestPathHandlersFactory } = await import('./suggest-path');
+      const handlers = suggestPathHandlersFactory(mockCrowi);
+      expect(Array.isArray(handlers)).toBe(true);
+      expect(handlers.length).toBeGreaterThanOrEqual(5);
+    });
+
+    it('should include certifyAiService in the middleware chain', async () => {
+      const { suggestPathHandlersFactory } = await import('./suggest-path');
+      const handlers = suggestPathHandlersFactory(mockCrowi);
+      expect(handlers).toContain(mocks.certifyAiServiceMock);
+    });
+  });
+
+  describe('handler', () => {
+    const createMockReqRes = () => {
+      const req = {
+        user: { _id: 'user123', username: 'alice' },
+        body: { body: 'Some page content' },
+      } as unknown as Request;
+
+      const res = {
+        apiv3: vi.fn(),
+        apiv3Err: vi.fn(),
+      } as unknown as ApiV3Response;
+
+      return { req, res };
+    };
+
+    it('should call generateMemoSuggestion with the authenticated user', async () => {
+      const memoSuggestion = {
+        type: 'memo',
+        path: '/user/alice/memo/',
+        label: 'Save as memo',
+        description: 'Save to your personal memo area',
+        grant: 4,
+      };
+      mocks.generateMemoSuggestionMock.mockReturnValue(memoSuggestion);
+
+      const { suggestPathHandlersFactory } = await import('./suggest-path');
+      const handlers = suggestPathHandlersFactory(mockCrowi);
+      const handler = handlers[handlers.length - 1] as RequestHandler;
+
+      const { req, res } = createMockReqRes();
+      await handler(req, res, vi.fn());
+
+      expect(mocks.generateMemoSuggestionMock).toHaveBeenCalledWith(req.user);
+    });
+
+    it('should return suggestions array via res.apiv3', async () => {
+      const memoSuggestion = {
+        type: 'memo',
+        path: '/user/alice/memo/',
+        label: 'Save as memo',
+        description: 'Save to your personal memo area',
+        grant: 4,
+      };
+      mocks.generateMemoSuggestionMock.mockReturnValue(memoSuggestion);
+
+      const { suggestPathHandlersFactory } = await import('./suggest-path');
+      const handlers = suggestPathHandlersFactory(mockCrowi);
+      const handler = handlers[handlers.length - 1] as RequestHandler;
+
+      const { req, res } = createMockReqRes();
+      await handler(req, res, vi.fn());
+
+      expect(res.apiv3).toHaveBeenCalledWith({
+        suggestions: [memoSuggestion],
+      });
+    });
+
+    it('should return error when generateMemoSuggestion throws', async () => {
+      mocks.generateMemoSuggestionMock.mockImplementation(() => {
+        throw new Error('Unexpected error');
+      });
+
+      const { suggestPathHandlersFactory } = await import('./suggest-path');
+      const handlers = suggestPathHandlersFactory(mockCrowi);
+      const handler = handlers[handlers.length - 1] as RequestHandler;
+
+      const { req, res } = createMockReqRes();
+      await handler(req, res, vi.fn());
+
+      expect(res.apiv3Err).toHaveBeenCalled();
+      // Should not expose internal error details (Req 9.2)
+      const apiv3ErrMock = res.apiv3Err as Mock;
+      const errorCall = apiv3ErrMock.mock.calls[0];
+      expect(errorCall[0].message).not.toContain('Unexpected error');
+    });
+  });
+});

+ 70 - 0
apps/app/src/server/routes/apiv3/ai-tools/suggest-path.ts

@@ -0,0 +1,70 @@
+import assert from 'node:assert';
+import type { IUserHasId } from '@growi/core/dist/interfaces';
+import { SCOPE } from '@growi/core/dist/interfaces';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import { body } from 'express-validator';
+
+import { certifyAiService } from '~/features/openai/server/routes/middlewares/certify-ai-service';
+import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import loginRequiredFactory from '~/server/middlewares/login-required';
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+import loggerFactory from '~/utils/logger';
+
+import { generateMemoSuggestion } from './generate-memo-suggestion';
+
+const logger = loggerFactory('growi:routes:apiv3:ai-tools:suggest-path');
+
+type ReqBody = {
+  body: string;
+};
+
+type SuggestPathReq = Request<
+  Record<string, string>,
+  ApiV3Response,
+  ReqBody
+> & {
+  user?: IUserHasId;
+};
+
+const validator = [
+  body('body')
+    .isString()
+    .withMessage('body must be a string')
+    .notEmpty()
+    .withMessage('body must not be empty'),
+];
+
+export const suggestPathHandlersFactory = (crowi: Crowi): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+
+  return [
+    accessTokenParser([SCOPE.READ.FEATURES.AI_ASSISTANT], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    certifyAiService,
+    ...validator,
+    apiV3FormValidator,
+    (req: SuggestPathReq, res: ApiV3Response) => {
+      const { user } = req;
+      assert(
+        user != null,
+        'user is required (ensured by loginRequiredStrictly middleware)',
+      );
+
+      try {
+        const memoSuggestion = generateMemoSuggestion(user);
+        return res.apiv3({ suggestions: [memoSuggestion] });
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err(
+          new ErrorV3('Failed to generate path suggestions'),
+          500,
+        );
+      }
+    },
+  ];
+};

+ 3 - 0
apps/app/src/server/routes/apiv3/index.js

@@ -7,6 +7,7 @@ import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import injectUserRegistrationOrderByTokenMiddleware from '../../middlewares/inject-user-registration-order-by-token-middleware';
 import * as loginFormValidator from '../../middlewares/login-form-validator';
 import * as registerFormValidator from '../../middlewares/register-form-validator';
+import { factory as aiToolsRouteFactory } from './ai-tools';
 import g2gTransfer from './g2g-transfer';
 import importRoute from './import';
 import pageListing from './page-listing';
@@ -184,6 +185,8 @@ module.exports = (crowi, app) => {
 
   router.use('/openai', openaiRouteFactory(crowi));
 
+  router.use('/ai-tools', aiToolsRouteFactory(crowi));
+
   router.use('/user', userRouteFactory(crowi));
 
   return [router, routerForAdmin, routerForAuth];