@@ -18,6 +18,14 @@ import { OrganizationList } from "../../common/orgs.js";
1818import { CLIENT_HTTP_CACHE_POLICY , EventRepeatOptions } from "./events.js" ;
1919import rateLimiter from "api/plugins/rateLimiter.js" ;
2020import { getCacheCounter } from "api/functions/cache.js" ;
21+ import {
22+ FastifyZodOpenApiSchema ,
23+ FastifyZodOpenApiTypeProvider ,
24+ serializerCompiler ,
25+ validatorCompiler ,
26+ } from "fastify-zod-openapi" ;
27+ import { withTags } from "api/components/index.js" ;
28+ import { z } from "zod" ;
2129
2230const repeatingIcalMap : Record < EventRepeatOptions , ICalEventJSONRepeatingData > =
2331 {
@@ -36,128 +44,144 @@ function generateHostName(host: string) {
3644}
3745
3846const icalPlugin : FastifyPluginAsync = async ( fastify , _options ) => {
47+ fastify . setValidatorCompiler ( validatorCompiler ) ;
48+ fastify . setSerializerCompiler ( serializerCompiler ) ;
3949 fastify . register ( rateLimiter , {
4050 limit : OrganizationList . length ,
4151 duration : 30 ,
4252 rateLimitIdentifier : "ical" ,
4353 } ) ;
44- fastify . get ( "/:host?" , async ( request , reply ) => {
45- const host = ( request . params as Record < string , string > ) . host ;
46- let queryParams : QueryCommandInput = {
47- TableName : genericConfig . EventsDynamoTableName ,
48- } ;
49- let response ;
50- const ifNoneMatch = request . headers [ "if-none-match" ] ;
51- if ( ifNoneMatch ) {
52- const etag = await getCacheCounter (
53- fastify . dynamoClient ,
54- `events-etag-${ host || "all" } ` ,
55- ) ;
54+ fastify . withTypeProvider < FastifyZodOpenApiTypeProvider > ( ) . get (
55+ "/:host?" ,
56+ {
57+ schema : withTags ( [ "iCalendar Integration" ] , {
58+ params : z . object ( {
59+ host : z
60+ . optional ( z . enum ( OrganizationList as [ string , ...string [ ] ] ) )
61+ . openapi ( { description : "Host to get calendar for." } ) ,
62+ } ) ,
63+ } satisfies FastifyZodOpenApiSchema ) ,
64+ } ,
65+ async ( request , reply ) => {
66+ const host = request . params . host || "ACM" ;
67+ let queryParams : QueryCommandInput = {
68+ TableName : genericConfig . EventsDynamoTableName ,
69+ } ;
70+ let response ;
71+ const ifNoneMatch = request . headers [ "if-none-match" ] ;
72+ if ( ifNoneMatch ) {
73+ const etag = await getCacheCounter (
74+ fastify . dynamoClient ,
75+ `events-etag-${ host || "all" } ` ,
76+ ) ;
5677
57- if (
58- ifNoneMatch === `"${ etag . toString ( ) } "` ||
59- ifNoneMatch === etag . toString ( )
60- ) {
61- return reply
62- . code ( 304 )
63- . header ( "ETag" , etag )
64- . header ( "Cache-Control" , CLIENT_HTTP_CACHE_POLICY )
65- . send ( ) ;
66- }
78+ if (
79+ ifNoneMatch === `"${ etag . toString ( ) } "` ||
80+ ifNoneMatch === etag . toString ( )
81+ ) {
82+ return reply
83+ . code ( 304 )
84+ . header ( "ETag" , etag )
85+ . header ( "Cache-Control" , CLIENT_HTTP_CACHE_POLICY )
86+ . send ( ) ;
87+ }
6788
68- reply . header ( "etag" , etag ) ;
69- }
70- if ( host ) {
71- if ( ! OrganizationList . includes ( host ) ) {
72- throw new ValidationError ( {
73- message : `Invalid host parameter "${ host } " in path.` ,
74- } ) ;
89+ reply . header ( "etag" , etag ) ;
7590 }
76- queryParams = {
77- ...queryParams ,
78- } ;
79- response = await fastify . dynamoClient . send (
80- new QueryCommand ( {
91+ if ( host ) {
92+ if ( ! OrganizationList . includes ( host ) ) {
93+ throw new ValidationError ( {
94+ message : `Invalid host parameter "${ host } " in path.` ,
95+ } ) ;
96+ }
97+ queryParams = {
8198 ...queryParams ,
82- ExpressionAttributeValues : {
83- ":host" : {
84- S : host ,
99+ } ;
100+ response = await fastify . dynamoClient . send (
101+ new QueryCommand ( {
102+ ...queryParams ,
103+ ExpressionAttributeValues : {
104+ ":host" : {
105+ S : host ,
106+ } ,
85107 } ,
86- } ,
87- KeyConditionExpression : "host = :host" ,
88- IndexName : "HostIndex" ,
89- } ) ,
90- ) ;
91- } else {
92- response = await fastify . dynamoClient . send ( new ScanCommand ( queryParams ) ) ;
93- }
94- const dynamoItems = response . Items
95- ? response . Items . map ( ( x ) => unmarshall ( x ) )
96- : null ;
97- if ( ! dynamoItems ) {
98- throw new NotFoundError ( {
99- endpointName : host ? `/api/v1/ical/${ host } ` : "/api/v1/ical" ,
100- } ) ;
101- }
102- // generate friendly calendar name
103- let calendarName =
104- host && host . includes ( "ACM" )
105- ? `${ host } Events`
106- : `ACM@UIUC - ${ host } Events` ;
107- if ( host == "ACM" ) {
108- calendarName = "ACM@UIUC - Major Events" ;
109- }
110- if ( ! host ) {
111- calendarName = "ACM@UIUC - All Events" ;
112- }
113- const calendar = ical ( { name : calendarName } ) ;
114- calendar . timezone ( {
115- name : "America/Chicago" ,
116- generator : getVtimezoneComponent ,
117- } ) ;
118- calendar . method ( ICalCalendarMethod . PUBLISH ) ;
119- for ( const rawEvent of dynamoItems ) {
120- let event = calendar . createEvent ( {
121- start : moment . tz ( rawEvent . start , "America/Chicago" ) ,
122- end : rawEvent . end
123- ? moment . tz ( rawEvent . end , "America/Chicago" )
124- : moment . tz ( rawEvent . start , "America/Chicago" ) ,
125- summary : rawEvent . title ,
126- description : rawEvent . locationLink
127- ? `Host: ${ rawEvent . host } \nGoogle Maps Link: ${ rawEvent . locationLink } \n\n` +
128- rawEvent . description
129- : `Host: ${ rawEvent . host } \n\n` + rawEvent . description ,
130- timezone : "America/Chicago" ,
131- organizer : generateHostName ( host ) ,
132- id : rawEvent . id ,
108+ KeyConditionExpression : "host = :host" ,
109+ IndexName : "HostIndex" ,
110+ } ) ,
111+ ) ;
112+ } else {
113+ response = await fastify . dynamoClient . send (
114+ new ScanCommand ( queryParams ) ,
115+ ) ;
116+ }
117+ const dynamoItems = response . Items
118+ ? response . Items . map ( ( x ) => unmarshall ( x ) )
119+ : null ;
120+ if ( ! dynamoItems ) {
121+ throw new NotFoundError ( {
122+ endpointName : host ? `/api/v1/ical/${ host } ` : "/api/v1/ical" ,
123+ } ) ;
124+ }
125+ // generate friendly calendar name
126+ let calendarName =
127+ host && host . includes ( "ACM" )
128+ ? `${ host } Events`
129+ : `ACM@UIUC - ${ host } Events` ;
130+ if ( host == "ACM" ) {
131+ calendarName = "ACM@UIUC - Major Events" ;
132+ }
133+ if ( ! host ) {
134+ calendarName = "ACM@UIUC - All Events" ;
135+ }
136+ const calendar = ical ( { name : calendarName } ) ;
137+ calendar . timezone ( {
138+ name : "America/Chicago" ,
139+ generator : getVtimezoneComponent ,
133140 } ) ;
141+ calendar . method ( ICalCalendarMethod . PUBLISH ) ;
142+ for ( const rawEvent of dynamoItems ) {
143+ let event = calendar . createEvent ( {
144+ start : moment . tz ( rawEvent . start , "America/Chicago" ) ,
145+ end : rawEvent . end
146+ ? moment . tz ( rawEvent . end , "America/Chicago" )
147+ : moment . tz ( rawEvent . start , "America/Chicago" ) ,
148+ summary : rawEvent . title ,
149+ description : rawEvent . locationLink
150+ ? `Host: ${ rawEvent . host } \nGoogle Maps Link: ${ rawEvent . locationLink } \n\n` +
151+ rawEvent . description
152+ : `Host: ${ rawEvent . host } \n\n` + rawEvent . description ,
153+ timezone : "America/Chicago" ,
154+ organizer : generateHostName ( host ) ,
155+ id : rawEvent . id ,
156+ } ) ;
134157
135- if ( rawEvent . repeats ) {
136- if ( rawEvent . repeatEnds ) {
137- event = event . repeating ( {
138- ...repeatingIcalMap [ rawEvent . repeats as EventRepeatOptions ] ,
139- until : moment . tz ( rawEvent . repeatEnds , "America/Chicago" ) ,
158+ if ( rawEvent . repeats ) {
159+ if ( rawEvent . repeatEnds ) {
160+ event = event . repeating ( {
161+ ...repeatingIcalMap [ rawEvent . repeats as EventRepeatOptions ] ,
162+ until : moment . tz ( rawEvent . repeatEnds , "America/Chicago" ) ,
163+ } ) ;
164+ } else {
165+ event . repeating (
166+ repeatingIcalMap [ rawEvent . repeats as EventRepeatOptions ] ,
167+ ) ;
168+ }
169+ }
170+ if ( rawEvent . location ) {
171+ event = event . location ( {
172+ title : rawEvent . location ,
140173 } ) ;
141- } else {
142- event . repeating (
143- repeatingIcalMap [ rawEvent . repeats as EventRepeatOptions ] ,
144- ) ;
145174 }
146175 }
147- if ( rawEvent . location ) {
148- event = event . location ( {
149- title : rawEvent . location ,
150- } ) ;
151- }
152- }
153176
154- reply
155- . headers ( {
156- "Content-Type" : "text/calendar; charset=utf-8" ,
157- "Content-Disposition" : 'attachment; filename="calendar.ics"' ,
158- } )
159- . send ( calendar . toString ( ) ) ;
160- } ) ;
177+ reply
178+ . headers ( {
179+ "Content-Type" : "text/calendar; charset=utf-8" ,
180+ "Content-Disposition" : 'attachment; filename="calendar.ics"' ,
181+ } )
182+ . send ( calendar . toString ( ) ) ;
183+ } ,
184+ ) ;
161185} ;
162186
163187export default icalPlugin ;
0 commit comments