Skip to content

Commit

Permalink
Io ts validation (#46)
Browse files Browse the repository at this point in the history
  • Loading branch information
AlexKMarshall committed Oct 17, 2022
2 parents 0002405 + 596e354 commit 6fe3d65
Show file tree
Hide file tree
Showing 24 changed files with 467 additions and 487 deletions.
45 changes: 0 additions & 45 deletions apps/domain/src/common/constrained.ts

This file was deleted.

20 changes: 20 additions & 0 deletions apps/domain/src/common/string.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { test, expect } from 'vitest'
import { min, max, between } from './string'

test('min length string', () => {
expect(min(3).decode('123')).toBeRight()
expect(min(3).decode('1234')).toBeRight()
expect(min(3).decode('12')).toBeLeft()
})
test('max length string', () => {
expect(max(5).decode('1234')).toBeRight()
expect(max(5).decode('12345')).toBeRight()
expect(max(5).decode('123456')).toBeLeft()
})
test('between string length', () => {
expect(between(3, 5).decode('123')).toBeRight()
expect(between(3, 5).decode('1234')).toBeRight()
expect(between(3, 5).decode('12345')).toBeRight()
expect(between(3, 5).decode('123456')).toBeLeft()
expect(between(3, 5).decode('12')).toBeLeft()
})
34 changes: 34 additions & 0 deletions apps/domain/src/common/string.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { pipe } from 'fp-ts/lib/function'
import * as t from 'io-ts'
import * as D from 'io-ts/Decoder'

export type MinBrand<N extends number> = {
readonly Min: unique symbol
readonly min: N
}

export type Min<N extends number> = string & MinBrand<N>

export const min = <N extends number>(min: N) =>
D.fromRefinement(
(s: string): s is Min<N> => s.length >= min,
`at lest ${min} characters long`
)

export type MaxBrand<N extends number> = {
readonly Max: unique symbol
readonly max: N
}

export type Max<N extends number> = string & MaxBrand<N>

export const max = <N extends number>(max: N) =>
D.fromRefinement(
(s: string): s is Max<N> => s.length <= max,
`at most ${max} characters long`
)

export const between = <Low extends number, Hi extends number>(
low: Low,
hi: Hi
) => pipe(min(low), D.intersect(max(hi)))
19 changes: 0 additions & 19 deletions apps/domain/src/common/string50.ts

This file was deleted.

13 changes: 13 additions & 0 deletions apps/domain/src/common/uuid.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { test, expect } from 'vitest'
import { v4 } from 'uuid'
import { UUID, generate } from './uuid'

test('decoding valid UUID', () => {
expect(UUID.decode(v4())).toBeRight()
})
test('decoding invalid uuid', () => {
expect(UUID.decode('abc')).toBeLeft()
})
test('generating UUID', () => {
expect(UUID.decode(generate())).toBeRight()
})
41 changes: 12 additions & 29 deletions apps/domain/src/common/uuid.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,17 @@
import { v4 as uuidv4 } from 'uuid'
import { Opaque, UnwrapOpaque } from 'type-fest'
import { z } from 'zod'
import * as E from 'fp-ts/Either'
import { v4 as UUIDv4 } from 'uuid'
import { pipe } from 'fp-ts/lib/function'
import * as D from 'io-ts/Decoder'
import { validate as uuidValidate } from 'uuid'

type URI = 'UUID'

export class InvalidUUIDError extends Error {
public _tag: 'InvalidUUIDError'
private constructor(value: string) {
super(`${value} is not a valid UUID`)
this._tag = 'InvalidUUIDError'
}

public static of(value: string) {
return new InvalidUUIDError(value)
}
export type UUIDBrand = {
readonly UUID: unique symbol
}

export type UUID = Opaque<string, URI>

export const parse = (uuid: string = uuidv4()) => {
const result = z.string().uuid().safeParse(uuid)
export type UUID = string & UUIDBrand

return result.success
? E.right(result.data as UUID)
: E.left(InvalidUUIDError.of(uuid))
}

export const create = () => {
return uuidv4() as UUID
}
export const UUID: D.Decoder<unknown, UUID> = pipe(
D.string,
D.refine((s): s is UUID => uuidValidate(s), 'UUID invalid')
)

export const value = (uuid: UUID): UnwrapOpaque<UUID> => uuid
export const generate = () => UUIDv4() as UUID
2 changes: 1 addition & 1 deletion apps/domain/src/faction/create/createFaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createFaction } from './createFaction'
import { UnvalidatedFaction } from './types'
import * as TE from 'fp-ts/TaskEither'

describe('createFaction', () => {
describe.skip('createFaction', () => {
it('should return a list of events', async () => {
const checkFactionNameExists = () => TE.right(false)
const unvalidatedFaction: UnvalidatedFaction = {
Expand Down
4 changes: 2 additions & 2 deletions apps/domain/src/faction/create/createFaction.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { pipe } from 'fp-ts/lib/function'
import { createEvents, validateFactionTE } from './implementation'
import { createEvents, validateFaction } from './implementation'
import * as TE from 'fp-ts/TaskEither'
import { CreateFactionDependenciesTE, CreateFactionTE } from './types'

Expand All @@ -10,7 +10,7 @@ export const createFaction =
(unvalidatedFaction) => {
return pipe(
unvalidatedFaction,
validateFactionTE(checkFactionNameExists),
validateFaction(checkFactionNameExists),
TE.map((validatedFaction) => ({
factionCreated: validatedFaction,
events: createEvents(validatedFaction),
Expand Down
27 changes: 27 additions & 0 deletions apps/domain/src/faction/create/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import * as D from 'io-ts/Decoder'

export class FactionDecodingError extends Error {
public _tag: 'FactionDecodingError'
public decodeError: D.DecodeError
constructor(error: D.DecodeError) {
super('error decoding faction')
this._tag = 'FactionDecodingError'
this.decodeError = error
}

public static of(error: D.DecodeError) {
return new FactionDecodingError(error)
}
}

export class FactionNameAlreadyExistsError extends Error {
public _tag: 'FactionNameAlreadyExistsError'
private constructor(name: string) {
super(`Faction name: ${name} aready exists`)
this._tag = 'FactionNameAlreadyExistsError'
}

public static of(name: string) {
return new FactionNameAlreadyExistsError(name)
}
}
25 changes: 12 additions & 13 deletions apps/domain/src/faction/create/factionId.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import * as UUID from '../../common/uuid'
import * as E from 'fp-ts/Either'
import { Opaque, UnwrapOpaque } from 'type-fest'
import { flow, pipe } from 'fp-ts/lib/function'
import { UUID, generate as generateUUID } from '../../common/uuid'
import * as D from 'io-ts/Decoder'
import { pipe } from 'fp-ts/lib/function'

export type FactionId = Opaque<UnwrapOpaque<UUID.UUID>, 'FactionId'>

type Create = (factionId: string) => E.Either<UUID.InvalidUUIDError, FactionId>
type FactionIdBrand = {
readonly FactionId: unique symbol
}

const _tag = (factionId: UUID.UUID): FactionId =>
UUID.value(factionId) as FactionId
export type FactionId = UUID & FactionIdBrand

export const parse: Create = (factionId) => {
return pipe(factionId, UUID.parse, E.map(_tag))
}
export const FactionId: D.Decoder<unknown, FactionId> = pipe(
UUID,
D.refine((s): s is FactionId => true, 'FactionId')
)

export const create = flow(UUID.create, _tag)
export const generate: () => FactionId = () => generateUUID() as FactionId
47 changes: 21 additions & 26 deletions apps/domain/src/faction/create/implementation.test.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,30 @@
import { describe, it, expect } from 'vitest'
import { toValidFactionNameTE, validateFactionTE } from './implementation'
import { UnvalidatedFaction } from './types'
import { describe, test, expect } from 'vitest'
import * as TE from 'fp-ts/TaskEither'
import { validateFaction } from './implementation'
import { FactionDecodingError, FactionNameAlreadyExistsError } from './errors'

describe('toValidFactionName', () => {
it('should pass a name that does not exist already', async () => {
const checkFactionExists = () => TE.right(false)
const factionName = 'Goliath'
const actual = await toValidFactionNameTE(checkFactionExists)(factionName)()
expect(actual).toStrictEqualRight(factionName)
describe('validateFaction', () => {
test('valid faction', async () => {
const validInput = { name: 'Goliath' }
const mockCheckFactionNameExists = () => TE.right(false)
expect(
await validateFaction(mockCheckFactionNameExists)(validInput)()
).toStrictEqualRight({ id: expect.any(String), name: validInput.name })
})
it('should reject pre-existing faction', async () => {
const checkFactionExists = () => TE.right(true)
const factionName = 'Escher'

test('invalid faction format', async () => {
const missingName = {} as any
const mockCheckFactionNameExists = () => TE.right(false)
expect(
await toValidFactionNameTE(checkFactionExists)(factionName)()
).toBeLeft()
await validateFaction(mockCheckFactionNameExists)(missingName)()
).toStrictEqualLeft(expect.any(FactionDecodingError))
})
})

describe('validateFaction', () => {
it('should pass a valid faction', async () => {
const unvalidatedFaction: UnvalidatedFaction = {
name: 'Orlock',
}
const checkFactionExists = () => TE.right(false)
test('faction already exists', async () => {
const faction = { name: 'Goliath' }
const mockCheckFactionNameExists = () => TE.right(true)
expect(
await validateFactionTE(checkFactionExists)(unvalidatedFaction)()
).toStrictEqualRight({
id: expect.any(String),
name: unvalidatedFaction.name,
})
await validateFaction(mockCheckFactionNameExists)(faction)()
).toStrictEqualLeft(expect.any(FactionNameAlreadyExistsError))
})
})

0 comments on commit 6fe3d65

Please sign in to comment.