diff --git a/docker/auth/realm.json b/docker/auth/realm.json index 06ab6e55..db5f1be0 100644 --- a/docker/auth/realm.json +++ b/docker/auth/realm.json @@ -26,7 +26,7 @@ "oauth2DeviceCodeLifespan": 600, "oauth2DevicePollingInterval": 5, "enabled": true, - "sslRequired": "none", + "sslRequired": "external", "registrationAllowed": true, "registrationEmailAsUsername": false, "rememberMe": true, diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index df06518d..9b843908 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -15,9 +15,9 @@ from rest_framework import serializers from core import choices, enums, models, utils, validators from core.services.ai_services import AI_ACTIONS -from core.services.yprovider_services import ( +from core.services.converter_services import ( ConversionError, - YProviderAPI, + YdocConverter, ) @@ -451,7 +451,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer): language = user.language or language try: - document_content = YProviderAPI().convert(validated_data["content"]) + document_content = YdocConverter().convert(validated_data["content"]) except ConversionError as err: raise serializers.ValidationError( {"content": ["Could not convert content"]} diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 58bf61c0..50ff8baa 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1,6 +1,7 @@ """API endpoints""" # pylint: disable=too-many-lines +import base64 import json import logging import uuid @@ -37,14 +38,14 @@ from rest_framework.permissions import AllowAny from core import authentication, choices, enums, models from core.services.ai_services import AIService from core.services.collaboration_services import CollaborationService -from core.services.yprovider_services import ( +from core.services.converter_services import ( ServiceUnavailableError as YProviderServiceUnavailableError, ) -from core.services.yprovider_services import ( +from core.services.converter_services import ( ValidationError as YProviderValidationError, ) -from core.services.yprovider_services import ( - YProviderAPI, +from core.services.converter_services import ( + YdocConverter, ) from core.tasks.mail import send_ask_for_access_mail from core.utils import extract_attachments, filter_descendants @@ -1534,9 +1535,17 @@ class DocumentViewSet( if base64_content is not None: # Convert using the y-provider service try: - yprovider = YProviderAPI() - result = yprovider.content(base64_content, content_format) - content = result["content"] + yprovider = YdocConverter() + result = yprovider.convert( + base64.b64decode(base64_content), + "application/vnd.yjs.doc", + { + "markdown": "text/markdown", + "html": "text/html", + "json": "application/json", + }[content_format], + ) + content = result except YProviderValidationError as e: return drf_response.Response( {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST diff --git a/src/backend/core/services/yprovider_services.py b/src/backend/core/services/converter_services.py similarity index 61% rename from src/backend/core/services/yprovider_services.py rename to src/backend/core/services/converter_services.py index 09b80f8c..9c79a719 100644 --- a/src/backend/core/services/yprovider_services.py +++ b/src/backend/core/services/converter_services.py @@ -1,6 +1,5 @@ """Y-Provider API services.""" -import json from base64 import b64encode from django.conf import settings @@ -20,8 +19,8 @@ class ServiceUnavailableError(ConversionError): """Raised when the conversion service is unavailable.""" -class YProviderAPI: - """Service class for Y-Provider API operations.""" +class YdocConverter: + """Service class for conversion-related operations.""" @property def auth_header(self): @@ -29,7 +28,7 @@ class YProviderAPI: # Note: Yprovider microservice accepts only raw token, which is not recommended return f"Bearer {settings.Y_PROVIDER_API_KEY}" - def _request(self, url, data, content_type): + def _request(self, url, data, content_type, accept): """Make a request to the Y-Provider API.""" response = requests.post( url, @@ -37,6 +36,7 @@ class YProviderAPI: headers={ "Authorization": self.auth_header, "Content-Type": content_type, + "Accept": accept, }, timeout=settings.CONVERSION_API_TIMEOUT, verify=settings.CONVERSION_API_SECURE, @@ -44,7 +44,9 @@ class YProviderAPI: response.raise_for_status() return response - def convert(self, text): + def convert( + self, text, content_type="text/markdown", accept="application/vnd.yjs.doc" + ): """Convert a Markdown text into our internal format using an external microservice.""" if not text: @@ -54,27 +56,17 @@ class YProviderAPI: response = self._request( f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/", text, - "text/markdown", + content_type, + accept, ) - return b64encode(response.content).decode("utf-8") + if accept == "application/vnd.yjs.doc": + return b64encode(response.content).decode("utf-8") + if accept in {"text/markdown", "text/html"}: + return response.text + if accept == "application/json": + return response.json() + raise ValidationError("Unsupported format") except requests.RequestException as err: raise ServiceUnavailableError( - "Failed to connect to backend service", - ) from err - - def content(self, base64_content, format_type): - """Convert base64 Yjs content to different formats using the y-provider service.""" - - if not base64_content: - raise ValidationError("Input content cannot be empty") - - data = json.dumps({"content": base64_content, "format": format_type}) - try: - response = self._request( - f"{settings.Y_PROVIDER_API_BASE_URL}content/", data, "application/json" - ) - return response.json() - except requests.RequestException as err: - raise ServiceUnavailableError( - "Failed to connect to backend service", + "Failed to connect to conversion service", ) from err diff --git a/src/backend/core/tests/documents/test_api_documents_content.py b/src/backend/core/tests/documents/test_api_documents_content.py index 62b215c9..459bf5a0 100644 --- a/src/backend/core/tests/documents/test_api_documents_content.py +++ b/src/backend/core/tests/documents/test_api_documents_content.py @@ -2,6 +2,7 @@ Tests for Documents API endpoint in impress's core app: content """ +import base64 from unittest.mock import patch import pytest @@ -21,11 +22,11 @@ pytestmark = pytest.mark.django_db ("public", "editor"), ], ) -@patch("core.services.yprovider_services.YProviderAPI.content") +@patch("core.services.converter_services.YdocConverter.convert") def test_api_documents_content_public(mock_content, reach, role): """Anonymous users should be allowed to access content of public documents.""" document = factories.DocumentFactory(link_reach=reach, link_role=role) - mock_content.return_value = {"content": {"some": "data"}} + mock_content.return_value = {"some": "data"} response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/content/") @@ -34,7 +35,11 @@ def test_api_documents_content_public(mock_content, reach, role): assert data["id"] == str(document.id) assert data["title"] == document.title assert data["content"] == {"some": "data"} - mock_content.assert_called_once_with(document.content, "json") + mock_content.assert_called_once_with( + base64.b64decode(document.content), + "application/vnd.yjs.doc", + "application/json", + ) @pytest.mark.parametrize( @@ -52,12 +57,12 @@ def test_api_documents_content_public(mock_content, reach, role): ("authenticated", "editor", None), ], ) -@patch("core.services.yprovider_services.YProviderAPI.content") +@patch("core.services.converter_services.YdocConverter.convert") def test_api_documents_content_not_public(mock_content, reach, doc_role, user_role): """Authenticated users need access to get non-public document content.""" user = factories.UserFactory() document = factories.DocumentFactory(link_reach=reach, link_role=doc_role) - mock_content.return_value = {"content": {"some": "data"}} + mock_content.return_value = {"some": "data"} # First anonymous request should fail client = APIClient() @@ -87,18 +92,26 @@ def test_api_documents_content_not_public(mock_content, reach, doc_role, user_ro assert data["id"] == str(document.id) assert data["title"] == document.title assert data["content"] == {"some": "data"} - mock_content.assert_called_once_with(document.content, "json") + mock_content.assert_called_once_with( + base64.b64decode(document.content), + "application/vnd.yjs.doc", + "application/json", + ) @pytest.mark.parametrize( - "content_format", - ["markdown", "html", "json"], + "content_format, accept", + [ + ("markdown", "text/markdown"), + ("html", "text/html"), + ("json", "application/json"), + ], ) -@patch("core.services.yprovider_services.YProviderAPI.content") -def test_api_documents_content_format(mock_content, content_format): +@patch("core.services.converter_services.YdocConverter.convert") +def test_api_documents_content_format(mock_content, content_format, accept): """Test that the content endpoint returns a specific format.""" document = factories.DocumentFactory(link_reach="public") - mock_content.return_value = {"content": "whatever"} + mock_content.return_value = {"some": "data"} response = APIClient().get( f"/api/v1.0/documents/{document.id!s}/content/?content_format={content_format}" @@ -108,11 +121,13 @@ def test_api_documents_content_format(mock_content, content_format): data = response.json() assert data["id"] == str(document.id) assert data["title"] == document.title - assert data["content"] == "whatever" - mock_content.assert_called_once_with(document.content, content_format) + assert data["content"] == {"some": "data"} + mock_content.assert_called_once_with( + base64.b64decode(document.content), "application/vnd.yjs.doc", accept + ) -@patch("core.services.yprovider_services.YProviderAPI._request") +@patch("core.services.converter_services.YdocConverter._request") def test_api_documents_content_invalid_format(mock_request): """Test that the content endpoint rejects invalid formats.""" document = factories.DocumentFactory(link_reach="public") @@ -124,7 +139,7 @@ def test_api_documents_content_invalid_format(mock_request): mock_request.assert_not_called() -@patch("core.services.yprovider_services.YProviderAPI._request") +@patch("core.services.converter_services.YdocConverter._request") def test_api_documents_content_yservice_error(mock_request): """Test that service errors are handled properly.""" document = factories.DocumentFactory(link_reach="public") @@ -135,7 +150,7 @@ def test_api_documents_content_yservice_error(mock_request): assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR -@patch("core.services.yprovider_services.YProviderAPI._request") +@patch("core.services.converter_services.YdocConverter._request") def test_api_documents_content_nonexistent_document(mock_request): """Test that accessing a nonexistent document returns 404.""" client = APIClient() @@ -146,7 +161,7 @@ def test_api_documents_content_nonexistent_document(mock_request): mock_request.assert_not_called() -@patch("core.services.yprovider_services.YProviderAPI._request") +@patch("core.services.converter_services.YdocConverter._request") def test_api_documents_content_empty_document(mock_request): """Test that accessing an empty document returns empty content.""" document = factories.DocumentFactory(link_reach="public", content="") diff --git a/src/backend/core/tests/documents/test_api_documents_create_for_owner.py b/src/backend/core/tests/documents/test_api_documents_create_for_owner.py index f011559b..346fe407 100644 --- a/src/backend/core/tests/documents/test_api_documents_create_for_owner.py +++ b/src/backend/core/tests/documents/test_api_documents_create_for_owner.py @@ -16,16 +16,16 @@ from rest_framework.test import APIClient from core import factories from core.api.serializers import ServerCreateDocumentSerializer from core.models import Document, Invitation, User -from core.services.yprovider_services import ConversionError, YProviderAPI +from core.services.converter_services import ConversionError, YdocConverter pytestmark = pytest.mark.django_db @pytest.fixture def mock_convert_md(): - """Mock YProviderAPI.convert to return a converted content.""" + """Mock YdocConverter.convert to return a converted content.""" with patch.object( - YProviderAPI, + YdocConverter, "convert", return_value="Converted document content", ) as mock: diff --git a/src/backend/core/tests/test_services_yprovider_services.py b/src/backend/core/tests/test_services_converter_services.py similarity index 65% rename from src/backend/core/tests/test_services_yprovider_services.py rename to src/backend/core/tests/test_services_converter_services.py index 603c2207..086d132b 100644 --- a/src/backend/core/tests/test_services_yprovider_services.py +++ b/src/backend/core/tests/test_services_converter_services.py @@ -1,29 +1,28 @@ """Test y-provider services.""" -import json from base64 import b64decode from unittest.mock import MagicMock, patch import pytest import requests -from core.services.yprovider_services import ( +from core.services.converter_services import ( ServiceUnavailableError, ValidationError, - YProviderAPI, + YdocConverter, ) def test_auth_header(settings): """Test authentication header generation.""" settings.Y_PROVIDER_API_KEY = "test-key" - converter = YProviderAPI() + converter = YdocConverter() assert converter.auth_header == "Bearer test-key" def test_convert_empty_text(): """Should raise ValidationError when text is empty.""" - converter = YProviderAPI() + converter = YdocConverter() with pytest.raises(ValidationError, match="Input text cannot be empty"): converter.convert("") @@ -31,13 +30,13 @@ def test_convert_empty_text(): @patch("requests.post") def test_convert_service_unavailable(mock_post): """Should raise ServiceUnavailableError when service is unavailable.""" - converter = YProviderAPI() + converter = YdocConverter() mock_post.side_effect = requests.RequestException("Connection error") with pytest.raises( ServiceUnavailableError, - match="Failed to connect to backend service", + match="Failed to connect to conversion service", ): converter.convert("test text") @@ -45,7 +44,7 @@ def test_convert_service_unavailable(mock_post): @patch("requests.post") def test_convert_http_error(mock_post): """Should raise ServiceUnavailableError when HTTP error occurs.""" - converter = YProviderAPI() + converter = YdocConverter() mock_response = MagicMock() mock_response.raise_for_status.side_effect = requests.HTTPError("HTTP Error") @@ -53,7 +52,7 @@ def test_convert_http_error(mock_post): with pytest.raises( ServiceUnavailableError, - match="Failed to connect to backend service", + match="Failed to connect to conversion service", ): converter.convert("test text") @@ -68,7 +67,7 @@ def test_convert_full_integration(mock_post, settings): settings.CONVERSION_API_TIMEOUT = 5 settings.CONVERSION_API_CONTENT_FIELD = "content" - converter = YProviderAPI() + converter = YdocConverter() expected_content = b"converted content" mock_response = MagicMock() @@ -85,6 +84,42 @@ def test_convert_full_integration(mock_post, settings): headers={ "Authorization": "Bearer test-key", "Content-Type": "text/markdown", + "Accept": "application/vnd.yjs.doc", + }, + timeout=5, + verify=False, + ) + + +@patch("requests.post") +def test_convert_full_integration_with_specific_headers(mock_post, settings): + """Test successful conversion with specific content type and accept headers.""" + settings.Y_PROVIDER_API_BASE_URL = "http://test.com/" + settings.Y_PROVIDER_API_KEY = "test-key" + settings.CONVERSION_API_ENDPOINT = "conversion-endpoint" + settings.CONVERSION_API_TIMEOUT = 5 + settings.CONVERSION_API_SECURE = False + + converter = YdocConverter() + + expected_response = "# Test Document\n\nThis is test content." + mock_response = MagicMock() + mock_response.text = expected_response + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + result = converter.convert( + b"test_content", "application/vnd.yjs.doc", "text/markdown" + ) + + assert result == expected_response + mock_post.assert_called_once_with( + "http://test.com/conversion-endpoint/", + data=b"test_content", + headers={ + "Authorization": "Bearer test-key", + "Content-Type": "application/vnd.yjs.doc", + "Accept": "text/markdown", }, timeout=5, verify=False, @@ -94,75 +129,20 @@ def test_convert_full_integration(mock_post, settings): @patch("requests.post") def test_convert_timeout(mock_post): """Should raise ServiceUnavailableError when request times out.""" - converter = YProviderAPI() + converter = YdocConverter() mock_post.side_effect = requests.Timeout("Request timed out") with pytest.raises( ServiceUnavailableError, - match="Failed to connect to backend service", + match="Failed to connect to conversion service", ): converter.convert("test text") def test_convert_none_input(): """Should raise ValidationError when input is None.""" - converter = YProviderAPI() + converter = YdocConverter() with pytest.raises(ValidationError, match="Input text cannot be empty"): converter.convert(None) - - -def test_content_empty_content(): - """Should raise ValidationError when content is empty.""" - converter = YProviderAPI() - with pytest.raises(ValidationError, match="Input content cannot be empty"): - converter.content("", "markdown") - - -@patch("requests.post") -def test_content_service_unavailable(mock_post): - """Should raise ServiceUnavailableError when service is unavailable.""" - converter = YProviderAPI() - - mock_post.side_effect = requests.RequestException("Connection error") - - with pytest.raises( - ServiceUnavailableError, - match="Failed to connect to backend service", - ): - converter.content("test_content", "markdown") - - -@patch("requests.post") -def test_content_success(mock_post, settings): - """Test successful content fetch.""" - settings.Y_PROVIDER_API_BASE_URL = "http://test.com/api/" - settings.Y_PROVIDER_API_KEY = "test-key" - settings.CONVERSION_API_TIMEOUT = 5 - settings.CONVERSION_API_SECURE = False - - converter = YProviderAPI() - - expected_response = { - "content": "# Test Document\n\nThis is test content.", - "format": "markdown", - } - mock_response = MagicMock() - mock_response.json.return_value = expected_response - mock_response.raise_for_status.return_value = None - mock_post.return_value = mock_response - - result = converter.content("test_content", "markdown") - - assert result == expected_response - mock_post.assert_called_once_with( - "http://test.com/api/content/", - data=json.dumps({"content": "test_content", "format": "markdown"}), - headers={ - "Authorization": "Bearer test-key", - "Content-Type": "application/json", - }, - timeout=5, - verify=False, - ) diff --git a/src/frontend/servers/y-provider/__tests__/content.test.ts b/src/frontend/servers/y-provider/__tests__/content.test.ts deleted file mode 100644 index 555b453a..00000000 --- a/src/frontend/servers/y-provider/__tests__/content.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { ServerBlockNoteEditor } from '@blocknote/server-util'; -import request from 'supertest'; -import { describe, expect, test, vi } from 'vitest'; -import * as Y from 'yjs'; - -vi.mock('../src/env', async (importOriginal) => { - return { - ...(await importOriginal()), - COLLABORATION_SERVER_ORIGIN: 'http://localhost:3000', - Y_PROVIDER_API_KEY: 'yprovider-api-key', - }; -}); - -import { initApp } from '@/servers'; - -import { - Y_PROVIDER_API_KEY as apiKey, - COLLABORATION_SERVER_ORIGIN as origin, -} from '../src/env'; - -console.error = vi.fn(); - -describe('Content API Tests', () => { - test('POST /api/content with incorrect API key responds with 401', async () => { - const app = initApp(); - - const response = await request(app) - .post('/api/content') - .set('origin', origin) - .set('authorization', 'wrong-api-key') - .set('content-type', 'application/json') - .send({ - content: 'dGVzdA==', // base64 for "test" - format: 'json', - }); - - expect(response.status).toBe(401); - expect(response.body).toStrictEqual({ - error: 'Unauthorized: Invalid API Key', - }); - }); - - test('POST /api/content with incorrect Bearer token responds with 401', async () => { - const app = initApp(); - - const response = await request(app) - .post('/api/content') - .set('origin', origin) - .set('authorization', 'Bearer test-secret-api-key') - .set('content-type', 'application/json') - .send({ - content: 'dGVzdA==', // base64 for "test" - format: 'json', - }); - - expect(response.status).toBe(401); - expect(response.body).toStrictEqual({ - error: 'Unauthorized: Invalid API Key', - }); - }); - - test('POST /api/content with missing content parameter', async () => { - const app = initApp(); - - const response = await request(app) - .post('/api/content') - .set('origin', origin) - .set('authorization', apiKey) - .set('content-type', 'application/json') - .send({ - format: 'json', - }); - - expect(response.status).toBe(400); - expect(response.body).toStrictEqual({ - error: 'Invalid request: missing content', - }); - }); - - test('POST /api/content with empty content', async () => { - const app = initApp(); - - const response = await request(app) - .post('/api/content') - .set('origin', origin) - .set('authorization', apiKey) - .set('content-type', 'application/json') - .send({ - content: '', - format: 'json', - }); - - expect(response.status).toBe(400); - expect(response.body).toStrictEqual({ - error: 'Invalid request: missing content', - }); - }); - - test('POST /api/content with missing format parameter', async () => { - const app = initApp(); - - const response = await request(app) - .post('/api/content') - .set('origin', origin) - .set('authorization', apiKey) - .set('content-type', 'application/json') - .send({ - content: 'dGVzdA==', - }); - - expect(response.status).toBe(400); - expect(response.body).toStrictEqual({ - error: 'Invalid format. Must be one of: json, markdown, html', - }); - }); - - test('POST /api/content with invalid format', async () => { - const app = initApp(); - - const response = await request(app) - .post('/api/content') - .set('origin', origin) - .set('authorization', apiKey) - .set('content-type', 'application/json') - .send({ - content: 'dGVzdA==', - format: 'invalid', - }); - - expect(response.status).toBe(400); - expect(response.body).toStrictEqual({ - error: 'Invalid format. Must be one of: json, markdown, html', - }); - }); - - test.each([ - { authHeader: `Bearer ${apiKey}`, format: 'json' }, - { authHeader: `Bearer ${apiKey}`, format: 'markdown' }, - { authHeader: `Bearer ${apiKey}`, format: 'html' }, - ])( - 'POST /api/content with correct content and format $format with Authorization: $authHeader', - async ({ authHeader, format }) => { - const app = initApp(); - - // Create a simple Yjs document for testing using BlockNote - const editor = ServerBlockNoteEditor.create(); - const markdownContent = '# Test Document\n\nThis is test content.'; - const blocks = await editor.tryParseMarkdownToBlocks(markdownContent); - const yDocument = editor.blocksToYDoc(blocks, 'document-store'); - const yjsUpdate = Y.encodeStateAsUpdate(yDocument); - const base64Content = Buffer.from(yjsUpdate).toString('base64'); - - const response = await request(app) - .post('/api/content') - .set('Origin', origin) - .set('Authorization', authHeader) - .set('content-type', 'application/json') - .send({ - content: base64Content, - format: format, - }); - expect(response.status).toBe(200); - expect(response.body).toHaveProperty('content'); - expect(response.body).toHaveProperty('format', format); - - // Verify the content based on format - if (format === 'json') { - const parsedContent = response.body.content; - expect(Array.isArray(parsedContent)).toBe(true); - expect(parsedContent.length).toBe(2); - expect(parsedContent[0].type).toBe('heading'); - expect(parsedContent[1].type).toBe('paragraph'); - expect(parsedContent[0].content[0].type).toBe('text'); - expect(parsedContent[0].content[0].text).toBe('Test Document'); - expect(parsedContent[1].content[0].type).toBe('text'); - expect(parsedContent[1].content[0].text).toBe('This is test content.'); - } else if (format === 'markdown') { - expect(typeof response.body.content).toBe('string'); - expect(response.body.content.trim()).toBe(markdownContent); - } else if (format === 'html') { - expect(typeof response.body.content).toBe('string'); - expect(response.body.content).toBe( - '

Test Document

This is test content.

', - ); - } - }, - ); - - test('POST /api/content with invalid base64 content returns 500', async () => { - const app = initApp(); - - const response = await request(app) - .post('/api/content') - .set('origin', origin) - .set('authorization', apiKey) - .set('content-type', 'application/json') - .send({ - content: 'invalid-base64-content!@#', - format: 'json', - }); - - expect(response.status).toBe(500); - expect(response.body).toStrictEqual({ - error: 'An error occurred during conversion', - }); - }); -}); diff --git a/src/frontend/servers/y-provider/__tests__/convert.test.ts b/src/frontend/servers/y-provider/__tests__/convert.test.ts index 67de07cf..e8a81c2c 100644 --- a/src/frontend/servers/y-provider/__tests__/convert.test.ts +++ b/src/frontend/servers/y-provider/__tests__/convert.test.ts @@ -121,6 +121,31 @@ describe('Server Tests', () => { }); }); + test('POST /api/convert with unsupported Content-Type returns 415', async () => { + const app = initApp(); + const response = await request(app) + .post('/api/convert') + .set('origin', origin) + .set('authorization', apiKey) + .set('content-type', 'image/png') + .send('randomdata'); + expect(response.status).toBe(415); + expect(response.body).toStrictEqual({ error: 'Unsupported Content-Type' }); + }); + + test('POST /api/convert with unsupported Accept returns 406', async () => { + const app = initApp(); + const response = await request(app) + .post('/api/convert') + .set('origin', origin) + .set('authorization', apiKey) + .set('content-type', 'text/markdown') + .set('accept', 'image/png') + .send('# Header'); + expect(response.status).toBe(406); + expect(response.body).toStrictEqual({ error: 'Unsupported format' }); + }); + test.each([[apiKey], [`Bearer ${apiKey}`]])( 'POST /api/convert with correct content with Authorization: %s', async (authHeader) => { @@ -137,6 +162,8 @@ describe('Server Tests', () => { .post('/api/convert') .set('Origin', origin) .set('Authorization', authHeader) + .set('content-type', 'text/markdown') + .set('accept', 'application/vnd.yjs.doc') .send(document); expect(response.status).toBe(200); @@ -150,4 +177,89 @@ describe('Server Tests', () => { expect(blocks).toStrictEqual(expectedBlocks); }, ); + + test('POST /api/convert Yjs to HTML', async () => { + const app = initApp(); + const editor = ServerBlockNoteEditor.create(); + const markdownContent = '# Test Document\n\nThis is test content.'; + const blocks = await editor.tryParseMarkdownToBlocks(markdownContent); + const yDocument = editor.blocksToYDoc(blocks, 'document-store'); + const yjsUpdate = Y.encodeStateAsUpdate(yDocument); + const response = await request(app) + .post('/api/convert') + .set('origin', origin) + .set('authorization', apiKey) + .set('content-type', 'application/vnd.yjs.doc') + .set('accept', 'text/html') + .send(Buffer.from(yjsUpdate)); + expect(response.status).toBe(200); + expect(response.header['content-type']).toBe('text/html; charset=utf-8'); + expect(typeof response.text).toBe('string'); + expect(response.text).toBe( + '

Test Document

This is test content.

', + ); + }); + + test('POST /api/convert Yjs to Markdown', async () => { + const app = initApp(); + const editor = ServerBlockNoteEditor.create(); + const markdownContent = '# Test Document\n\nThis is test content.'; + const blocks = await editor.tryParseMarkdownToBlocks(markdownContent); + const yDocument = editor.blocksToYDoc(blocks, 'document-store'); + const yjsUpdate = Y.encodeStateAsUpdate(yDocument); + const response = await request(app) + .post('/api/convert') + .set('origin', origin) + .set('authorization', apiKey) + .set('content-type', 'application/vnd.yjs.doc') + .set('accept', 'text/markdown') + .send(Buffer.from(yjsUpdate)); + expect(response.status).toBe(200); + expect(response.header['content-type']).toBe( + 'text/markdown; charset=utf-8', + ); + expect(typeof response.text).toBe('string'); + expect(response.text.trim()).toBe(markdownContent); + }); + + test('POST /api/convert Yjs to JSON', async () => { + const app = initApp(); + const editor = ServerBlockNoteEditor.create(); + const markdownContent = '# Test Document\n\nThis is test content.'; + const blocks = await editor.tryParseMarkdownToBlocks(markdownContent); + const yDocument = editor.blocksToYDoc(blocks, 'document-store'); + const yjsUpdate = Y.encodeStateAsUpdate(yDocument); + const response = await request(app) + .post('/api/convert') + .set('origin', origin) + .set('authorization', apiKey) + .set('content-type', 'application/vnd.yjs.doc') + .set('accept', 'application/json') + .send(Buffer.from(yjsUpdate)); + expect(response.status).toBe(200); + expect(response.header['content-type']).toBe( + 'application/json; charset=utf-8', + ); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body.length).toBe(2); + expect(response.body[0].type).toBe('heading'); + expect(response.body[1].type).toBe('paragraph'); + expect(response.body[0].content[0].type).toBe('text'); + expect(response.body[0].content[0].text).toBe('Test Document'); + expect(response.body[1].content[0].type).toBe('text'); + expect(response.body[1].content[0].text).toBe('This is test content.'); + }); + + test('POST /api/convert with invalid Yjs content returns 400', async () => { + const app = initApp(); + const response = await request(app) + .post('/api/convert') + .set('origin', origin) + .set('authorization', apiKey) + .set('content-type', 'application/vnd.yjs.doc') + .set('accept', 'application/json') + .send(Buffer.from('notvalidyjs')); + expect(response.status).toBe(400); + expect(response.body).toStrictEqual({ error: 'Invalid Yjs content' }); + }); }); diff --git a/src/frontend/servers/y-provider/package.json b/src/frontend/servers/y-provider/package.json index eb86b204..a984ffc1 100644 --- a/src/frontend/servers/y-provider/package.json +++ b/src/frontend/servers/y-provider/package.json @@ -9,7 +9,7 @@ "build": "tsc -p tsconfig.build.json && tsc-alias", "dev": "cross-env COLLABORATION_LOGGING=true && nodemon --config nodemon.json", "start": "node ./dist/start-server.js", - "lint": "eslint . --ext .ts", + "lint": "eslint . --ext .ts --fix", "test": "vitest --run --disable-console-intercept" }, "engines": { diff --git a/src/frontend/servers/y-provider/src/handlers/contentHandler.ts b/src/frontend/servers/y-provider/src/handlers/contentHandler.ts deleted file mode 100644 index 671ec89d..00000000 --- a/src/frontend/servers/y-provider/src/handlers/contentHandler.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { PartialBlock } from '@blocknote/core'; -import { ServerBlockNoteEditor } from '@blocknote/server-util'; -import { Request, Response } from 'express'; -import * as Y from 'yjs'; - -import { logger } from '@/utils'; - -interface ErrorResponse { - error: string; -} - -interface ContentRequest { - content: string; - format: string; -} - -const editor = ServerBlockNoteEditor.create(); - -export const contentHandler = async ( - req: Request, - res: Response, -) => { - const { content, format } = req.body; - - if (!content) { - res.status(400).json({ error: 'Invalid request: missing content' }); - return; - } - - if (!format || !['json', 'markdown', 'html'].includes(format)) { - res - .status(400) - .json({ error: 'Invalid format. Must be one of: json, markdown, html' }); - return; - } - - try { - // Decode base64 content to Uint8Array - const uint8Array = new Uint8Array(Buffer.from(content, 'base64')); - - // Create Yjs document and apply the update - const yDocument = new Y.Doc(); - Y.applyUpdate(yDocument, uint8Array); - - // Convert to blocks - const blocks = editor.yDocToBlocks(yDocument, 'document-store'); - - let result: string | object | null; - - if (!blocks || blocks.length === 0) { - result = null; - } else if (format === 'json') { - result = blocks; - } else if (format === 'markdown') { - result = await editor.blocksToMarkdownLossy(blocks as PartialBlock[]); - } else if (format === 'html') { - result = await editor.blocksToHTMLLossy(blocks as PartialBlock[]); - } else { - res.status(400).json({ error: 'Unsupported format' }); - return; - } - - res.status(200).json({ - content: result, - format: format, - }); - } catch (e) { - logger('content conversion failed:', e); - res.status(500).json({ error: 'An error occurred during conversion' }); - } -}; diff --git a/src/frontend/servers/y-provider/src/handlers/convertHandler.ts b/src/frontend/servers/y-provider/src/handlers/convertHandler.ts index 15bab784..d5e23039 100644 --- a/src/frontend/servers/y-provider/src/handlers/convertHandler.ts +++ b/src/frontend/servers/y-provider/src/handlers/convertHandler.ts @@ -1,3 +1,4 @@ +import { PartialBlock } from '@blocknote/core'; import { ServerBlockNoteEditor } from '@blocknote/server-util'; import { Request, Response } from 'express'; import * as Y from 'yjs'; @@ -12,29 +13,80 @@ const editor = ServerBlockNoteEditor.create(); export const convertHandler = async ( req: Request, - res: Response, + res: Response, ) => { if (!req.body || req.body.length === 0) { res.status(400).json({ error: 'Invalid request: missing content' }); return; } - try { - // Perform the conversion from markdown to Blocknote.js blocks - const blocks = await editor.tryParseMarkdownToBlocks(req.body.toString()); + const contentType = (req.header('content-type') || 'text/markdown').split( + ';', + )[0]; + const accept = (req.header('accept') || 'application/vnd.yjs.doc').split( + ';', + )[0]; + let blocks: PartialBlock[] | null = null; + try { + + // First, convert from the input format to blocks + // application/x-www-form-urlencoded is interpreted as Markdown for backward compatibility + if ( + contentType === 'text/markdown' || + contentType === 'application/x-www-form-urlencoded' + ) { + blocks = await editor.tryParseMarkdownToBlocks(req.body.toString()); + } else if ( + contentType === 'application/vnd.yjs.doc' || + contentType === 'application/octet-stream' + ) { + try { + const ydoc = new Y.Doc(); + Y.applyUpdate(ydoc, req.body); + blocks = editor.yDocToBlocks(ydoc, 'document-store') as PartialBlock[]; + } catch (e) { + logger('Invalid Yjs content:', e); + res.status(400).json({ error: 'Invalid Yjs content' }); + return; + } + } else { + res.status(415).json({ error: 'Unsupported Content-Type' }); + return; + } if (!blocks || blocks.length === 0) { res.status(500).json({ error: 'No valid blocks were generated' }); return; } - // Create a Yjs Document from blocks - const yDocument = editor.blocksToYDoc(blocks, 'document-store'); + // Then, convert from blocks to the output format + if (accept === 'application/json') { + res.status(200).json(blocks); + } else { + const yDocument = editor.blocksToYDoc(blocks, 'document-store'); - res - .status(200) - .setHeader('content-type', 'application/octet-stream') - .send(Y.encodeStateAsUpdate(yDocument)); + if ( + accept === 'application/vnd.yjs.doc' || + accept === 'application/octet-stream' + ) { + res + .status(200) + .setHeader('content-type', 'application/octet-stream') + .send(Y.encodeStateAsUpdate(yDocument)); + } else if (accept === 'text/markdown') { + res + .status(200) + .setHeader('content-type', 'text/markdown') + .send(await editor.blocksToMarkdownLossy(blocks)); + } else if (accept === 'text/html') { + res + .status(200) + .setHeader('content-type', 'text/html') + .send(await editor.blocksToHTMLLossy(blocks)); + } else { + res.status(406).json({ error: 'Unsupported format' }); + } + } } catch (e) { logger('conversion failed:', e); res.status(500).json({ error: 'An error occurred' }); diff --git a/src/frontend/servers/y-provider/src/handlers/index.ts b/src/frontend/servers/y-provider/src/handlers/index.ts index 19666250..26b0ebed 100644 --- a/src/frontend/servers/y-provider/src/handlers/index.ts +++ b/src/frontend/servers/y-provider/src/handlers/index.ts @@ -1,5 +1,4 @@ export * from './collaborationResetConnectionsHandler'; export * from './collaborationWSHandler'; export * from './convertHandler'; -export * from './contentHandler'; export * from './getDocumentConnectionInfoHandler'; diff --git a/src/frontend/servers/y-provider/src/servers/appServer.ts b/src/frontend/servers/y-provider/src/servers/appServer.ts index ae008b36..c9807bf7 100644 --- a/src/frontend/servers/y-provider/src/servers/appServer.ts +++ b/src/frontend/servers/y-provider/src/servers/appServer.ts @@ -8,7 +8,6 @@ import expressWebsockets from 'express-ws'; import { collaborationResetConnectionsHandler, collaborationWSHandler, - contentHandler, convertHandler, getDocumentConnectionInfoHandler, } from '@/handlers'; @@ -50,7 +49,7 @@ export const initApp = () => { ); /** - * Route to convert Markdown or BlockNote blocks + * Route to convert Markdown or BlockNote blocks and Yjs content */ app.post( routes.CONVERT, @@ -62,11 +61,6 @@ export const initApp = () => { convertHandler, ); - /** - * Route to convert base64 Yjs content to different formats - */ - app.post(routes.CONTENT, httpSecurity, express.json(), contentHandler); - Sentry.setupExpressErrorHandler(app); app.get('/ping', (req, res) => {