diff --git a/packages/mxfx/src/api/endpoints/auth/post-login-v3.ts b/packages/mxfx/src/api/endpoints/auth/post-login-v3.ts index f6fae69..06b66a7 100644 --- a/packages/mxfx/src/api/endpoints/auth/post-login-v3.ts +++ b/packages/mxfx/src/api/endpoints/auth/post-login-v3.ts @@ -1,8 +1,7 @@ -import { Effect, Schema } from 'effect' -import { HttpBody } from 'effect/unstable/http' +import { Schema } from 'effect' -import { encodeSnakeCaseSchema } from '../../schema/encode-case.ts' import { makeEndpoint } from '../endpoint.ts' +import * as RequestOptions from '../request-options.ts' const commonOptionsSchema = Schema.Struct({ initialDeviceDisplayName: Schema.optional(Schema.String), @@ -47,9 +46,4 @@ const schema = Schema.Struct({ * @see https://spec.matrix.org/v1.17/client-server-api/#post_matrixclientv3login */ export const postLoginV3 = (options: Schema.Schema.Type) => - Effect.gen(function* () { - //TODO: this is weird encodeSnakeCaseSchema should not be exposed like this - const body = yield* Schema.encodeEffect(optionsSchema.pipe(encodeSnakeCaseSchema))(options).pipe(Effect.andThen(HttpBody.json)) - - return yield* makeEndpoint('POST', { auth: false, body, schema })`/v3/login` - }) + makeEndpoint('POST', { auth: false, body: RequestOptions.body(optionsSchema, options), schema })`/v3/login` diff --git a/packages/mxfx/src/api/endpoints/discovery/get-well-known.ts b/packages/mxfx/src/api/endpoints/discovery/get-well-known.ts index 53de4fa..a346dae 100644 --- a/packages/mxfx/src/api/endpoints/discovery/get-well-known.ts +++ b/packages/mxfx/src/api/endpoints/discovery/get-well-known.ts @@ -1,7 +1,8 @@ import { Schema } from 'effect' -import type { ServerName } from '../../../branded/server-name.ts' +import { ServerName } from '../../../branded/server-name.ts' import { makeEndpoint } from '../endpoint.ts' +import * as RequestOptions from '../request-options.ts' const schema = Schema.Struct({ 'm.homeserver': Schema.Struct({ @@ -18,4 +19,8 @@ const schema = Schema.Struct({ * @see https://spec.matrix.org/v1.17/client-server-api/#getwell-knownmatrixclient */ export const getWellKnown = ({ serverName }: { serverName: ServerName }) => - makeEndpoint('GET', { auth: false, schema, encode: false })`https://${serverName}/.well-known/matrix/client` + makeEndpoint('GET', { + auth: false, + schema, + encode: false, + })`https://${RequestOptions.path(ServerName.schema, serverName)}/.well-known/matrix/client` diff --git a/packages/mxfx/src/api/endpoints/endpoint.ts b/packages/mxfx/src/api/endpoints/endpoint.ts index 5e92320..919f707 100644 --- a/packages/mxfx/src/api/endpoints/endpoint.ts +++ b/packages/mxfx/src/api/endpoints/endpoint.ts @@ -2,6 +2,7 @@ import { Effect, Schema } from 'effect' import { HttpBody, HttpClientRequest, HttpClientResponse, UrlParams } from 'effect/unstable/http' import { encodeSnakeCaseSchema } from '../schema/encode-case.ts' +import * as RequestOptions from './request-options.ts' export type MatrixEndpoint = { path: typeof pathBrandSchema.Type @@ -20,40 +21,58 @@ const makeEndpointFromConfig = (endpoint: MatrixEndpoint = ( + strings: TemplateStringsArray, + ...values: readonly (PathValue | RequestOptions.Path)[] +) => Effect.Effect, HttpBody.HttpBodyError | Schema.SchemaError> + type EndpointBaseOptions = { schema: S - params?: UrlParams.Input + query?: RequestOptions.Query encode?: boolean } type EndpointWithBodyOptions = EndpointBaseOptions & { auth: boolean - body?: HttpBody.HttpBody + body?: RequestOptions.Body } type GetOptions = EndpointBaseOptions & { auth: boolean } type WriteOptions = EndpointWithBodyOptions -export function makeEndpoint( - method: 'GET', - options: GetOptions, -): (strings: TemplateStringsArray, ...values: readonly PathValue[]) => Effect.Effect> +export function makeEndpoint(method: 'GET', options: GetOptions): EndpointBuilder export function makeEndpoint( method: 'POST' | 'PUT' | 'DELETE' | 'PATCH', options: WriteOptions, -): (strings: TemplateStringsArray, ...values: readonly PathValue[]) => Effect.Effect> +): EndpointBuilder export function makeEndpoint(method: MatrixEndpoint['method'], options: GetOptions | WriteOptions) { + return make(method, options) +} + +const make = (method: MatrixEndpoint['method'], options: GetOptions | WriteOptions) => { const encode = options.encode ?? true - return (strings: TemplateStringsArray, ...values: readonly PathValue[]) => - makeEndpointFromConfig({ - auth: options.auth, - method, - path: apiPath({ encode })(strings, ...values), - params: options.params, - schema: options.schema, - body: method === 'GET' ? undefined : 'body' in options ? options.body : undefined, - } as MatrixEndpoint) + return (strings: TemplateStringsArray, ...values: readonly (PathValue | RequestOptions.Path)[]) => { + const path = Effect.all( + values.map(value => (RequestOptions.isPath(value) ? RequestOptions.encodePath(value) : Effect.succeed(String(value)))), + ).pipe(Effect.map(values => apiPath({ encode })(strings, ...values))) + const params = options.query ? RequestOptions.encodeQuery(options.query) : Effect.succeed(UrlParams.empty) + const body = + method !== 'GET' && 'body' in options && options.body ? RequestOptions.encodeBody(options.body) : Effect.succeed(HttpBody.empty) + + return Effect.all({ body, params, path }).pipe( + Effect.flatMap(({ body, params, path }) => + makeEndpointFromConfig({ + auth: options.auth, + method, + path, + params, + schema: options.schema, + body: method === 'GET' ? undefined : body, + } as MatrixEndpoint), + ), + ) + } } export const makeHttpRequest = (endpoint: MatrixEndpoint) => diff --git a/packages/mxfx/src/api/endpoints/index.ts b/packages/mxfx/src/api/endpoints/index.ts index ac82c3f..36867be 100644 --- a/packages/mxfx/src/api/endpoints/index.ts +++ b/packages/mxfx/src/api/endpoints/index.ts @@ -6,3 +6,4 @@ export * from './sync/index.ts' export * from './profile/index.ts' export * from './rooms/index.ts' export * from './account/index.ts' +export * as RequestOptions from './request-options.ts' diff --git a/packages/mxfx/src/api/endpoints/profile/get-profile-v3.ts b/packages/mxfx/src/api/endpoints/profile/get-profile-v3.ts index 4c131a8..4e04794 100644 --- a/packages/mxfx/src/api/endpoints/profile/get-profile-v3.ts +++ b/packages/mxfx/src/api/endpoints/profile/get-profile-v3.ts @@ -3,6 +3,7 @@ import { Schema } from 'effect' import { MxcUri } from '../../../branded/mxc-uri.ts' import { UserId } from '../../../branded/user-id.ts' import { makeEndpoint } from '../endpoint.ts' +import * as RequestOptions from '../request-options.ts' const schema = Schema.Struct({ avatarUrl: Schema.optional(Schema.NullOr(MxcUri.schema)), @@ -21,7 +22,8 @@ const schema = Schema.Struct({ * * @see https://spec.matrix.org/v1.17/client-server-api/#get_matrixclientv3profileuserid */ -export const getProfileV3 = ({ userId }: { userId: UserId }) => makeEndpoint('GET', { auth: true, schema })`/v3/profile/${userId}` +export const getProfileV3 = ({ userId }: { userId: UserId }) => + makeEndpoint('GET', { auth: true, schema })`/v3/profile/${RequestOptions.path(UserId.schema, userId)}` /* * TODO: explore this pattern: diff --git a/packages/mxfx/src/api/endpoints/request-options.test.ts b/packages/mxfx/src/api/endpoints/request-options.test.ts new file mode 100644 index 0000000..3b34de1 --- /dev/null +++ b/packages/mxfx/src/api/endpoints/request-options.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from '@effect/vitest' +import { Effect, Schema } from 'effect' + +import { makeEndpoint } from './endpoint.ts' +import * as RequestOptions from './request-options.ts' + +const filterSchema = Schema.Struct({ + eventFields: Schema.Array(Schema.String), +}) + +describe('RequestOptions', () => { + it.effect('encodes body keys as snake_case', () => + Effect.gen(function* () { + const schema = Schema.Struct({ + userName: Schema.String, + profile: Schema.Struct({ displayName: Schema.String }), + }) + + const body = yield* RequestOptions.encodeBody(RequestOptions.body(schema, { userName: 'alice', profile: { displayName: 'Alice' } })) + + expect(body._tag).toBe('Uint8Array') + if (body._tag === 'Uint8Array') { + expect(JSON.parse(new TextDecoder().decode(body.body))).toStrictEqual({ + user_name: 'alice', + profile: { display_name: 'Alice' }, + }) + } + }), + ) + + it.effect('encodes query keys and JSON-string values as snake_case', () => + Effect.gen(function* () { + const schema = Schema.Struct({ + filter: Schema.optional(Schema.fromJsonString(filterSchema)), + fullState: Schema.Boolean, + }) + + const query = yield* RequestOptions.encodeQuery( + RequestOptions.query(schema, { filter: { eventFields: ['content.body'] }, fullState: true }), + ) + + expect(query).toStrictEqual({ + filter: '{"event_fields":["content.body"]}', + full_state: true, + }) + }), + ) + + it.effect('encodes Schema.fromJsonString path parameters before URI encoding', () => + Effect.gen(function* () { + const endpoint = yield* makeEndpoint('GET', { auth: true, schema: Schema.Unknown })`/filters/${RequestOptions.path( + Schema.fromJsonString(filterSchema), + { eventFields: ['content.body'] }, + )}` + + expect(endpoint.path).toBe('/filters/%7B%22event_fields%22%3A%5B%22content.body%22%5D%7D') + }), + ) +}) diff --git a/packages/mxfx/src/api/endpoints/request-options.ts b/packages/mxfx/src/api/endpoints/request-options.ts new file mode 100644 index 0000000..c30c46f --- /dev/null +++ b/packages/mxfx/src/api/endpoints/request-options.ts @@ -0,0 +1,47 @@ +import { Effect, Schema } from 'effect' +import { HttpBody, type UrlParams } from 'effect/unstable/http' + +import { encodeSnakeCaseSchema } from '../schema/encode-case.ts' + +type RequestSchema = Schema.Top & Schema.Encoder +type PathSchema = Schema.Top & Schema.Encoder +type QuerySchema = Schema.Top & Schema.Encoder + +export interface Body { + readonly _tag: 'Body' + readonly effect: Effect.Effect +} + +export interface Path { + readonly _tag: 'Path' + readonly effect: Effect.Effect +} + +export interface Query { + readonly _tag: 'Query' + readonly effect: Effect.Effect +} + +export const body = (schema: S, value: S['Type']): Body => ({ + _tag: 'Body', + effect: HttpBody.jsonSchema(encodeSnakeCaseSchema(schema))(value), +}) + +export const path = (schema: S, value: S['Type']): Path => ({ + _tag: 'Path', + effect: Schema.encodeEffect(encodeSnakeCaseSchema(schema))(value).pipe(Effect.map(String)), +}) + +export const query = (schema: S, value: S['Type']): Query => ({ + _tag: 'Query', + effect: Schema.encodeEffect(encodeSnakeCaseSchema(schema))(value).pipe(Effect.map(encoded => encoded as UrlParams.Input)), +}) + +export const isPath = (value: unknown): value is Path => + typeof value === 'object' && value !== null && '_tag' in value && value._tag === 'Path' + +export const encodeBody = ({ effect }: Body) => effect + +export const encodePath = ({ effect }: Path) => effect + +export const encodeQuery = ({ effect }: Query) => effect diff --git a/packages/mxfx/src/api/endpoints/rooms/get-rooms-event-v3.ts b/packages/mxfx/src/api/endpoints/rooms/get-rooms-event-v3.ts index b2954a0..ebfea0a 100644 --- a/packages/mxfx/src/api/endpoints/rooms/get-rooms-event-v3.ts +++ b/packages/mxfx/src/api/endpoints/rooms/get-rooms-event-v3.ts @@ -1,6 +1,7 @@ -import type { EventId, RoomId } from '../../../branded/index.ts' +import { EventId, RoomId } from '../../../branded/index.ts' import { roomEvent } from '../../../schema/event.ts' import { makeEndpoint } from '../endpoint.ts' +import * as RequestOptions from '../request-options.ts' const schema = roomEvent @@ -14,5 +15,8 @@ const schema = roomEvent * @see https://spec.matrix.org/v1.17/client-server-api/#get_matrixclientv3roomsroomideventeventid */ export const getRoomsEventV3 = ({ roomId, eventId }: { roomId: RoomId; eventId: EventId }) => { - return makeEndpoint('GET', { auth: true, schema })`/v3/rooms/${roomId}/event/${eventId}` + return makeEndpoint('GET', { auth: true, schema })`/v3/rooms/${RequestOptions.path(RoomId.schema, roomId)}/event/${RequestOptions.path( + EventId.schema, + eventId, + )}` } diff --git a/packages/mxfx/src/api/endpoints/rooms/post-rooms-join-v3.ts b/packages/mxfx/src/api/endpoints/rooms/post-rooms-join-v3.ts index c13cc97..2d9ae37 100644 --- a/packages/mxfx/src/api/endpoints/rooms/post-rooms-join-v3.ts +++ b/packages/mxfx/src/api/endpoints/rooms/post-rooms-join-v3.ts @@ -2,6 +2,7 @@ import { Schema } from 'effect' import { RoomId } from '../../../branded/room-id.ts' import { makeEndpoint } from '../endpoint.ts' +import * as RequestOptions from '../request-options.ts' const schema = Schema.Struct({ roomId: RoomId.schema, @@ -19,4 +20,5 @@ const schema = Schema.Struct({ * * @see https://spec.matrix.org/v1.17/client-server-api/#post_matrixclientv3roomsroomidjoin */ -export const postRoomsJoinV3 = ({ roomId }: { roomId: RoomId }) => makeEndpoint('POST', { auth: true, schema })`/v3/rooms/${roomId}/join` +export const postRoomsJoinV3 = ({ roomId }: { roomId: RoomId }) => + makeEndpoint('POST', { auth: true, schema })`/v3/rooms/${RequestOptions.path(RoomId.schema, roomId)}/join` diff --git a/packages/mxfx/src/api/endpoints/rooms/put-rooms-send-v3.ts b/packages/mxfx/src/api/endpoints/rooms/put-rooms-send-v3.ts index 99bff47..73e8edc 100644 --- a/packages/mxfx/src/api/endpoints/rooms/put-rooms-send-v3.ts +++ b/packages/mxfx/src/api/endpoints/rooms/put-rooms-send-v3.ts @@ -1,9 +1,8 @@ import { Effect, Schema, Random } from 'effect' -import { HttpBody } from 'effect/unstable/http' import { EventId, RoomId } from '../../../branded/index.ts' -import { encodeSnakeCaseSchema } from '../../schema/encode-case.ts' import { makeEndpoint } from '../endpoint.ts' +import * as RequestOptions from '../request-options.ts' const schema = Schema.Struct({ eventId: EventId.schema, @@ -19,24 +18,26 @@ const commonMessageContentSchema = Schema.Struct({ msgtype: Schema.String, }) +const messageContentSchema = Schema.Union([ + Schema.Struct({ + ...commonMessageContentSchema.fields, + 'm.newContent': Schema.Struct({ body: Schema.String, msgtype: Schema.String }), + 'm.relatesTo': Schema.Struct({ + relType: Schema.Literal('m.replace'), + eventId: EventId.schema, + }), + }), + commonMessageContentSchema, +]) + +const eventTypeSchema = Schema.Literal('m.room.message') + const optionsSchema = Schema.Union([ //TODO: support more event types Schema.Struct({ ...commonOptionsSchema.fields, - eventType: Schema.Literal('m.room.message'), - content: Schema.Union([ - Schema.Struct({ - ...commonMessageContentSchema.fields, - 'm.newContent': Schema.Struct({ body: Schema.String, msgtype: Schema.String }), - 'm.relatesTo': Schema.Struct({ - relType: Schema.Literal('m.replace'), - eventId: EventId.schema, - }), - }), - Schema.Struct({ - ...commonMessageContentSchema.fields, - }), - ]), + eventType: eventTypeSchema, + content: messageContentSchema, }), ]) @@ -53,12 +54,16 @@ const optionsSchema = Schema.Union([ * @see https://spec.matrix.org/v1.17/client-server-api/#put_matrixclientv3roomsroomidsendeventtypetxnid */ export const putRoomsSendV3 = (options: typeof optionsSchema.Type) => - Effect.gen(function* () { - const body = yield* Schema.encodeEffect(optionsSchema.pipe(encodeSnakeCaseSchema))(options).pipe( - Effect.andThen(({ content }) => HttpBody.json(content)), - ) - - const transactionId = options.transactionId ? options.transactionId : yield* Random.nextIntBetween(1, 10000000) // TODO: used be uuidv4 but was removed from random module into crypto, but haven't bothered to check how possible it is to DI anything here bc crypto gotta be injected - - return yield* makeEndpoint('PUT', { auth: true, schema, body })`/v3/rooms/${options.roomId}/send/${options.eventType}/${transactionId}` - }) + (options.transactionId ? Effect.succeed(options.transactionId) : Random.nextIntBetween(1, 10000000).pipe(Effect.map(String))).pipe( + Effect.flatMap( + transactionId => + makeEndpoint('PUT', { + auth: true, + schema, + body: RequestOptions.body(messageContentSchema, options.content), + })`/v3/rooms/${RequestOptions.path( + RoomId.schema, + options.roomId, + )}/send/${RequestOptions.path(eventTypeSchema, options.eventType)}/${RequestOptions.path(Schema.String, transactionId)}`, + ), + ) diff --git a/packages/mxfx/src/api/endpoints/sync/get-sync-v3.ts b/packages/mxfx/src/api/endpoints/sync/get-sync-v3.ts index 8d619f6..efd3d4b 100644 --- a/packages/mxfx/src/api/endpoints/sync/get-sync-v3.ts +++ b/packages/mxfx/src/api/endpoints/sync/get-sync-v3.ts @@ -1,15 +1,15 @@ -import { Effect, Schema } from 'effect' +import { Schema } from 'effect' import { EventId, RoomId } from '../../../branded/index.ts' import { baseEvent, strippedStateEvent, filterSchema } from '../../../schema/index.ts' -import { encodeSnakeCaseSchema } from '../../schema/encode-case.ts' import { AccountData, StateSchema, Timeline } from '../../schema/sync.ts' import { makeEndpoint } from '../endpoint.ts' +import * as RequestOptions from '../request-options.ts' const presenceSchema = Schema.Union([Schema.Literal('online'), Schema.Literal('offline'), Schema.Literal('unavailable')]) const optionsSchema = Schema.Struct({ - filter: Schema.optional(Schema.fromJsonString(encodeSnakeCaseSchema(filterSchema))), //TODO: remove snakecase handling here + filter: Schema.optional(Schema.fromJsonString(filterSchema)), fullState: Schema.optional(Schema.Boolean), setPresence: Schema.optional(presenceSchema), since: Schema.optional(Schema.String), @@ -105,8 +105,4 @@ const schema = getSyncV3ResponseSchema * @see https://spec.matrix.org/v1.17/client-server-api/#get_matrixclientv3sync */ export const getSyncV3 = (options: typeof optionsSchema.Type) => - Effect.gen(function* () { - const params = yield* Schema.encodeEffect(encodeSnakeCaseSchema(optionsSchema))(options) //TODO: remove snakecase handling here - - return yield* makeEndpoint('GET', { auth: true, params, schema })`/v3/sync` - }) + makeEndpoint('GET', { auth: true, query: RequestOptions.query(optionsSchema, options), schema })`/v3/sync` diff --git a/packages/mxfx/src/api/endpoints/user-directory/post-user-directory-search-v3.ts b/packages/mxfx/src/api/endpoints/user-directory/post-user-directory-search-v3.ts index 37e1fc7..c06dc37 100644 --- a/packages/mxfx/src/api/endpoints/user-directory/post-user-directory-search-v3.ts +++ b/packages/mxfx/src/api/endpoints/user-directory/post-user-directory-search-v3.ts @@ -1,9 +1,8 @@ -import { Effect, Schema } from 'effect' -import { HttpBody } from 'effect/unstable/http' +import { Schema } from 'effect' import { MxcUri } from '../../../branded/index.ts' -import { encodeSnakeCaseSchema } from '../../schema/encode-case.ts' import { makeEndpoint } from '../endpoint.ts' +import * as RequestOptions from '../request-options.ts' const optionsSchema = Schema.Struct({ limit: Schema.optional(Schema.Int), @@ -31,8 +30,4 @@ const schema = Schema.Struct({ * @see https://spec.matrix.org/v1.17/client-server-api/#post_matrixclientv3user_directorysearch */ export const postUserDirectorySearchV3 = (options: Schema.Schema.Type) => - Effect.gen(function* () { - const body = yield* Schema.encodeEffect(optionsSchema.pipe(encodeSnakeCaseSchema))(options).pipe(Effect.andThen(HttpBody.json)) - - return yield* makeEndpoint('POST', { auth: true, body, schema })`/v3/user_directory/search` - }) + makeEndpoint('POST', { auth: true, body: RequestOptions.body(optionsSchema, options), schema })`/v3/user_directory/search` diff --git a/packages/mxfx/src/api/schema/encode-case.ts b/packages/mxfx/src/api/schema/encode-case.ts index bac7c83..b0db6b4 100644 --- a/packages/mxfx/src/api/schema/encode-case.ts +++ b/packages/mxfx/src/api/schema/encode-case.ts @@ -29,6 +29,15 @@ type AnyOptionalWrapper = AnyWrapper & { readonly members: ReadonlyArray } } +type AnyFromJsonString = { + readonly from: { + readonly ast: { + readonly _tag: 'String' + readonly annotations?: { readonly contentMediaType?: string } + } + } + readonly to: Schema.Top +} const isSchemaLike = (value: unknown): value is object | Function => (typeof value === 'object' || typeof value === 'function') && value !== null @@ -49,6 +58,13 @@ const isOptionalWrapperSchema = (value: unknown): value is AnyOptionalWrapper => (value as AnyOptionalWrapper).schema !== null && 'members' in (value as AnyOptionalWrapper).schema && (value as AnyOptionalWrapper).schema.members.some(member => member.ast._tag === 'Undefined') +const isFromJsonStringSchema = (value: unknown): value is AnyFromJsonString => + isSchemaLike(value) && + 'from' in value && + 'to' in value && + isSchemaLike((value as AnyFromJsonString).from) && + (value as AnyFromJsonString).from.ast._tag === 'String' && + (value as AnyFromJsonString).from.ast.annotations?.contentMediaType === 'application/json' const encodeSnakeCaseStruct = (schema: AnyStruct): Schema.Top => { const nestedFields = Object.fromEntries( @@ -85,6 +101,10 @@ const encodeSnakeCaseSchemaInternal = (schema: Schema.Top): Schema.Top => { return member ? Schema.optional(encodeSnakeCaseSchemaInternal(member)) : schema } + if (isFromJsonStringSchema(schema)) { + return Schema.fromJsonString(encodeSnakeCaseSchemaInternal(schema.to)) + } + return schema }