Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 3 additions & 9 deletions packages/mxfx/src/api/endpoints/auth/post-login-v3.ts
Original file line number Diff line number Diff line change
@@ -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),
Expand Down Expand Up @@ -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<typeof optionsSchema>) =>
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`
9 changes: 7 additions & 2 deletions packages/mxfx/src/api/endpoints/discovery/get-well-known.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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`
51 changes: 35 additions & 16 deletions packages/mxfx/src/api/endpoints/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<S extends Schema.Top> = {
path: typeof pathBrandSchema.Type
Expand All @@ -20,40 +21,58 @@ const makeEndpointFromConfig = <S extends Schema.Top>(endpoint: MatrixEndpoint<S

type PathValue = string | number

type EndpointBuilder<S extends Schema.Top> = (
strings: TemplateStringsArray,
...values: readonly (PathValue | RequestOptions.Path)[]
) => Effect.Effect<MatrixEndpoint<S>, HttpBody.HttpBodyError | Schema.SchemaError>

type EndpointBaseOptions<S extends Schema.Top> = {
schema: S
params?: UrlParams.Input
query?: RequestOptions.Query
encode?: boolean
}

type EndpointWithBodyOptions<S extends Schema.Top> = EndpointBaseOptions<S> & {
auth: boolean
body?: HttpBody.HttpBody
body?: RequestOptions.Body
}

type GetOptions<S extends Schema.Top> = EndpointBaseOptions<S> & { auth: boolean }
type WriteOptions<S extends Schema.Top> = EndpointWithBodyOptions<S>

export function makeEndpoint<S extends Schema.Top>(
method: 'GET',
options: GetOptions<S>,
): (strings: TemplateStringsArray, ...values: readonly PathValue[]) => Effect.Effect<MatrixEndpoint<S>>
export function makeEndpoint<S extends Schema.Top>(method: 'GET', options: GetOptions<S>): EndpointBuilder<S>
export function makeEndpoint<S extends Schema.Top>(
method: 'POST' | 'PUT' | 'DELETE' | 'PATCH',
options: WriteOptions<S>,
): (strings: TemplateStringsArray, ...values: readonly PathValue[]) => Effect.Effect<MatrixEndpoint<S>>
): EndpointBuilder<S>
export function makeEndpoint<S extends Schema.Top>(method: MatrixEndpoint<S>['method'], options: GetOptions<S> | WriteOptions<S>) {
return make(method, options)
}

const make = <S extends Schema.Top>(method: MatrixEndpoint<S>['method'], options: GetOptions<S> | WriteOptions<S>) => {
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<S>)
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<S>),
),
)
}
}

export const makeHttpRequest = <S extends Schema.Top>(endpoint: MatrixEndpoint<S>) =>
Expand Down
1 change: 1 addition & 0 deletions packages/mxfx/src/api/endpoints/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
4 changes: 3 additions & 1 deletion packages/mxfx/src/api/endpoints/profile/get-profile-v3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand All @@ -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:
Expand Down
59 changes: 59 additions & 0 deletions packages/mxfx/src/api/endpoints/request-options.test.ts
Original file line number Diff line number Diff line change
@@ -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')
}),
)
})
47 changes: 47 additions & 0 deletions packages/mxfx/src/api/endpoints/request-options.ts
Original file line number Diff line number Diff line change
@@ -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<unknown, never>
type PathSchema = Schema.Top & Schema.Encoder<string | number, never>
type QuerySchema = Schema.Top & Schema.Encoder<UrlParams.Input, never>

export interface Body {
readonly _tag: 'Body'
readonly effect: Effect.Effect<HttpBody.HttpBody, HttpBody.HttpBodyError>
}

export interface Path {
readonly _tag: 'Path'
readonly effect: Effect.Effect<string, Schema.SchemaError>
}

export interface Query {
readonly _tag: 'Query'
readonly effect: Effect.Effect<UrlParams.Input, Schema.SchemaError>
}

export const body = <S extends RequestSchema>(schema: S, value: S['Type']): Body => ({
_tag: 'Body',
effect: HttpBody.jsonSchema(encodeSnakeCaseSchema(schema))(value),
})

export const path = <S extends PathSchema>(schema: S, value: S['Type']): Path => ({
_tag: 'Path',
effect: Schema.encodeEffect(encodeSnakeCaseSchema(schema))(value).pipe(Effect.map(String)),
})

export const query = <S extends QuerySchema>(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
8 changes: 6 additions & 2 deletions packages/mxfx/src/api/endpoints/rooms/get-rooms-event-v3.ts
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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,
)}`
}
4 changes: 3 additions & 1 deletion packages/mxfx/src/api/endpoints/rooms/post-rooms-join-v3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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`
55 changes: 30 additions & 25 deletions packages/mxfx/src/api/endpoints/rooms/put-rooms-send-v3.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
}),
])

Expand All @@ -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)}`,
),
)
12 changes: 4 additions & 8 deletions packages/mxfx/src/api/endpoints/sync/get-sync-v3.ts
Original file line number Diff line number Diff line change
@@ -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),
Expand Down Expand Up @@ -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`
Loading