diff --git a/dist/schema.json b/dist/schema.json index e42cf86..03b8981 100644 --- a/dist/schema.json +++ b/dist/schema.json @@ -25253,7 +25253,11 @@ } }, "content": { - "text/event-stream": {} + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/StreamingDirectEvent" + } + } } }, "401": { @@ -25371,7 +25375,11 @@ } }, "content": { - "text/event-stream": {} + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/StreamingHashtagEvent" + } + } } }, "401": { @@ -25501,7 +25509,11 @@ } }, "content": { - "text/event-stream": {} + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/StreamingHashtagLocalEvent" + } + } } }, "401": { @@ -25739,7 +25751,11 @@ } }, "content": { - "text/event-stream": {} + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/StreamingListEvent" + } + } } }, "401": { @@ -25869,7 +25885,11 @@ } }, "content": { - "text/event-stream": {} + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/StreamingPublicEvent" + } + } } }, "401": { @@ -25998,7 +26018,11 @@ } }, "content": { - "text/event-stream": {} + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/StreamingPublicLocalEvent" + } + } } }, "401": { @@ -26127,7 +26151,11 @@ } }, "content": { - "text/event-stream": {} + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/StreamingPublicRemoteEvent" + } + } } }, "401": { @@ -26256,7 +26284,11 @@ } }, "content": { - "text/event-stream": {} + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/StreamingUserEvent" + } + } } }, "401": { @@ -26375,7 +26407,11 @@ } }, "content": { - "text/event-stream": {} + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/StreamingUserNotificationEvent" + } + } } }, "401": { @@ -36835,6 +36871,427 @@ "height": null } }, + "AnnouncementReactionPayload": { + "type": "object", + "description": "Payload for announcement.reaction streaming events", + "properties": { + "name": { + "type": "string", + "description": "The emoji used for the reaction" + }, + "count": { + "type": "integer", + "description": "The total number of reactions with this emoji" + }, + "announcement_id": { + "type": "string", + "description": "The ID of the announcement being reacted to" + } + }, + "required": [ + "name", + "count", + "announcement_id" + ] + }, + "UpdateEvent": { + "type": "object", + "description": "A new Status has appeared. Payload contains a Status cast to a string.", + "properties": { + "event": { + "type": "string", + "description": "The type of event", + "$ref": "#/components/schemas/UpdateEventEventEnum" + }, + "payload": { + "description": "JSON-encoded Status object. For SSE, this is a string that must be parsed.", + "oneOf": [ + { + "$ref": "#/components/schemas/Status" + }, + { + "type": "string", + "description": "JSON-encoded Status string" + } + ] + } + }, + "required": [ + "event", + "payload" + ] + }, + "DeleteEvent": { + "type": "object", + "description": "A status has been deleted. Payload contains the String ID of the deleted Status.", + "properties": { + "event": { + "type": "string", + "description": "The type of event", + "$ref": "#/components/schemas/DeleteEventEventEnum" + }, + "payload": { + "type": "string", + "description": "String ID of the deleted resource" + } + }, + "required": [ + "event", + "payload" + ] + }, + "NotificationEvent": { + "type": "object", + "description": "A new notification has appeared. Payload contains a Notification cast to a string.", + "properties": { + "event": { + "type": "string", + "description": "The type of event", + "$ref": "#/components/schemas/NotificationEventEventEnum" + }, + "payload": { + "description": "JSON-encoded Notification object. For SSE, this is a string that must be parsed.", + "oneOf": [ + { + "$ref": "#/components/schemas/Notification" + }, + { + "type": "string", + "description": "JSON-encoded Notification string" + } + ] + } + }, + "required": [ + "event", + "payload" + ] + }, + "FiltersChangedEvent": { + "type": "object", + "description": "Keyword filters have been changed. Does not contain a payload for WebSocket connections.", + "properties": { + "event": { + "type": "string", + "description": "The type of event", + "$ref": "#/components/schemas/FiltersChangedEventEventEnum" + } + }, + "required": [ + "event" + ] + }, + "ConversationEvent": { + "type": "object", + "description": "A direct conversation has been updated. Payload contains a Conversation cast to a string.", + "properties": { + "event": { + "type": "string", + "description": "The type of event", + "$ref": "#/components/schemas/ConversationEventEventEnum" + }, + "payload": { + "description": "JSON-encoded Conversation object. For SSE, this is a string that must be parsed.", + "oneOf": [ + { + "$ref": "#/components/schemas/Conversation" + }, + { + "type": "string", + "description": "JSON-encoded Conversation string" + } + ] + } + }, + "required": [ + "event", + "payload" + ] + }, + "AnnouncementEvent": { + "type": "object", + "description": "An announcement has been published. Payload contains an Announcement cast to a string.", + "properties": { + "event": { + "type": "string", + "description": "The type of event", + "$ref": "#/components/schemas/AnnouncementEventEventEnum" + }, + "payload": { + "description": "JSON-encoded Announcement object. For SSE, this is a string that must be parsed.", + "oneOf": [ + { + "$ref": "#/components/schemas/Announcement" + }, + { + "type": "string", + "description": "JSON-encoded Announcement string" + } + ] + } + }, + "required": [ + "event", + "payload" + ] + }, + "AnnouncementReactionEvent": { + "type": "object", + "description": "An announcement has received an emoji reaction. Payload contains a Hash with name, count, and announcement_id.", + "properties": { + "event": { + "type": "string", + "description": "The type of event", + "$ref": "#/components/schemas/AnnouncementReactionEventEventEnum" + }, + "payload": { + "description": "JSON-encoded reaction data. For SSE, this is a string that must be parsed.", + "oneOf": [ + { + "$ref": "#/components/schemas/AnnouncementReactionPayload" + }, + { + "type": "string", + "description": "JSON-encoded reaction data string" + } + ] + } + }, + "required": [ + "event", + "payload" + ] + }, + "AnnouncementDeleteEvent": { + "type": "object", + "description": "An announcement has been deleted. Payload contains the String ID of the deleted Announcement.", + "properties": { + "event": { + "type": "string", + "description": "The type of event", + "$ref": "#/components/schemas/AnnouncementDeleteEventEventEnum" + }, + "payload": { + "type": "string", + "description": "String ID of the deleted resource" + } + }, + "required": [ + "event", + "payload" + ] + }, + "StatusUpdateEvent": { + "type": "object", + "description": "A Status has been edited. Payload contains a Status cast to a string.", + "properties": { + "event": { + "type": "string", + "description": "The type of event", + "$ref": "#/components/schemas/StatusUpdateEventEventEnum" + }, + "payload": { + "description": "JSON-encoded Status object. For SSE, this is a string that must be parsed.", + "oneOf": [ + { + "$ref": "#/components/schemas/Status" + }, + { + "type": "string", + "description": "JSON-encoded Status string" + } + ] + } + }, + "required": [ + "event", + "payload" + ] + }, + "NotificationsMergedEvent": { + "type": "object", + "description": "Accepted notification requests have finished merging, and the notifications list should be refreshed. Payload can be ignored.", + "properties": { + "event": { + "type": "string", + "description": "The type of event", + "$ref": "#/components/schemas/NotificationsMergedEventEventEnum" + } + }, + "required": [ + "event" + ] + }, + "StreamingEvent": { + "description": "Server-Sent Event from the Mastodon streaming API. The event field determines the type of payload.", + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateEvent" + }, + { + "$ref": "#/components/schemas/DeleteEvent" + }, + { + "$ref": "#/components/schemas/NotificationEvent" + }, + { + "$ref": "#/components/schemas/FiltersChangedEvent" + }, + { + "$ref": "#/components/schemas/ConversationEvent" + }, + { + "$ref": "#/components/schemas/AnnouncementEvent" + }, + { + "$ref": "#/components/schemas/AnnouncementReactionEvent" + }, + { + "$ref": "#/components/schemas/AnnouncementDeleteEvent" + }, + { + "$ref": "#/components/schemas/StatusUpdateEvent" + }, + { + "$ref": "#/components/schemas/NotificationsMergedEvent" + } + ] + }, + "StreamingUserEvent": { + "description": "Server-Sent Events for the /api/v1/streaming/user streaming endpoint", + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateEvent" + }, + { + "$ref": "#/components/schemas/DeleteEvent" + }, + { + "$ref": "#/components/schemas/NotificationEvent" + }, + { + "$ref": "#/components/schemas/FiltersChangedEvent" + }, + { + "$ref": "#/components/schemas/AnnouncementEvent" + }, + { + "$ref": "#/components/schemas/AnnouncementReactionEvent" + }, + { + "$ref": "#/components/schemas/AnnouncementDeleteEvent" + }, + { + "$ref": "#/components/schemas/StatusUpdateEvent" + }, + { + "$ref": "#/components/schemas/NotificationsMergedEvent" + } + ] + }, + "StreamingUserNotificationEvent": { + "description": "Server-Sent Events for the /api/v1/streaming/user/notification streaming endpoint", + "oneOf": [ + { + "$ref": "#/components/schemas/NotificationEvent" + }, + { + "$ref": "#/components/schemas/NotificationsMergedEvent" + } + ] + }, + "StreamingPublicEvent": { + "description": "Server-Sent Events for the /api/v1/streaming/public streaming endpoint", + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateEvent" + }, + { + "$ref": "#/components/schemas/DeleteEvent" + }, + { + "$ref": "#/components/schemas/StatusUpdateEvent" + } + ] + }, + "StreamingPublicLocalEvent": { + "description": "Server-Sent Events for the /api/v1/streaming/public/local streaming endpoint", + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateEvent" + }, + { + "$ref": "#/components/schemas/DeleteEvent" + }, + { + "$ref": "#/components/schemas/StatusUpdateEvent" + } + ] + }, + "StreamingPublicRemoteEvent": { + "description": "Server-Sent Events for the /api/v1/streaming/public/remote streaming endpoint", + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateEvent" + }, + { + "$ref": "#/components/schemas/DeleteEvent" + }, + { + "$ref": "#/components/schemas/StatusUpdateEvent" + } + ] + }, + "StreamingHashtagEvent": { + "description": "Server-Sent Events for the /api/v1/streaming/hashtag streaming endpoint", + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateEvent" + }, + { + "$ref": "#/components/schemas/DeleteEvent" + }, + { + "$ref": "#/components/schemas/StatusUpdateEvent" + } + ] + }, + "StreamingHashtagLocalEvent": { + "description": "Server-Sent Events for the /api/v1/streaming/hashtag/local streaming endpoint", + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateEvent" + }, + { + "$ref": "#/components/schemas/DeleteEvent" + }, + { + "$ref": "#/components/schemas/StatusUpdateEvent" + } + ] + }, + "StreamingListEvent": { + "description": "Server-Sent Events for the /api/v1/streaming/list streaming endpoint", + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateEvent" + }, + { + "$ref": "#/components/schemas/DeleteEvent" + }, + { + "$ref": "#/components/schemas/StatusUpdateEvent" + } + ] + }, + "StreamingDirectEvent": { + "description": "Server-Sent Events for the /api/v1/streaming/direct streaming endpoint", + "oneOf": [ + { + "$ref": "#/components/schemas/ConversationEvent" + } + ] + }, "ValidationError": { "type": "object", "description": "Represents a validation error with field-specific details.", @@ -37236,6 +37693,66 @@ "user_domain_block", "account_suspension" ] + }, + "UpdateEventEventEnum": { + "type": "string", + "enum": [ + "update" + ] + }, + "DeleteEventEventEnum": { + "type": "string", + "enum": [ + "delete" + ] + }, + "NotificationEventEventEnum": { + "type": "string", + "enum": [ + "notification" + ] + }, + "FiltersChangedEventEventEnum": { + "type": "string", + "enum": [ + "filters_changed" + ] + }, + "ConversationEventEventEnum": { + "type": "string", + "enum": [ + "conversation" + ] + }, + "AnnouncementEventEventEnum": { + "type": "string", + "enum": [ + "announcement" + ] + }, + "AnnouncementReactionEventEventEnum": { + "type": "string", + "enum": [ + "announcement.reaction" + ] + }, + "AnnouncementDeleteEventEventEnum": { + "type": "string", + "enum": [ + "announcement.delete" + ] + }, + "StatusUpdateEventEventEnum": { + "type": "string", + "enum": [ + "status.update" + ] + }, + "NotificationsMergedEventEventEnum": { + "type": "string", + "enum": [ + "notifications_merged" + ] } }, "securitySchemes": { diff --git a/src/__tests__/generators/OpenAPIGenerator.test.ts b/src/__tests__/generators/OpenAPIGenerator.test.ts index 774bb19..42eb5bb 100644 --- a/src/__tests__/generators/OpenAPIGenerator.test.ts +++ b/src/__tests__/generators/OpenAPIGenerator.test.ts @@ -126,10 +126,17 @@ describe('OpenAPIGenerator', () => { expect(spec.openapi).toBe('3.1.0'); expect(spec.paths).toEqual({}); - // With empty inputs, the schema should only contain the OAuth scope schemas + // With empty inputs, the schema should contain the OAuth scope schemas + // and the streaming event schemas (added by MethodConverter even with empty methods) expect(spec.components?.schemas).toHaveProperty('OAuthScope'); expect(spec.components?.schemas).toHaveProperty('OAuthScopes'); - expect(Object.keys(spec.components?.schemas || {}).length).toBe(2); + // Should also contain streaming-related schemas + expect(spec.components?.schemas).toHaveProperty('StreamingEvent'); + expect(spec.components?.schemas).toHaveProperty('UpdateEvent'); + // The total number of schemas will be more than 2 due to streaming schemas + expect( + Object.keys(spec.components?.schemas || {}).length + ).toBeGreaterThan(2); }); it('should include default public security at root level', () => { diff --git a/src/__tests__/generators/StreamingSchemaGenerator.test.ts b/src/__tests__/generators/StreamingSchemaGenerator.test.ts new file mode 100644 index 0000000..82bdb1a --- /dev/null +++ b/src/__tests__/generators/StreamingSchemaGenerator.test.ts @@ -0,0 +1,207 @@ +import { StreamingSchemaGenerator } from '../../generators/StreamingSchemaGenerator'; +import { OpenAPISpec } from '../../interfaces/OpenAPISchema'; + +describe('StreamingSchemaGenerator', () => { + let generator: StreamingSchemaGenerator; + let spec: OpenAPISpec; + + beforeEach(() => { + generator = new StreamingSchemaGenerator(); + // Create a minimal spec with required entities + spec = { + openapi: '3.1.0', + info: { title: 'Test', version: '1.0.0' }, + paths: {}, + components: { + schemas: { + Status: { + type: 'object', + properties: { id: { type: 'string' } }, + }, + Notification: { + type: 'object', + properties: { id: { type: 'string' } }, + }, + Conversation: { + type: 'object', + properties: { id: { type: 'string' } }, + }, + Announcement: { + type: 'object', + properties: { id: { type: 'string' } }, + }, + }, + }, + }; + }); + + describe('createStreamingSchemas', () => { + it('should create all event type schemas', () => { + generator.createStreamingSchemas(spec); + + // Check individual event schemas exist + expect(spec.components?.schemas).toHaveProperty('UpdateEvent'); + expect(spec.components?.schemas).toHaveProperty('DeleteEvent'); + expect(spec.components?.schemas).toHaveProperty('NotificationEvent'); + expect(spec.components?.schemas).toHaveProperty('FiltersChangedEvent'); + expect(spec.components?.schemas).toHaveProperty('ConversationEvent'); + expect(spec.components?.schemas).toHaveProperty('AnnouncementEvent'); + expect(spec.components?.schemas).toHaveProperty( + 'AnnouncementReactionEvent' + ); + expect(spec.components?.schemas).toHaveProperty( + 'AnnouncementDeleteEvent' + ); + expect(spec.components?.schemas).toHaveProperty('StatusUpdateEvent'); + expect(spec.components?.schemas).toHaveProperty( + 'NotificationsMergedEvent' + ); + }); + + it('should create combined streaming event schema', () => { + generator.createStreamingSchemas(spec); + + expect(spec.components?.schemas).toHaveProperty('StreamingEvent'); + const streamingEvent = spec.components?.schemas?.[ + 'StreamingEvent' + ] as any; + expect(streamingEvent.oneOf).toBeDefined(); + expect(streamingEvent.oneOf.length).toBe(10); // All event types including notifications_merged + }); + + it('should create endpoint-specific event schemas', () => { + generator.createStreamingSchemas(spec); + + expect(spec.components?.schemas).toHaveProperty('StreamingUserEvent'); + expect(spec.components?.schemas).toHaveProperty( + 'StreamingUserNotificationEvent' + ); + expect(spec.components?.schemas).toHaveProperty('StreamingPublicEvent'); + expect(spec.components?.schemas).toHaveProperty( + 'StreamingPublicLocalEvent' + ); + expect(spec.components?.schemas).toHaveProperty( + 'StreamingPublicRemoteEvent' + ); + expect(spec.components?.schemas).toHaveProperty('StreamingHashtagEvent'); + expect(spec.components?.schemas).toHaveProperty( + 'StreamingHashtagLocalEvent' + ); + expect(spec.components?.schemas).toHaveProperty('StreamingListEvent'); + expect(spec.components?.schemas).toHaveProperty('StreamingDirectEvent'); + }); + + it('should create AnnouncementReactionPayload schema', () => { + generator.createStreamingSchemas(spec); + + expect(spec.components?.schemas).toHaveProperty( + 'AnnouncementReactionPayload' + ); + const schema = spec.components?.schemas?.[ + 'AnnouncementReactionPayload' + ] as any; + expect(schema.type).toBe('object'); + expect(schema.properties).toHaveProperty('name'); + expect(schema.properties).toHaveProperty('count'); + expect(schema.properties).toHaveProperty('announcement_id'); + expect(schema.required).toContain('name'); + expect(schema.required).toContain('count'); + expect(schema.required).toContain('announcement_id'); + }); + + it('should create UpdateEvent with correct structure', () => { + generator.createStreamingSchemas(spec); + + const updateEvent = spec.components?.schemas?.['UpdateEvent'] as any; + expect(updateEvent.type).toBe('object'); + expect(updateEvent.properties.event).toBeDefined(); + expect(updateEvent.properties.event.enum).toContain('update'); + expect(updateEvent.properties.payload).toBeDefined(); + expect(updateEvent.properties.payload.oneOf).toBeDefined(); + expect(updateEvent.required).toContain('event'); + expect(updateEvent.required).toContain('payload'); + }); + + it('should create DeleteEvent with string payload', () => { + generator.createStreamingSchemas(spec); + + const deleteEvent = spec.components?.schemas?.['DeleteEvent'] as any; + expect(deleteEvent.type).toBe('object'); + expect(deleteEvent.properties.event.enum).toContain('delete'); + expect(deleteEvent.properties.payload.type).toBe('string'); + expect(deleteEvent.required).toContain('event'); + expect(deleteEvent.required).toContain('payload'); + }); + + it('should create FiltersChangedEvent without payload requirement', () => { + generator.createStreamingSchemas(spec); + + const filtersEvent = spec.components?.schemas?.[ + 'FiltersChangedEvent' + ] as any; + expect(filtersEvent.type).toBe('object'); + expect(filtersEvent.properties.event.enum).toContain('filters_changed'); + // payload is not required for filters_changed + expect(filtersEvent.required).not.toContain('payload'); + }); + + it('should create StreamingUserEvent with all expected event types', () => { + generator.createStreamingSchemas(spec); + + const userEvent = spec.components?.schemas?.['StreamingUserEvent'] as any; + expect(userEvent.oneOf).toBeDefined(); + expect(userEvent.oneOf.length).toBe(9); // All user events including notifications_merged + + // Check all expected event types are referenced + const refs = userEvent.oneOf.map((ref: any) => ref.$ref); + expect(refs).toContain('#/components/schemas/UpdateEvent'); + expect(refs).toContain('#/components/schemas/DeleteEvent'); + expect(refs).toContain('#/components/schemas/NotificationEvent'); + expect(refs).toContain('#/components/schemas/FiltersChangedEvent'); + expect(refs).toContain('#/components/schemas/AnnouncementEvent'); + expect(refs).toContain('#/components/schemas/AnnouncementReactionEvent'); + expect(refs).toContain('#/components/schemas/AnnouncementDeleteEvent'); + expect(refs).toContain('#/components/schemas/StatusUpdateEvent'); + expect(refs).toContain('#/components/schemas/NotificationsMergedEvent'); + }); + + it('should create StreamingDirectEvent with only conversation event', () => { + generator.createStreamingSchemas(spec); + + const directEvent = spec.components?.schemas?.[ + 'StreamingDirectEvent' + ] as any; + expect(directEvent.oneOf).toBeDefined(); + expect(directEvent.oneOf.length).toBe(1); + expect(directEvent.oneOf[0].$ref).toBe( + '#/components/schemas/ConversationEvent' + ); + }); + }); + + describe('getStreamingSchemaRef', () => { + beforeEach(() => { + generator.createStreamingSchemas(spec); + }); + + it('should return correct schema ref for /api/v1/streaming/user', () => { + const ref = generator.getStreamingSchemaRef('/api/v1/streaming/user'); + expect(ref?.$ref).toBe('#/components/schemas/StreamingUserEvent'); + }); + + it('should return correct schema ref for /api/v1/streaming/public', () => { + const ref = generator.getStreamingSchemaRef('/api/v1/streaming/public'); + expect(ref?.$ref).toBe('#/components/schemas/StreamingPublicEvent'); + }); + + it('should return correct schema ref for /api/v1/streaming/direct', () => { + const ref = generator.getStreamingSchemaRef('/api/v1/streaming/direct'); + expect(ref?.$ref).toBe('#/components/schemas/StreamingDirectEvent'); + }); + + it('should return generic StreamingEvent for unknown endpoints', () => { + const ref = generator.getStreamingSchemaRef('/api/v1/streaming/unknown'); + expect(ref?.$ref).toBe('#/components/schemas/StreamingEvent'); + }); + }); +}); diff --git a/src/generators/MethodConverter.ts b/src/generators/MethodConverter.ts index 3dc1e4e..bdb9b79 100644 --- a/src/generators/MethodConverter.ts +++ b/src/generators/MethodConverter.ts @@ -18,6 +18,10 @@ import { RateLimitHeader, } from '../parsers/RateLimitHeaderParser'; import { VersionParser } from '../parsers/VersionParser'; +import { + StreamingSchemaGenerator, + STREAMING_ENDPOINT_EVENTS, +} from './StreamingSchemaGenerator'; /** * Interface for OAuth security configuration @@ -38,6 +42,7 @@ class MethodConverter { private errorExampleRegistry: ErrorExampleRegistry; private responseCodes: Array<{ code: string; description: string }>; private rateLimitHeaders: RateLimitHeader[]; + private streamingSchemaGenerator: StreamingSchemaGenerator; constructor( typeParser: TypeParser, @@ -51,6 +56,8 @@ class MethodConverter { this.responseCodes = ResponseCodeParser.parseResponseCodes(); // Parse rate limit headers once during initialization this.rateLimitHeaders = RateLimitHeaderParser.parseRateLimitHeaders(); + // Initialize streaming schema generator + this.streamingSchemaGenerator = new StreamingSchemaGenerator(); } /** @@ -113,6 +120,9 @@ class MethodConverter { methodFiles: ApiMethodsFile[], spec: OpenAPISpec ): void { + // Create streaming event schemas before processing methods + this.streamingSchemaGenerator.createStreamingSchemas(spec); + for (const methodFile of methodFiles) { for (const method of methodFile.methods) { this.convertMethod(method, methodFile.name, spec); @@ -178,9 +188,15 @@ class MethodConverter { : 'application/json'; if (method.isStreaming) { - // Streaming endpoints always have content with text/event-stream - // even if no specific schema is parsed from the returns field - const content: any = responseSchema ? { schema: responseSchema } : {}; + // Streaming endpoints use SSE event schemas + // Get the appropriate streaming schema for this endpoint + const streamingSchema = + this.streamingSchemaGenerator.getStreamingSchemaRef( + this.normalizePath(method.endpoint) + ); + const content: any = streamingSchema + ? { schema: streamingSchema } + : {}; if (responseExample) { content.example = responseExample; } diff --git a/src/generators/StreamingSchemaGenerator.ts b/src/generators/StreamingSchemaGenerator.ts new file mode 100644 index 0000000..5e289a9 --- /dev/null +++ b/src/generators/StreamingSchemaGenerator.ts @@ -0,0 +1,385 @@ +import { OpenAPISpec, OpenAPIProperty } from '../interfaces/OpenAPISchema'; + +/** + * Streaming event type definitions based on Mastodon documentation + * https://docs.joinmastodon.org/methods/streaming/#events + */ +interface StreamingEventDefinition { + eventName: string; + payloadType: + | 'status' + | 'notification' + | 'conversation' + | 'announcement' + | 'string' + | 'reaction' + | 'none'; + description: string; +} + +/** + * Streaming endpoint event mapping + * Maps streaming endpoint paths to the events they support + */ +const STREAMING_ENDPOINT_EVENTS: Record = { + '/api/v1/streaming/user': [ + 'update', + 'delete', + 'notification', + 'filters_changed', + 'announcement', + 'announcement.reaction', + 'announcement.delete', + 'status.update', + 'notifications_merged', + ], + '/api/v1/streaming/user/notification': [ + 'notification', + 'notifications_merged', + ], + '/api/v1/streaming/public': ['update', 'delete', 'status.update'], + '/api/v1/streaming/public/local': ['update', 'delete', 'status.update'], + '/api/v1/streaming/public/remote': ['update', 'delete', 'status.update'], + '/api/v1/streaming/hashtag': ['update', 'delete', 'status.update'], + '/api/v1/streaming/hashtag/local': ['update', 'delete', 'status.update'], + '/api/v1/streaming/list': ['update', 'delete', 'status.update'], + '/api/v1/streaming/direct': ['conversation'], +}; + +/** + * Event type definitions with payload information + */ +const STREAMING_EVENTS: StreamingEventDefinition[] = [ + { + eventName: 'update', + payloadType: 'status', + description: + 'A new Status has appeared. Payload contains a Status cast to a string.', + }, + { + eventName: 'delete', + payloadType: 'string', + description: + 'A status has been deleted. Payload contains the String ID of the deleted Status.', + }, + { + eventName: 'notification', + payloadType: 'notification', + description: + 'A new notification has appeared. Payload contains a Notification cast to a string.', + }, + { + eventName: 'filters_changed', + payloadType: 'none', + description: + 'Keyword filters have been changed. Does not contain a payload for WebSocket connections.', + }, + { + eventName: 'conversation', + payloadType: 'conversation', + description: + 'A direct conversation has been updated. Payload contains a Conversation cast to a string.', + }, + { + eventName: 'announcement', + payloadType: 'announcement', + description: + 'An announcement has been published. Payload contains an Announcement cast to a string.', + }, + { + eventName: 'announcement.reaction', + payloadType: 'reaction', + description: + 'An announcement has received an emoji reaction. Payload contains a Hash with name, count, and announcement_id.', + }, + { + eventName: 'announcement.delete', + payloadType: 'string', + description: + 'An announcement has been deleted. Payload contains the String ID of the deleted Announcement.', + }, + { + eventName: 'status.update', + payloadType: 'status', + description: + 'A Status has been edited. Payload contains a Status cast to a string.', + }, + { + eventName: 'notifications_merged', + payloadType: 'none', + description: + 'Accepted notification requests have finished merging, and the notifications list should be refreshed. Payload can be ignored.', + }, +]; + +/** + * Generator for streaming SSE event schemas + */ +class StreamingSchemaGenerator { + /** + * Create all streaming-related schemas and add them to the spec + */ + public createStreamingSchemas(spec: OpenAPISpec): void { + if (!spec.components) { + spec.components = {}; + } + if (!spec.components.schemas) { + spec.components.schemas = {}; + } + + // Create the announcement reaction payload schema + this.createAnnouncementReactionPayloadSchema(spec); + + // Create individual event schemas + for (const eventDef of STREAMING_EVENTS) { + this.createEventSchema(spec, eventDef); + } + + // Create combined event schemas for each endpoint type + this.createEndpointEventSchemas(spec); + } + + /** + * Create the AnnouncementReactionPayload schema for announcement.reaction events + */ + private createAnnouncementReactionPayloadSchema(spec: OpenAPISpec): void { + spec.components!.schemas!['AnnouncementReactionPayload'] = { + type: 'object', + description: 'Payload for announcement.reaction streaming events', + properties: { + name: { + type: 'string', + description: 'The emoji used for the reaction', + }, + count: { + type: 'integer', + description: 'The total number of reactions with this emoji', + }, + announcement_id: { + type: 'string', + description: 'The ID of the announcement being reacted to', + }, + }, + required: ['name', 'count', 'announcement_id'], + } as any; + } + + /** + * Create a schema for a single streaming event type + */ + private createEventSchema( + spec: OpenAPISpec, + eventDef: StreamingEventDefinition + ): void { + const schemaName = this.getEventSchemaName(eventDef.eventName); + const properties: Record = { + event: { + type: 'string', + enum: [eventDef.eventName], + description: 'The type of event', + }, + }; + + const required: string[] = ['event']; + + // Add payload based on event type + const payloadSchema = this.getPayloadSchema(eventDef, spec); + if (payloadSchema) { + properties['payload'] = payloadSchema; + // payload is not required for filters_changed and notifications_merged + if (eventDef.payloadType !== 'none') { + required.push('payload'); + } + } + + spec.components!.schemas![schemaName] = { + type: 'object', + description: eventDef.description, + properties, + required, + } as any; + } + + /** + * Get the payload schema for an event type + */ + private getPayloadSchema( + eventDef: StreamingEventDefinition, + spec: OpenAPISpec + ): OpenAPIProperty | null { + switch (eventDef.payloadType) { + case 'status': + // Reference to Status entity if it exists + if (spec.components?.schemas?.['Status']) { + return { + description: + 'JSON-encoded Status object. For SSE, this is a string that must be parsed.', + oneOf: [ + { $ref: '#/components/schemas/Status' }, + { type: 'string', description: 'JSON-encoded Status string' }, + ], + }; + } + return { type: 'string', description: 'JSON-encoded Status' }; + + case 'notification': + // Reference to Notification entity if it exists + if (spec.components?.schemas?.['Notification']) { + return { + description: + 'JSON-encoded Notification object. For SSE, this is a string that must be parsed.', + oneOf: [ + { $ref: '#/components/schemas/Notification' }, + { + type: 'string', + description: 'JSON-encoded Notification string', + }, + ], + }; + } + return { type: 'string', description: 'JSON-encoded Notification' }; + + case 'conversation': + // Reference to Conversation entity if it exists + if (spec.components?.schemas?.['Conversation']) { + return { + description: + 'JSON-encoded Conversation object. For SSE, this is a string that must be parsed.', + oneOf: [ + { $ref: '#/components/schemas/Conversation' }, + { + type: 'string', + description: 'JSON-encoded Conversation string', + }, + ], + }; + } + return { type: 'string', description: 'JSON-encoded Conversation' }; + + case 'announcement': + // Reference to Announcement entity if it exists + if (spec.components?.schemas?.['Announcement']) { + return { + description: + 'JSON-encoded Announcement object. For SSE, this is a string that must be parsed.', + oneOf: [ + { $ref: '#/components/schemas/Announcement' }, + { + type: 'string', + description: 'JSON-encoded Announcement string', + }, + ], + }; + } + return { type: 'string', description: 'JSON-encoded Announcement' }; + + case 'reaction': + return { + description: + 'JSON-encoded reaction data. For SSE, this is a string that must be parsed.', + oneOf: [ + { $ref: '#/components/schemas/AnnouncementReactionPayload' }, + { + type: 'string', + description: 'JSON-encoded reaction data string', + }, + ], + }; + + case 'string': + return { + type: 'string', + description: 'String ID of the deleted resource', + }; + + case 'none': + // No payload for this event type + return null; + + default: + return { type: 'string' }; + } + } + + /** + * Create combined schemas for each streaming endpoint's event types + */ + private createEndpointEventSchemas(spec: OpenAPISpec): void { + // Create a general StreamingEvent schema using oneOf for all event types + const allEventRefs = STREAMING_EVENTS.map((eventDef) => ({ + $ref: `#/components/schemas/${this.getEventSchemaName(eventDef.eventName)}`, + })); + + spec.components!.schemas!['StreamingEvent'] = { + description: + 'Server-Sent Event from the Mastodon streaming API. The event field determines the type of payload.', + oneOf: allEventRefs, + } as any; + + // Create specific schemas for each endpoint type + for (const [endpoint, events] of Object.entries( + STREAMING_ENDPOINT_EVENTS + )) { + const schemaName = this.getEndpointSchemaName(endpoint); + const eventRefs = events.map((eventName) => ({ + $ref: `#/components/schemas/${this.getEventSchemaName(eventName)}`, + })); + + spec.components!.schemas![schemaName] = { + description: `Server-Sent Events for the ${endpoint} streaming endpoint`, + oneOf: eventRefs, + } as any; + } + } + + /** + * Get the schema name for an endpoint's combined events + */ + private getEndpointSchemaName(endpoint: string): string { + // Convert endpoint path to PascalCase schema name + // e.g., "/api/v1/streaming/user" -> "StreamingUserEvent" + const path = endpoint.replace('/api/v1/streaming/', '').replace(/\//g, '_'); + const parts = path.split('_'); + const pascalCase = parts + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); + return `Streaming${pascalCase}Event`; + } + + /** + * Get the schema name for an event type + */ + private getEventSchemaName(eventName: string): string { + // Convert event name to PascalCase + // e.g., "status.update" -> "StatusUpdateEvent", "announcement.reaction" -> "AnnouncementReactionEvent" + const parts = eventName.split('.'); + const pascalCase = parts + .map((part) => { + // Handle underscore_case within parts + return part + .split('_') + .map((subPart) => subPart.charAt(0).toUpperCase() + subPart.slice(1)) + .join(''); + }) + .join(''); + return `${pascalCase}Event`; + } + + /** + * Get the schema reference for a streaming endpoint + */ + public getStreamingSchemaRef(endpoint: string): OpenAPIProperty | null { + const normalizedEndpoint = endpoint.replace(/\{[^}]+\}/g, ''); + + // Check if this endpoint has specific events defined + if (STREAMING_ENDPOINT_EVENTS[normalizedEndpoint]) { + const schemaName = this.getEndpointSchemaName(normalizedEndpoint); + return { $ref: `#/components/schemas/${schemaName}` }; + } + + // Fall back to generic streaming event + return { $ref: '#/components/schemas/StreamingEvent' }; + } +} + +export { StreamingSchemaGenerator, STREAMING_ENDPOINT_EVENTS };