♻️(convert) reuse existing convert yprovider endpoint for content API

reuse convert service instead of renaming it in content
This commit is contained in:
Sylvain Zimmer
2025-07-24 14:08:18 +02:00
committed by Manuel Raynaud
parent 8a8a1460e5
commit ede0a77665
14 changed files with 296 additions and 421 deletions

View File

@@ -26,7 +26,7 @@
"oauth2DeviceCodeLifespan": 600, "oauth2DeviceCodeLifespan": 600,
"oauth2DevicePollingInterval": 5, "oauth2DevicePollingInterval": 5,
"enabled": true, "enabled": true,
"sslRequired": "none", "sslRequired": "external",
"registrationAllowed": true, "registrationAllowed": true,
"registrationEmailAsUsername": false, "registrationEmailAsUsername": false,
"rememberMe": true, "rememberMe": true,

View File

@@ -15,9 +15,9 @@ from rest_framework import serializers
from core import choices, enums, models, utils, validators from core import choices, enums, models, utils, validators
from core.services.ai_services import AI_ACTIONS from core.services.ai_services import AI_ACTIONS
from core.services.yprovider_services import ( from core.services.converter_services import (
ConversionError, ConversionError,
YProviderAPI, YdocConverter,
) )
@@ -451,7 +451,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
language = user.language or language language = user.language or language
try: try:
document_content = YProviderAPI().convert(validated_data["content"]) document_content = YdocConverter().convert(validated_data["content"])
except ConversionError as err: except ConversionError as err:
raise serializers.ValidationError( raise serializers.ValidationError(
{"content": ["Could not convert content"]} {"content": ["Could not convert content"]}

View File

@@ -1,6 +1,7 @@
"""API endpoints""" """API endpoints"""
# pylint: disable=too-many-lines # pylint: disable=too-many-lines
import base64
import json import json
import logging import logging
import uuid import uuid
@@ -37,14 +38,14 @@ from rest_framework.permissions import AllowAny
from core import authentication, choices, enums, models from core import authentication, choices, enums, models
from core.services.ai_services import AIService from core.services.ai_services import AIService
from core.services.collaboration_services import CollaborationService from core.services.collaboration_services import CollaborationService
from core.services.yprovider_services import ( from core.services.converter_services import (
ServiceUnavailableError as YProviderServiceUnavailableError, ServiceUnavailableError as YProviderServiceUnavailableError,
) )
from core.services.yprovider_services import ( from core.services.converter_services import (
ValidationError as YProviderValidationError, ValidationError as YProviderValidationError,
) )
from core.services.yprovider_services import ( from core.services.converter_services import (
YProviderAPI, YdocConverter,
) )
from core.tasks.mail import send_ask_for_access_mail from core.tasks.mail import send_ask_for_access_mail
from core.utils import extract_attachments, filter_descendants from core.utils import extract_attachments, filter_descendants
@@ -1534,9 +1535,17 @@ class DocumentViewSet(
if base64_content is not None: if base64_content is not None:
# Convert using the y-provider service # Convert using the y-provider service
try: try:
yprovider = YProviderAPI() yprovider = YdocConverter()
result = yprovider.content(base64_content, content_format) result = yprovider.convert(
content = result["content"] 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: except YProviderValidationError as e:
return drf_response.Response( return drf_response.Response(
{"error": str(e)}, status=status.HTTP_400_BAD_REQUEST {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST

View File

@@ -1,6 +1,5 @@
"""Y-Provider API services.""" """Y-Provider API services."""
import json
from base64 import b64encode from base64 import b64encode
from django.conf import settings from django.conf import settings
@@ -20,8 +19,8 @@ class ServiceUnavailableError(ConversionError):
"""Raised when the conversion service is unavailable.""" """Raised when the conversion service is unavailable."""
class YProviderAPI: class YdocConverter:
"""Service class for Y-Provider API operations.""" """Service class for conversion-related operations."""
@property @property
def auth_header(self): def auth_header(self):
@@ -29,7 +28,7 @@ class YProviderAPI:
# Note: Yprovider microservice accepts only raw token, which is not recommended # Note: Yprovider microservice accepts only raw token, which is not recommended
return f"Bearer {settings.Y_PROVIDER_API_KEY}" 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.""" """Make a request to the Y-Provider API."""
response = requests.post( response = requests.post(
url, url,
@@ -37,6 +36,7 @@ class YProviderAPI:
headers={ headers={
"Authorization": self.auth_header, "Authorization": self.auth_header,
"Content-Type": content_type, "Content-Type": content_type,
"Accept": accept,
}, },
timeout=settings.CONVERSION_API_TIMEOUT, timeout=settings.CONVERSION_API_TIMEOUT,
verify=settings.CONVERSION_API_SECURE, verify=settings.CONVERSION_API_SECURE,
@@ -44,7 +44,9 @@ class YProviderAPI:
response.raise_for_status() response.raise_for_status()
return response 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.""" """Convert a Markdown text into our internal format using an external microservice."""
if not text: if not text:
@@ -54,27 +56,17 @@ class YProviderAPI:
response = self._request( response = self._request(
f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/", f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/",
text, 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: except requests.RequestException as err:
raise ServiceUnavailableError( raise ServiceUnavailableError(
"Failed to connect to backend service", "Failed to connect to conversion 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",
) from err ) from err

View File

@@ -2,6 +2,7 @@
Tests for Documents API endpoint in impress's core app: content Tests for Documents API endpoint in impress's core app: content
""" """
import base64
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
@@ -21,11 +22,11 @@ pytestmark = pytest.mark.django_db
("public", "editor"), ("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): def test_api_documents_content_public(mock_content, reach, role):
"""Anonymous users should be allowed to access content of public documents.""" """Anonymous users should be allowed to access content of public documents."""
document = factories.DocumentFactory(link_reach=reach, link_role=role) 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/") 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["id"] == str(document.id)
assert data["title"] == document.title assert data["title"] == document.title
assert data["content"] == {"some": "data"} 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( @pytest.mark.parametrize(
@@ -52,12 +57,12 @@ def test_api_documents_content_public(mock_content, reach, role):
("authenticated", "editor", None), ("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): def test_api_documents_content_not_public(mock_content, reach, doc_role, user_role):
"""Authenticated users need access to get non-public document content.""" """Authenticated users need access to get non-public document content."""
user = factories.UserFactory() user = factories.UserFactory()
document = factories.DocumentFactory(link_reach=reach, link_role=doc_role) 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 # First anonymous request should fail
client = APIClient() 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["id"] == str(document.id)
assert data["title"] == document.title assert data["title"] == document.title
assert data["content"] == {"some": "data"} 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( @pytest.mark.parametrize(
"content_format", "content_format, accept",
["markdown", "html", "json"], [
("markdown", "text/markdown"),
("html", "text/html"),
("json", "application/json"),
],
) )
@patch("core.services.yprovider_services.YProviderAPI.content") @patch("core.services.converter_services.YdocConverter.convert")
def test_api_documents_content_format(mock_content, content_format): def test_api_documents_content_format(mock_content, content_format, accept):
"""Test that the content endpoint returns a specific format.""" """Test that the content endpoint returns a specific format."""
document = factories.DocumentFactory(link_reach="public") document = factories.DocumentFactory(link_reach="public")
mock_content.return_value = {"content": "whatever"} mock_content.return_value = {"some": "data"}
response = APIClient().get( response = APIClient().get(
f"/api/v1.0/documents/{document.id!s}/content/?content_format={content_format}" 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() data = response.json()
assert data["id"] == str(document.id) assert data["id"] == str(document.id)
assert data["title"] == document.title assert data["title"] == document.title
assert data["content"] == "whatever" assert data["content"] == {"some": "data"}
mock_content.assert_called_once_with(document.content, content_format) 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): def test_api_documents_content_invalid_format(mock_request):
"""Test that the content endpoint rejects invalid formats.""" """Test that the content endpoint rejects invalid formats."""
document = factories.DocumentFactory(link_reach="public") document = factories.DocumentFactory(link_reach="public")
@@ -124,7 +139,7 @@ def test_api_documents_content_invalid_format(mock_request):
mock_request.assert_not_called() 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): def test_api_documents_content_yservice_error(mock_request):
"""Test that service errors are handled properly.""" """Test that service errors are handled properly."""
document = factories.DocumentFactory(link_reach="public") 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 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): def test_api_documents_content_nonexistent_document(mock_request):
"""Test that accessing a nonexistent document returns 404.""" """Test that accessing a nonexistent document returns 404."""
client = APIClient() client = APIClient()
@@ -146,7 +161,7 @@ def test_api_documents_content_nonexistent_document(mock_request):
mock_request.assert_not_called() 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): def test_api_documents_content_empty_document(mock_request):
"""Test that accessing an empty document returns empty content.""" """Test that accessing an empty document returns empty content."""
document = factories.DocumentFactory(link_reach="public", content="") document = factories.DocumentFactory(link_reach="public", content="")

View File

@@ -16,16 +16,16 @@ from rest_framework.test import APIClient
from core import factories from core import factories
from core.api.serializers import ServerCreateDocumentSerializer from core.api.serializers import ServerCreateDocumentSerializer
from core.models import Document, Invitation, User 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 pytestmark = pytest.mark.django_db
@pytest.fixture @pytest.fixture
def mock_convert_md(): def mock_convert_md():
"""Mock YProviderAPI.convert to return a converted content.""" """Mock YdocConverter.convert to return a converted content."""
with patch.object( with patch.object(
YProviderAPI, YdocConverter,
"convert", "convert",
return_value="Converted document content", return_value="Converted document content",
) as mock: ) as mock:

View File

@@ -1,29 +1,28 @@
"""Test y-provider services.""" """Test y-provider services."""
import json
from base64 import b64decode from base64 import b64decode
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
import requests import requests
from core.services.yprovider_services import ( from core.services.converter_services import (
ServiceUnavailableError, ServiceUnavailableError,
ValidationError, ValidationError,
YProviderAPI, YdocConverter,
) )
def test_auth_header(settings): def test_auth_header(settings):
"""Test authentication header generation.""" """Test authentication header generation."""
settings.Y_PROVIDER_API_KEY = "test-key" settings.Y_PROVIDER_API_KEY = "test-key"
converter = YProviderAPI() converter = YdocConverter()
assert converter.auth_header == "Bearer test-key" assert converter.auth_header == "Bearer test-key"
def test_convert_empty_text(): def test_convert_empty_text():
"""Should raise ValidationError when text is empty.""" """Should raise ValidationError when text is empty."""
converter = YProviderAPI() converter = YdocConverter()
with pytest.raises(ValidationError, match="Input text cannot be empty"): with pytest.raises(ValidationError, match="Input text cannot be empty"):
converter.convert("") converter.convert("")
@@ -31,13 +30,13 @@ def test_convert_empty_text():
@patch("requests.post") @patch("requests.post")
def test_convert_service_unavailable(mock_post): def test_convert_service_unavailable(mock_post):
"""Should raise ServiceUnavailableError when service is unavailable.""" """Should raise ServiceUnavailableError when service is unavailable."""
converter = YProviderAPI() converter = YdocConverter()
mock_post.side_effect = requests.RequestException("Connection error") mock_post.side_effect = requests.RequestException("Connection error")
with pytest.raises( with pytest.raises(
ServiceUnavailableError, ServiceUnavailableError,
match="Failed to connect to backend service", match="Failed to connect to conversion service",
): ):
converter.convert("test text") converter.convert("test text")
@@ -45,7 +44,7 @@ def test_convert_service_unavailable(mock_post):
@patch("requests.post") @patch("requests.post")
def test_convert_http_error(mock_post): def test_convert_http_error(mock_post):
"""Should raise ServiceUnavailableError when HTTP error occurs.""" """Should raise ServiceUnavailableError when HTTP error occurs."""
converter = YProviderAPI() converter = YdocConverter()
mock_response = MagicMock() mock_response = MagicMock()
mock_response.raise_for_status.side_effect = requests.HTTPError("HTTP Error") 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( with pytest.raises(
ServiceUnavailableError, ServiceUnavailableError,
match="Failed to connect to backend service", match="Failed to connect to conversion service",
): ):
converter.convert("test text") converter.convert("test text")
@@ -68,7 +67,7 @@ def test_convert_full_integration(mock_post, settings):
settings.CONVERSION_API_TIMEOUT = 5 settings.CONVERSION_API_TIMEOUT = 5
settings.CONVERSION_API_CONTENT_FIELD = "content" settings.CONVERSION_API_CONTENT_FIELD = "content"
converter = YProviderAPI() converter = YdocConverter()
expected_content = b"converted content" expected_content = b"converted content"
mock_response = MagicMock() mock_response = MagicMock()
@@ -85,6 +84,42 @@ def test_convert_full_integration(mock_post, settings):
headers={ headers={
"Authorization": "Bearer test-key", "Authorization": "Bearer test-key",
"Content-Type": "text/markdown", "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, timeout=5,
verify=False, verify=False,
@@ -94,75 +129,20 @@ def test_convert_full_integration(mock_post, settings):
@patch("requests.post") @patch("requests.post")
def test_convert_timeout(mock_post): def test_convert_timeout(mock_post):
"""Should raise ServiceUnavailableError when request times out.""" """Should raise ServiceUnavailableError when request times out."""
converter = YProviderAPI() converter = YdocConverter()
mock_post.side_effect = requests.Timeout("Request timed out") mock_post.side_effect = requests.Timeout("Request timed out")
with pytest.raises( with pytest.raises(
ServiceUnavailableError, ServiceUnavailableError,
match="Failed to connect to backend service", match="Failed to connect to conversion service",
): ):
converter.convert("test text") converter.convert("test text")
def test_convert_none_input(): def test_convert_none_input():
"""Should raise ValidationError when input is None.""" """Should raise ValidationError when input is None."""
converter = YProviderAPI() converter = YdocConverter()
with pytest.raises(ValidationError, match="Input text cannot be empty"): with pytest.raises(ValidationError, match="Input text cannot be empty"):
converter.convert(None) 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,
)

View File

@@ -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(
'<h1>Test Document</h1><p>This is test content.</p>',
);
}
},
);
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',
});
});
});

View File

@@ -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}`]])( test.each([[apiKey], [`Bearer ${apiKey}`]])(
'POST /api/convert with correct content with Authorization: %s', 'POST /api/convert with correct content with Authorization: %s',
async (authHeader) => { async (authHeader) => {
@@ -137,6 +162,8 @@ describe('Server Tests', () => {
.post('/api/convert') .post('/api/convert')
.set('Origin', origin) .set('Origin', origin)
.set('Authorization', authHeader) .set('Authorization', authHeader)
.set('content-type', 'text/markdown')
.set('accept', 'application/vnd.yjs.doc')
.send(document); .send(document);
expect(response.status).toBe(200); expect(response.status).toBe(200);
@@ -150,4 +177,89 @@ describe('Server Tests', () => {
expect(blocks).toStrictEqual(expectedBlocks); 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(
'<h1>Test Document</h1><p>This is test content.</p>',
);
});
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' });
});
}); });

View File

@@ -9,7 +9,7 @@
"build": "tsc -p tsconfig.build.json && tsc-alias", "build": "tsc -p tsconfig.build.json && tsc-alias",
"dev": "cross-env COLLABORATION_LOGGING=true && nodemon --config nodemon.json", "dev": "cross-env COLLABORATION_LOGGING=true && nodemon --config nodemon.json",
"start": "node ./dist/start-server.js", "start": "node ./dist/start-server.js",
"lint": "eslint . --ext .ts", "lint": "eslint . --ext .ts --fix",
"test": "vitest --run --disable-console-intercept" "test": "vitest --run --disable-console-intercept"
}, },
"engines": { "engines": {

View File

@@ -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<object, object | ErrorResponse, ContentRequest, object>,
res: Response<object | ErrorResponse>,
) => {
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' });
}
};

View File

@@ -1,3 +1,4 @@
import { PartialBlock } from '@blocknote/core';
import { ServerBlockNoteEditor } from '@blocknote/server-util'; import { ServerBlockNoteEditor } from '@blocknote/server-util';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import * as Y from 'yjs'; import * as Y from 'yjs';
@@ -12,29 +13,80 @@ const editor = ServerBlockNoteEditor.create();
export const convertHandler = async ( export const convertHandler = async (
req: Request<object, Uint8Array | ErrorResponse, Buffer, object>, req: Request<object, Uint8Array | ErrorResponse, Buffer, object>,
res: Response<Uint8Array | ErrorResponse>, res: Response<Uint8Array | string | object | ErrorResponse>,
) => { ) => {
if (!req.body || req.body.length === 0) { if (!req.body || req.body.length === 0) {
res.status(400).json({ error: 'Invalid request: missing content' }); res.status(400).json({ error: 'Invalid request: missing content' });
return; return;
} }
try { const contentType = (req.header('content-type') || 'text/markdown').split(
// Perform the conversion from markdown to Blocknote.js blocks ';',
const blocks = await editor.tryParseMarkdownToBlocks(req.body.toString()); )[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) { if (!blocks || blocks.length === 0) {
res.status(500).json({ error: 'No valid blocks were generated' }); res.status(500).json({ error: 'No valid blocks were generated' });
return; return;
} }
// Create a Yjs Document from blocks // Then, convert from blocks to the output format
const yDocument = editor.blocksToYDoc(blocks, 'document-store'); if (accept === 'application/json') {
res.status(200).json(blocks);
} else {
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
res if (
.status(200) accept === 'application/vnd.yjs.doc' ||
.setHeader('content-type', 'application/octet-stream') accept === 'application/octet-stream'
.send(Y.encodeStateAsUpdate(yDocument)); ) {
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) { } catch (e) {
logger('conversion failed:', e); logger('conversion failed:', e);
res.status(500).json({ error: 'An error occurred' }); res.status(500).json({ error: 'An error occurred' });

View File

@@ -1,5 +1,4 @@
export * from './collaborationResetConnectionsHandler'; export * from './collaborationResetConnectionsHandler';
export * from './collaborationWSHandler'; export * from './collaborationWSHandler';
export * from './convertHandler'; export * from './convertHandler';
export * from './contentHandler';
export * from './getDocumentConnectionInfoHandler'; export * from './getDocumentConnectionInfoHandler';

View File

@@ -8,7 +8,6 @@ import expressWebsockets from 'express-ws';
import { import {
collaborationResetConnectionsHandler, collaborationResetConnectionsHandler,
collaborationWSHandler, collaborationWSHandler,
contentHandler,
convertHandler, convertHandler,
getDocumentConnectionInfoHandler, getDocumentConnectionInfoHandler,
} from '@/handlers'; } 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( app.post(
routes.CONVERT, routes.CONVERT,
@@ -62,11 +61,6 @@ export const initApp = () => {
convertHandler, convertHandler,
); );
/**
* Route to convert base64 Yjs content to different formats
*/
app.post(routes.CONTENT, httpSecurity, express.json(), contentHandler);
Sentry.setupExpressErrorHandler(app); Sentry.setupExpressErrorHandler(app);
app.get('/ping', (req, res) => { app.get('/ping', (req, res) => {